SLOPSHOPPER

android-device-console

Live Android device console: UI-state toggles, Doze and standby controls, WorkManager job runner, and the device state in the system prompt.

panebandguardcommandprompttool
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · android-device-console
│ ┃ Android device ✕ › fix the failing auth test and add an audit log call │ ┃ No device read yet [ Refresh ] │ ┃ ⏺ Read(src/auth.ts) │ ┃ Doze and standby ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ unknown ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ [ Force idle ] [ Unforce ] ⎿ 3 pass, 1 fail │ ┃ │ ┃ Scheduled jobs ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ None listed. Refresh after enqueueing work. ✻ Worked for 42s · done 4:20 PM │ │ › /device │ ⎿ android-device-console: No device is connected (adb devices list │ ⎿ android-device-console: No application id is known: pass package │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Android device
No device read yet [ Refresh ] Doze and standby unknown [ Force idle ] [ Unforce ] Scheduled jobs None listed. Refresh after enqueueing work.
README

android-device-console

A Claude Code mod that puts the attached Android device's live state in front of you and the model.

  • Band above the prompt (terminal and desktop): six toggles with labels that show the live state. They are Dark, Font scale (1 → 1.15 → 1.3 → 2), Density (physical → ×1.15 → ×1.3 → ×0.85), forced RTL, layout bounds, and animations. Hotkeys 1–6 work once the band has focus. The band appears after the first successful device read.
  • /device pane:
  • the same toggles, plus Reset all
  • a target picker when several devices are connected
  • Doze controls (Force idle, Unforce) and the app standby bucket
  • the app's scheduled JobScheduler/WorkManager jobs, each with a Run button and the constraints it is still waiting on

/device reset restores the defaults and takes the device out of forced idle.

  • System prompt section (android-device-console:device): says which device is targeted and its dark mode, font scale, density, RTL, layout-bounds, animation, Doze and standby state. The model then knows a screenshot was taken under, say, dark mode at font 1.3.
  • Model tools:
  • set_device_state: dark, fontScale, density or "reset", rtl, layoutBounds, animations, and restartApp.
  • run_worker: force-runs a job with cmd jobscheduler run -f, adding -n <namespace> for a job in a JobScheduler namespace. On API 34+ WorkManager 2.9+ schedules its jobs in androidx.work.systemjobscheduler. Give it a jobId, a workSpecId, or nothing when the app has exactly one job. WorkManager gives the work a new job id each time it is enqueued; when the id it was given is gone, run_worker lists the jobs again and runs the one that took its place, or asks when several could have.
  • set_doze: state idle/off and/or a standby bucket.
  • Command fixer: when the model hand-types cmd jobscheduler run through Bash, the mod rewrites the command before it runs. It adds a missing -f, the package, the job id when exactly one job is known, and -n <namespace> when the console knows the job's namespace. It also puts the package before the id. When the device answers "Could not find job" to a run without -n, the model gets a note about WorkManager's namespace. Only a command that adb shell runs is touched; the same words in a script, a grep or a file being written are left alone. It refuses the command, listing the job ids, when it can't tell which job was meant. The model gets a note about each rewrite. The mod never hooks Gradle commands.

Target device

The mod picks the target in this order:

  1. ANDROID_SERIAL
  2. the device you chose in /device
  3. the only ready device

If several devices are connected and none was chosen, the tools refuse and ask you to pick one. They never guess. Every adb call passes -s <serial>.

The app

The application id comes from the plugin option package. If that is empty, the mod reads applicationId from app/build.gradle.kts or app/build.gradle. You can also pass package to any tool.

Desktop app: permission prompts

The mod runs adb through $.process when the host provides it. $.process is documented as CLI-only, so the mod checks for it at session start. If it is missing, every adb call goes through the Bash tool, and each call raises a permission prompt. Button presses pass consent so the prompt names what you pressed.

To stop the prompts, add this to the permissions.allow list in ~/.claude/settings.json:

"Bash(adb *)"

When the mod has to use the Bash tool, it never polls: state is re-read only on /device, Refresh, or after an action. With $.process, it re-reads the state every pollSeconds (15 by default; 0 turns polling off).

Caveats

  • dumpsys output differs between API levels. The parsers read defensively and show ? for anything they can't tell.
  • Forced RTL on API 33+ gives the app a per-app RTL locale (cmd locale set-app-locales <pkg> --locales he), so it needs the app's package. The app's activities recreate right-to-left at once; the system UI stays left-to-right. Hebrew is used because an app without pseudoLocalesEnabled drops the ar-XB pseudo-locale from its resources and stays left-to-right. Text stays in the default language unless the app ships Hebrew strings. RTL off, or /device reset, puts back the locales the app had before.
  • Below API 33, or without the package, forced RTL falls back to debug.force_rtl. Current Android applies that setting only after a configuration change or a reboot, so the tool says when it used this fallback. Pass restartApp: true to relaunch the app.
  • Layout bounds are set with setprop debug.layout plus service call activity 1599295570. The second command can fail silently on some images.
  • Force idle also runs dumpsys battery unplug. Unforce or /device reset runs dumpsys battery reset.
  • FCM testing is not part of this mod.

Development

claude plugin validate .
claude plugin test .
Source 3 files
hooks/register.tsx 707 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { Device, DeviceState, ExecMode, Job } from '../types'
5import {
6  BUCKET_NAMES,
7  DEFAULTS,
8  type DeviceChanges,
9  PACKAGE,
10  changeScript,
11  describeDevice,
12  dozeScript,
13  fixJobschedulerCommand,
14  isJobMissing,
15  isRtlLocaleList,
16  jobLabel,
17  parseAppLocales,
18  nextDensity,
19  nextFontScale,
20  parseDevices,
21  parseJobs,
22  parseState,
23  pickTarget,
24  readChanges,
25  runJobScript,
26  shellQuote,
27  stateScript,
28  splitSections,
29} from './adb'
30
31type $ = EngineInterface
32
33const PLUGIN = 'android-device-console'
34const PANE = 'device-console'
35const TOOL_SET = 'mcp__android-device-console__set_device_state'
36const TOOL_RUN = 'mcp__android-device-console__run_worker'
37const TOOL_DOZE = 'mcp__android-device-console__set_doze'
38
39const mode = atom({ plugin: 'android-device-console', key: 'mode' } as const, null)
40const adbPath = atom({ plugin: 'android-device-console', key: 'adb' } as const, 'adb')
41const probeNote = atom({ plugin: 'android-device-console', key: 'probeNote' } as const, null)
42const devices = atom({ plugin: 'android-device-console', key: 'devices' } as const, [])
43const chosenSerial = atom({ plugin: 'android-device-console', key: 'chosenSerial' } as const, null)
44const device = atom({ plugin: 'android-device-console', key: 'device' } as const, null)
45const jobs = atom({ plugin: 'android-device-console', key: 'jobs' } as const, [])
46const pkgAtom = atom({ plugin: 'android-device-console', key: 'pkg' } as const, null)
47const busy = atom({ plugin: 'android-device-console', key: 'busy' } as const, null)
48const lastMessage = atom({ plugin: 'android-device-console', key: 'lastMessage' } as const, null)
49const rtlRestore = atom({ plugin: 'android-device-console', key: 'rtlRestore' } as const, null)
50
51type Ran = { ok: boolean; out: string; err: string }
52type Ctx = { serial?: string; consent?: string; pkg?: string }
53
54let configuredPkg: string | null = null
55let pollMs = 15000
56let poller: Timer | null = null
57let isPolling = false
58
59// --- host commands: $.process where the host has it, else the Bash tool --
60
61async function probe($: $): Promise<ExecMode> {
62  const known = await read($, mode)
63  if (known !== null) return known
64  const home = await $.env.get('ANDROID_HOME')
65  const sdkRoot = await $.env.get('ANDROID_SDK_ROOT')
66  const userHome = await $.env.get('HOME')
67  const candidates = [
68    'adb',
69    home && `${home}/platform-tools/adb`,
70    sdkRoot && `${sdkRoot}/platform-tools/adb`,
71    userHome && `${userHome}/Library/Android/sdk/platform-tools/adb`,
72    userHome && `${userHome}/Android/Sdk/platform-tools/adb`,
73  ].filter((c): c is string => Boolean(c))
74  let reason = ''
75  for (const candidate of candidates) {
76    try {
77      const r = await $.process.run([candidate, 'version'], { timeoutMs: 5000 })
78      if (r.exitCode === 0) {
79        await update($, adbPath, () => candidate)
80        await update($, probeNote, () => null)
81        await update($, mode, () => 'process' as const)
82        return 'process'
83      }
84      reason = r.stderr.trim() || `exit ${r.exitCode}`
85    } catch (err) {
86      reason = err instanceof Error ? err.message : String(err)
87    }
88  }
89  await update($, adbPath, () => 'adb')
90  await update($, probeNote, () => `$.process could not run adb here (${reason.slice(0, 160)}); adb runs through the Bash tool.`)
91  await update($, mode, () => 'bash' as const)
92  return 'bash'
93}
94
95async function adb($: $, args: readonly string[], why: string, consent?: string): Promise<Ran> {
96  const how = await probe($)
97  const argv = [await read($, adbPath), ...args]
98  if (how === 'process') {
99    try {
100      const r = await $.process.run(argv, { timeoutMs: 30000 })
101      return { ok: r.exitCode === 0, out: r.stdout, err: r.stderr }
102    } catch (err) {
103      return { ok: false, out: '', err: err instanceof Error ? err.message : String(err) }
104    }
105  }
106  const ran = await $.tool.call({
107    tool: 'Bash',
108    command: shellQuote(argv),
109    description: why,
110    ...(consent ? { consent } : {}),
111  })
112  if (ran.deny !== undefined) return { ok: false, out: '', err: ran.deny }
113  if (ran.isError) return { ok: false, out: '', err: ran.text ?? String(ran.result) }
114  return { ok: true, out: ran.result.stdout, err: ran.result.stderr }
115}
116
117// --- target device and package -------------------------------------------
118
119async function refreshDevices($: $, consent?: string): Promise<Device[]> {
120  const r = await adb($, ['devices', '-l'], 'List connected Android devices', consent)
121  const list = r.ok ? parseDevices(r.out) : []
122  await update($, devices, () => list)
123  return list
124}
125
126async function target($: $, ctx: Ctx): Promise<{ serial: string } | { error: string }> {
127  if (ctx.serial) return { serial: ctx.serial }
128  const env = await $.env.get('ANDROID_SERIAL')
129  if (env) return { serial: env }
130  let list = await read($, devices)
131  if (list.filter(d => d.state === 'device').length === 0) list = await refreshDevices($, ctx.consent)
132  return pickTarget(list, undefined, await read($, chosenSerial))
133}
134
135async function resolvePkg($: $, explicit?: string): Promise<string | null> {
136  if (explicit && PACKAGE.test(explicit)) return explicit
137  if (configuredPkg) return configuredPkg
138  const cached = await read($, pkgAtom)
139  if (cached !== null) return cached
140  for (const file of ['app/build.gradle.kts', 'app/build.gradle']) {
141    try {
142      const text = await $.fs.read(file)
143      const id = /applicationId\s*=?\s*["']([\w.]+)["']/.exec(text)?.[1]
144      if (id) {
145        await update($, pkgAtom, () => id)
146        return id
147      }
148    } catch {
149      // not this file
150    }
151  }
152  return null
153}
154
155async function shell($: $, ctx: Ctx, script: string, why: string): Promise<{ serial: string; ran: Ran } | { error: string }> {
156  const t = await target($, ctx)
157  if ('error' in t) return t
158  const ran = await adb($, ['-s', t.serial, 'shell', script], why, ctx.consent)
159  if (!ran.ok && /not found|offline|unauthorized|no devices/i.test(ran.err)) {
160    await update($, devices, () => [])
161  }
162  return { serial: t.serial, ran }
163}
164
165async function storeState($: $, serial: string, stdout: string, pkg: string | null): Promise<DeviceState> {
166  const state = parseState(serial, stdout, await $.clock.now())
167  await update($, device, () => state)
168  if (pkg !== null) await update($, pkgAtom, () => pkg)
169  return state
170}
171
172// --- actions shared by the tools, the band, the pane and /device ---------
173
174async function withBusy<T>($: $, label: string, work: () => Promise<T>): Promise<T> {
175  await update($, busy, () => label)
176  try {
177    return await work()
178  } finally {
179    await update($, busy, () => null)
180  }
181}
182
183async function refreshState($: $, ctx: Ctx): Promise<string> {
184  const pkg = await resolvePkg($, ctx.pkg)
185  const r = await shell($, ctx, stateScript(pkg), 'Read Android device UI and Doze state')
186  if ('error' in r) return r.error
187  if (!r.ran.ok) return `Reading device state failed: ${r.ran.err.trim()}`
188  const state = await storeState($, r.serial, r.ran.out, pkg)
189  return describeDevice(state, pkg)
190}
191
192async function setDeviceState($: $, changes: DeviceChanges, ctx: Ctx & { restartApp?: boolean }): Promise<{ text: string } | { error: string }> {
193  const pkg = await resolvePkg($, ctx.pkg)
194  const options = { restartPkg: ctx.restartApp ? pkg : null, pkg, restoreLocales: await read($, rtlRestore) }
195  const script = `echo @@out; ${changeScript(changes, options)}; ${stateScript(pkg)}`
196  const r = await shell($, ctx, script, 'Set Android device UI state')
197  if ('error' in r) return r
198  if (!r.ran.ok) return { error: `adb failed: ${r.ran.err.trim() || r.ran.out.trim()}` }
199  const sections = splitSections(r.ran.out)
200  if (changes.rtl === true) {
201    // Keep the app's own locales to put back, unless they are already an RTL one.
202    const before = parseAppLocales(sections.get('rtlprev'))
203    if (before !== null && !isRtlLocaleList(before)) await update($, rtlRestore, () => before)
204  } else if (changes.rtl === false) {
205    await update($, rtlRestore, () => null)
206  }
207  const state = await storeState($, r.serial, r.ran.out, pkg)
208  return { text: `Applied on ${r.serial}.\n${describeDevice(state, pkg)}${rtlNote(changes, sections.get('rtlvia'), ctx.restartApp === true)}` }
209}
210
211function rtlNote(changes: DeviceChanges, via: string | undefined, isRestarted: boolean): string {
212  if (changes.rtl !== true) return ''
213  if (via === 'app-locale') {
214    return (
215      "\nForced RTL through the app's per-app locale (he, Hebrew): its activities recreate right-to-left at once. " +
216      'Text stays in the default language unless the app ships Hebrew strings; system UI stays left-to-right. ' +
217      'RTL off, or reset, puts back the locales the app had.'
218    )
219  }
220  return (
221    '\nForced RTL through the developer setting debug.force_rtl' +
222    (isRestarted ? ' and an app restart' : '') +
223    '. On Android 13+ it takes effect only after a configuration change or a reboot; ' +
224    'with the app package known, API 33+ uses a per-app RTL locale instead, which applies at once.'
225  )
226}
227
228async function setDoze($: $, input: { state?: 'idle' | 'off'; bucket?: string }, ctx: Ctx): Promise<{ text: string } | { error: string }> {
229  const pkg = await resolvePkg($, ctx.pkg)
230  if (input.bucket !== undefined && pkg === null) {
231    return { error: 'A standby bucket needs the application id: pass package, or set it in the plugin options.' }
232  }
233  const script = `echo @@out; ${dozeScript(input.state, input.bucket, pkg)}; ${stateScript(pkg)}`
234  const r = await shell($, ctx, script, 'Change Android Doze or app standby state')
235  if ('error' in r) return r
236  if (!r.ran.ok) return { error: `adb failed: ${r.ran.err.trim() || r.ran.out.trim()}` }
237  const state = await storeState($, r.serial, r.ran.out, pkg)
238  const out = splitSections(r.ran.out).get('out') ?? ''
239  return { text: `${out ? `${out}\n` : ''}${describeDevice(state, pkg)}` }
240}
241
242async function refreshJobs($: $, ctx: Ctx): Promise<{ jobs: Job[]; pkg: string } | { error: string }> {
243  const pkg = await resolvePkg($, ctx.pkg)
244  if (pkg === null) return { error: 'No application id is known: pass package, or set it in the plugin options.' }
245  const r = await shell($, ctx, `dumpsys jobscheduler ${pkg}`, `List scheduled jobs for ${pkg}`)
246  if ('error' in r) return r
247  if (!r.ran.ok) return { error: `dumpsys jobscheduler failed: ${r.ran.err.trim()}` }
248  const list = parseJobs(r.ran.out, pkg)
249  await update($, jobs, () => list)
250  return { jobs: list, pkg }
251}
252
253type RunInput = { jobId?: number; workSpecId?: string; namespace?: string }
254
255async function runWorker($: $, input: RunInput, ctx: Ctx): Promise<{ text: string } | { error: string }> {
256  const pkg = await resolvePkg($, ctx.pkg)
257  if (pkg === null) return { error: 'No application id is known: pass package, or set it in the plugin options.' }
258  const named = (list: readonly Job[]) =>
259    list.filter(job => job.id === input.jobId && (input.namespace === undefined || job.namespace === input.namespace))
260
261  let job: Job | undefined
262  let note = ''
263  if (input.jobId === undefined) {
264    const listed = await refreshJobs($, { ...ctx, pkg })
265    if ('error' in listed) return listed
266    const matches = input.workSpecId ? listed.jobs.filter(one => one.workSpecId === input.workSpecId) : listed.jobs
267    if (matches.length !== 1) return { error: pickError(pkg, listed.jobs, matches.length, input.workSpecId) }
268    job = matches[0]!
269  } else {
270    job = named(await read($, jobs))[0]
271    if (job === undefined) {
272      const listed = await refreshJobs($, { ...ctx, pkg })
273      if ('error' in listed) return listed
274      job = named(listed.jobs)[0]
275      if (job === undefined) {
276        const stale = replacement(listed.jobs, input.jobId)
277        if ('error' in stale) return { error: `${stale.error} ${pickError(pkg, listed.jobs, listed.jobs.length)}` }
278        ;({ job, note } = stale)
279      }
280    }
281  }
282
283  let r = await shell($, ctx, runJobScript(pkg, job), `Force-run job ${job.id} of ${pkg}`)
284  if ('error' in r) return r
285  let out = `${r.ran.out}${r.ran.err}`.trim()
286  if (isJobMissing(out)) {
287    // WorkManager gives the work a new job id each time it is enqueued.
288    const listed = await refreshJobs($, { ...ctx, pkg })
289    if ('error' in listed) return listed
290    const stale = replacement(listed.jobs, job.id, job)
291    if ('error' in stale) return { error: `Job ${jobLabel(job)} did not run: ${out}. ${pickError(pkg, listed.jobs, listed.jobs.length)}` }
292    ;({ job, note } = stale)
293    r = await shell($, ctx, runJobScript(pkg, job), `Force-run job ${job.id} of ${pkg}`)
294    if ('error' in r) return r
295    out = `${r.ran.out}${r.ran.err}`.trim()
296  }
297  if (!r.ran.ok || isJobMissing(out) || /unknown|error|exception/i.test(out)) {
298    return { error: `Job ${jobLabel(job)} did not run: ${out}` }
299  }
300  return {
301    text: `Forced job ${jobLabel(job)} of ${pkg} on ${r.serial}: ${out || 'started'}.${note} Read logcat for the Worker's output.`,
302  }
303}
304
305/** The job that took a gone id's place: the only one left, or the only one like the old. */
306function replacement(list: readonly Job[], goneId: number, like?: Job): { job: Job; note: string } | { error: string } {
307  const same = like ? list.filter(one => one.namespace === like.namespace && one.component === like.component) : []
308  const pick = same.length === 1 ? same[0]! : list.length === 1 ? list[0]! : undefined
309  if (pick === undefined) return { error: `Job ${goneId} is no longer scheduled.` }
310  return {
311    job: pick,
312    note: ` Job ${goneId} is no longer scheduled; ran job ${pick.id}, which took its place (WorkManager gives work a new job id on each enqueue).`,
313  }
314}
315
316function pickError(pkg: string, all: readonly Job[], matched: number, workSpecId?: string): string {
317  const list = all.map(jobLabel).join(', ')
318  return matched === 0
319    ? `No scheduled job for ${pkg}${workSpecId ? ` with WorkSpec ${workSpecId}` : ''}. Jobs: ${list || 'none'}. Enqueue the work first.`
320    : `${matched} jobs are scheduled for ${pkg}: ${list}. Pass jobId (and namespace when two share an id) or workSpecId.`
321}
322
323async function resetAll($: $, ctx: Ctx): Promise<string> {
324  const set = await setDeviceState($, DEFAULTS, ctx)
325  if ('error' in set) return set.error
326  const doze = await setDoze($, { state: 'off' }, ctx)
327  return 'error' in doze ? doze.error : `Reset to defaults.\n${doze.text}`
328}
329
330// --- polling: only through $.process, never through the Bash tool -------
331
332async function pollOnce($: $): Promise<void> {
333  if (isPolling) return
334  isPolling = true
335  try {
336    if ((await read($, mode)) !== 'process') return
337    await refreshDevices($)
338    const t = await target($, {})
339    if ('error' in t) {
340      await update($, device, () => null)
341      return
342    }
343    await refreshState($, { serial: t.serial })
344  } catch {
345    // the next period tries again
346  } finally {
347    isPolling = false
348  }
349}
350
351async function ensurePoller($: $): Promise<void> {
352  if (poller !== null || pollMs === 0) return
353  if ((await probe($)) !== 'process') return
354  poller = $.clock.every(pollMs, () => void pollOnce($))
355}
356
357function field(e: unknown, key: string): unknown {
358  return (e as Record<string, unknown>)[key]
359}
360function str(e: unknown, key: string): string | undefined {
361  const v = field(e, key)
362  return typeof v === 'string' && v ? v : undefined
363}
364
365/** A pane key per job: its id, and its namespace when it has one (ids repeat across namespaces). */
366function jobKey(job: Job): string {
367  return job.namespace ? `${job.id}-${job.namespace}` : String(job.id)
368}
369
370function press(label: string): string {
371  return `The user pressed "${label}" in the Android device console`
372}
373
374async function toggleAction($: $, label: string, changes: DeviceChanges): Promise<void> {
375  const r = await withBusy($, label, () => setDeviceState($, changes, { consent: press(label) }))
376  await update($, lastMessage, () => ('error' in r ? r.error : `${label}: done`))
377}
378
379function toggles($: $, state: DeviceState) {
380  const font = state.fontScale ?? 1
381  const nextDpi = nextDensity(state)
382  return [
383    { key: 'dark', hotkey: '1', label: `Dark ${state.isDark ? 'on' : 'off'}`, changes: { dark: !state.isDark } },
384    { key: 'font', hotkey: '2', label: `Font ${font}×`, changes: { fontScale: nextFontScale(font) } },
385    {
386      key: 'density',
387      hotkey: '3',
388      label: `Density ${state.density ?? '?'}${state.density !== state.physicalDensity ? '*' : ''}`,
389      changes: { density: nextDpi },
390    },
391    { key: 'rtl', hotkey: '4', label: `RTL ${state.isRtl ? 'on' : 'off'}`, changes: { rtl: !state.isRtl } },
392    {
393      key: 'bounds',
394      hotkey: '5',
395      label: `Bounds ${state.hasLayoutBounds ? 'on' : 'off'}`,
396      changes: { layoutBounds: !state.hasLayoutBounds },
397    },
398    {
399      key: 'anims',
400      hotkey: '6',
401      label: `Anims ${state.hasAnimations === false ? 'off' : 'on'}`,
402      changes: { animations: state.hasAnimations === false },
403    },
404  ].map(t => ({ ...t, onPress: () => void toggleAction($, t.label, t.changes) }))
405}
406
407export const register: Register = (on, options) => {
408  configuredPkg = typeof options.package === 'string' && options.package.trim() ? options.package.trim() : null
409  pollMs = typeof options.pollSeconds === 'number' ? Math.max(0, options.pollSeconds) * 1000 : 15000
410  poller = null
411  isPolling = false
412
413  // --- session start: register, probe, first read ---------------------------
414
415  on('session.start', async ($, e, next) => {
416    await $.tool.register({
417      name: 'set_device_state',
418      description:
419        'Set live UI state on the attached Android device or emulator through adb: dark mode, font scale, ' +
420        'display density, forced RTL, layout bounds, animations. Answers the device state afterwards. ' +
421        'Use before judging a screenshot under a configuration (for example dark + font 1.3 + RTL).',
422      isDeferred: false,
423      inputSchema: {
424        type: 'object',
425        properties: {
426          dark: { type: 'boolean', description: 'Night mode on (true) or off (false).' },
427          fontScale: { type: 'number', description: 'System font scale, 0.5 to 3 (1 is default).' },
428          density: {
429            description: 'Display density in dpi, or "reset" for the physical density.',
430            anyOf: [{ type: 'integer' }, { type: 'string', enum: ['reset'] }],
431          },
432          rtl: { type: 'boolean', description: 'Force RTL layout direction.' },
433          layoutBounds: { type: 'boolean', description: 'Show layout bounds.' },
434          animations: { type: 'boolean', description: 'false sets all three animation scales to 0.' },
435          restartApp: { type: 'boolean', description: 'Force-stop and relaunch the app afterwards (needed for RTL).' },
436          serial: { type: 'string', description: 'adb serial; defaults to ANDROID_SERIAL or the one device.' },
437          package: { type: 'string', description: 'applicationId; defaults to the project app.' },
438        },
439      },
440    })
441    await $.tool.register({
442      name: 'run_worker',
443      description:
444        "Force-run a scheduled WorkManager/JobScheduler job of the app (`cmd jobscheduler run -f`, with `-n <namespace>` on API 34+ " +
445        "where WorkManager schedules in androidx.work.systemjobscheduler), ignoring its constraints. With no jobId it lists the app's " +
446        'jobs and runs the only one, or the one whose WorkSpec id matches. Job ids change each time work is enqueued; a gone id is re-resolved.',
447      isDeferred: false,
448      inputSchema: {
449        type: 'object',
450        properties: {
451          jobId: { type: 'integer', description: 'JobScheduler job id.' },
452          workSpecId: { type: 'string', description: 'WorkManager WorkSpec UUID (EXTRA_WORK_SPEC_ID; dumpsys shows it only before API 34).' },
453          namespace: { type: 'string', description: 'JobScheduler namespace, when two jobs share an id. Found from the job list otherwise.' },
454          serial: { type: 'string' },
455          package: { type: 'string' },
456        },
457      },
458    })
459    await $.tool.register({
460      name: 'set_doze',
461      description:
462        "Force the device into Doze deep idle ('idle', also unplugs the battery) or back out ('off'), " +
463        'and/or set the app standby bucket. Answers the Doze and bucket state afterwards.',
464      isDeferred: false,
465      inputSchema: {
466        type: 'object',
467        properties: {
468          state: { type: 'string', enum: ['idle', 'off'] },
469          bucket: { type: 'string', enum: [...BUCKET_NAMES] },
470          serial: { type: 'string' },
471          package: { type: 'string' },
472        },
473      },
474    })
475    await $.command.register({
476      name: 'device',
477      description: 'Android device console: UI toggles, Doze, standby bucket, jobs. `/device reset` restores defaults.',
478      argumentHint: '[reset]',
479    })
480    const started = await next(e)
481    if ((await probe($)) === 'process') {
482      void pollOnce($)
483      await ensurePoller($)
484    }
485    return started
486  })
487
488  // --- /device ---------------------------------------------------------------
489
490  on('command.run', { command: 'device' }, async ($, e) => {
491    const consent = `The user ran /device ${e.args}`.trim()
492    await ensurePoller($)
493    if (e.args.trim() === 'reset') {
494      const text = await withBusy($, 'resetting', () => resetAll($, { consent }))
495      await update($, lastMessage, () => text.split('\n')[0] ?? null)
496      return { text }
497    }
498    await $.ui.open({ id: PANE, title: 'Android device', focus: true })
499    const text = await withBusy($, 'reading', async () => {
500      const state = await refreshState($, { consent })
501      const listed = await refreshJobs($, { consent })
502      const jobLine = 'error' in listed ? listed.error : `${listed.jobs.length} scheduled job(s) for ${listed.pkg}.`
503      return `${state}\n${jobLine}`
504    })
505    const note = await read($, probeNote)
506    return { text: note ? `${text}\n${note}` : text }
507  })
508
509  // --- model tools -----------------------------------------------------------
510
511  on('tool.call', { tool: TOOL_SET }, async ($, e) => {
512    const changes = readChanges(e as Record<string, unknown>)
513    if ('error' in changes) return { deny: changes.error }
514    await ensurePoller($)
515    const r = await withBusy($, 'setting state', () =>
516      setDeviceState($, changes, {
517        serial: str(e, 'serial'),
518        pkg: str(e, 'package'),
519        restartApp: field(e, 'restartApp') === true,
520      }),
521    )
522    return 'error' in r ? { deny: r.error } : { result: r.text }
523  }).catch(() => ({ deny: 'android-device-console: set_device_state failed inside the plugin.' }))
524
525  on('tool.call', { tool: TOOL_RUN }, async ($, e) => {
526    const jobId = field(e, 'jobId')
527    if (jobId !== undefined && !Number.isInteger(jobId)) return { deny: 'jobId must be an integer.' }
528    const r = await withBusy($, 'running job', () =>
529      runWorker(
530        $,
531        { jobId: jobId as number | undefined, workSpecId: str(e, 'workSpecId'), namespace: str(e, 'namespace') },
532        { serial: str(e, 'serial'), pkg: str(e, 'package') },
533      ),
534    )
535    return 'error' in r ? { deny: r.error } : { result: r.text }
536  }).catch(() => ({ deny: 'android-device-console: run_worker failed inside the plugin.' }))
537
538  on('tool.call', { tool: TOOL_DOZE }, async ($, e) => {
539    const state = field(e, 'state')
540    const bucket = field(e, 'bucket')
541    if (state !== undefined && state !== 'idle' && state !== 'off') return { deny: "state must be 'idle' or 'off'." }
542    if (bucket !== undefined && !(BUCKET_NAMES as readonly unknown[]).includes(bucket)) {
543      return { deny: `bucket must be one of ${BUCKET_NAMES.join(', ')}.` }
544    }
545    if (state === undefined && bucket === undefined) return { deny: 'Name state, bucket, or both.' }
546    const r = await withBusy($, 'changing Doze', () =>
547      setDoze(
548        $,
549        { state: state as 'idle' | 'off' | undefined, bucket: bucket as string | undefined },
550        { serial: str(e, 'serial'), pkg: str(e, 'package') },
551      ),
552    )
553    return 'error' in r ? { deny: r.error } : { result: r.text }
554  }).catch(() => ({ deny: 'android-device-console: set_doze failed inside the plugin.' }))
555
556  // --- fix the model's hand-typed `cmd jobscheduler run` ----------------------
557
558  on('tool.call', { tool: 'Bash', command: /\bcmd\s+jobscheduler\s+run\b/ }, async ($, e, next) => {
559    const env = await $.env.get('ANDROID_SERIAL')
560    const ready = (await read($, devices)).filter(d => d.state === 'device')
561    const chosen = await read($, chosenSerial)
562    const fix = fixJobschedulerCommand(e.command, {
563      pkg: await resolvePkg($),
564      jobs: await read($, jobs),
565      serialToInsert: !env && ready.length > 1 && chosen !== null ? chosen : null,
566    })
567    if ('deny' in fix) return { deny: fix.deny }
568    const command = fix.notes.length === 0 ? e.command : fix.command
569    const ran = await next(command === e.command ? e : { ...e, command })
570    if (ran.deny !== undefined) return ran
571    const notes: string[] = []
572    if (fix.notes.length > 0) notes.push(`android-device-console rewrote the command (${fix.notes.join('; ')}): ${command}`)
573    const said = `${ran.text ?? ''}\n${ran.isError ? String(ran.result ?? '') : ''}`
574    if (isJobMissing(said) && !/\s(-n|--namespace)\s/.test(command)) {
575      notes.push(
576        'android-device-console: on API 34+ WorkManager 2.9+ schedules its jobs in the JobScheduler namespace ' +
577          'androidx.work.systemjobscheduler, and the job id changes on each enqueue. Retry with ' +
578          '`-n androidx.work.systemjobscheduler`, or call mcp__android-device-console__run_worker, which resolves both.',
579      )
580    }
581    return notes.length === 0 ? ran : { ...ran, context: [...(ran.context ?? []), ...notes] }
582  }).catch(($, e, next) => next(e))
583
584  // --- system prompt: the device state the model judges UI under ------------
585
586  on('prompt.compose', async ($, e, next) => {
587    const composed = await next(e)
588    const state = await read($, device)
589    if (state === null) return composed
590    const section = { id: `${PLUGIN}:device`, text: describeDevice(state, await read($, pkgAtom)), scope: 'session' as const }
591    return { sections: [...composed.sections, section] }
592  })
593
594  // --- drawing ---------------------------------------------------------------
595
596  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
597    const state = await read($, device)
598    if (e.props.hasSurvey || state === null) return next(e)
599    const working = await read($, busy)
600    const { Box, Text, Button } = $.ui.resolve(e)
601    return (
602      <Box flexDirection="row" flexWrap="wrap" columnGap={1}>
603        <Text dimColor>{state.model ?? state.serial}{working ? ` · ${working}…` : ''}</Text>
604        {toggles($, state).map(t => (
605          <Button key={t.key} hotkey={t.hotkey} label={t.label} onPress={t.onPress} />
606        ))}
607      </Box>
608    )
609  })
610
611  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
612    const els = $.ui.resolve(e)
613    const { Box, Text, Button } = els
614    const state = await read($, device)
615    const list = await read($, devices)
616    const chosen = await read($, chosenSerial)
617    const jobList = await read($, jobs)
618    const pkg = await read($, pkgAtom)
619    const working = await read($, busy)
620    const message = await read($, lastMessage)
621    const note = await read($, probeNote)
622    const ready = list.filter(d => d.state === 'device')
623    const consent = (label: string) => press(label)
624
625    const refresh = () =>
626      void withBusy($, 'reading', async () => {
627        await refreshDevices($, consent('Refresh'))
628        await refreshState($, { consent: consent('Refresh') })
629        await refreshJobs($, { consent: consent('Refresh') })
630      })
631    const doze = (label: string, input: { state?: 'idle' | 'off'; bucket?: string }) =>
632      void withBusy($, label, async () => {
633        const r = await setDoze($, input, { consent: consent(label) })
634        await update($, lastMessage, () => ('error' in r ? r.error : `${label}: done`))
635      })
636    const run = (job: Job) =>
637      void withBusy($, `running ${job.id}`, async () => {
638        const r = await runWorker($, { jobId: job.id, namespace: job.namespace ?? undefined }, { consent: consent(`Run job ${job.id}`) })
639        await update($, lastMessage, () => ('error' in r ? r.error : r.text))
640      })
641
642    const Select = 'Select' in els ? els.Select : null
643
644    return (
645      <Box flexDirection="column" rowGap={1}>
646        <Box flexDirection="row" columnGap={1} flexWrap="wrap">
647          <Text bold>{state ? `${state.model ?? ''} ${state.serial}${state.api ? ` · API ${state.api}` : ''}` : 'No device read yet'}</Text>
648          {working ? <Text dimColor>{working}…</Text> : null}
649          <Button key="refresh" hotkey="r" label="Refresh" onPress={refresh} />
650        </Box>
651        {ready.length > 1 && Select ? (
652          <Select
653            key="serial"
654            label="Target"
655            value={chosen ?? undefined}
656            options={ready.map(d => ({ value: d.serial, label: d.model ? `${d.serial} ${d.model}` : d.serial }))}
657            onSelect={value => void update($, chosenSerial, () => value).then(refresh)}
658          />
659        ) : null}
660        {state ? (
661          <Box flexDirection="row" columnGap={1} flexWrap="wrap">
662            {toggles($, state).map(t => (
663              <Button key={t.key} hotkey={t.hotkey} label={t.label} onPress={t.onPress} />
664            ))}
665            <Button key="reset" label="Reset all" onPress={() => void withBusy($, 'resetting', async () => {
666              const text = await resetAll($, { consent: consent('Reset all') })
667              await update($, lastMessage, () => text.split('\n')[0] ?? null)
668            })} />
669          </Box>
670        ) : null}
671        <Text bold>Doze and standby{pkg ? ` · ${pkg}` : ''}</Text>
672        <Text dimColor>
673          {state ? `deep ${state.deepIdle ?? '?'} · light ${state.lightIdle ?? '?'} · bucket ${state.bucket ?? '?'}` : 'unknown'}
674        </Text>
675        <Box flexDirection="row" columnGap={1} flexWrap="wrap">
676          <Button key="idle" hotkey="i" label="Force idle" onPress={() => doze('Force idle', { state: 'idle' })} />
677          <Button key="unforce" hotkey="u" label="Unforce" onPress={() => doze('Unforce', { state: 'off' })} />
678          {Select && pkg ? (
679            <Select
680              key="bucket"
681              label="Bucket"
682              value={state?.bucket && (BUCKET_NAMES as readonly string[]).includes(state.bucket) ? state.bucket : undefined}
683              options={BUCKET_NAMES.map(b => ({ value: b }))}
684              onSelect={value => doze(`Bucket ${value}`, { bucket: value })}
685            />
686          ) : null}
687        </Box>
688        <Text bold>Scheduled jobs</Text>
689        {jobList.length === 0 ? <Text dimColor>None listed. Refresh after enqueueing work.</Text> : null}
690        {jobList.map(job => (
691          <Box key={`job-${jobKey(job)}`} flexDirection="row" columnGap={1}>
692            <Button key={`run-${jobKey(job)}`} label={`Run ${job.id}`} onPress={() => run(job)} />
693            <Text wrap="truncate-end">
694              {job.component.split('/').pop()}
695              {job.namespace === 'androidx.work.systemjobscheduler' ? ' · WorkManager' : job.namespace ? ` · ${job.namespace}` : ''}
696              {job.workSpecId ? ` · ${job.workSpecId.slice(0, 8)}` : ''}
697              {job.unsatisfied ? ` · waiting on ${job.unsatisfied}` : ''}
698            </Text>
699          </Box>
700        ))}
701        {message ? <Text dimColor>{message}</Text> : null}
702        {note ? <Text dimColor>{note}</Text> : null}
703      </Box>
704    )
705  })
706}
707
hooks/adb.ts 571 lines
1// Pure helpers: adb argv and device-shell scripts, and parsers for what the
2// device prints. dumpsys/settings output differs by API level, so every
3// parser reads defensively and answers null for what it cannot tell.
4
5import type { Device, DeviceState, Job } from '../types'
6
7export const SAFE_WORD = /^[\w@%+=:,./-]+$/
8
9export function shellQuote(argv: readonly string[]): string {
10  return argv
11    .map(arg => (SAFE_WORD.test(arg) ? arg : `'${arg.replaceAll("'", `'\\''`)}'`))
12    .join(' ')
13}
14
15export const PACKAGE = /^[A-Za-z][\w]*(\.[A-Za-z_][\w]*)+$/
16
17// --- devices --------------------------------------------------------------
18
19export function parseDevices(stdout: string): Device[] {
20  const devices: Device[] = []
21  for (const raw of stdout.split('\n')) {
22    const line = raw.trim()
23    if (line === '' || line.startsWith('List of devices') || line.startsWith('*')) continue
24    const [serial, state] = line.split(/\s+/)
25    if (serial === undefined || state === undefined) continue
26    const model = /\bmodel:(\S+)/.exec(line)?.[1] ?? null
27    devices.push({ serial, state, model })
28  }
29  return devices
30}
31
32export type Target = { serial: string } | { error: string }
33
34export function pickTarget(
35  devices: readonly Device[],
36  envSerial: string | undefined,
37  chosen: string | null,
38): Target {
39  if (envSerial) return { serial: envSerial }
40  const ready = devices.filter(d => d.state === 'device')
41  if (chosen !== null && ready.some(d => d.serial === chosen)) return { serial: chosen }
42  if (ready.length === 1) return { serial: ready[0]!.serial }
43  if (ready.length === 0) {
44    const other = devices.map(d => `${d.serial} (${d.state})`).join(', ')
45    return {
46      error: other
47        ? `No device is ready: ${other}.`
48        : 'No device is connected (adb devices lists none).',
49    }
50  }
51  const list = ready.map(d => d.serial + (d.model ? ` ${d.model}` : '')).join(', ')
52  return {
53    error: `${ready.length} devices are connected (${list}). Pick one in /device, pass serial, or set ANDROID_SERIAL.`,
54  }
55}
56
57// --- device state ---------------------------------------------------------
58
59export function stateScript(pkg: string | null): string {
60  const parts = [
61    'echo @@model; getprop ro.product.model',
62    'echo @@api; getprop ro.build.version.sdk',
63    'echo @@night; cmd uimode night',
64    'echo @@font; settings get system font_scale',
65    'echo @@density; wm density',
66    'echo @@rtl; settings get global debug.force_rtl',
67    'echo @@layout; getprop debug.layout',
68    'echo @@anim; settings get global animator_duration_scale',
69    'echo @@deep; dumpsys deviceidle get deep',
70    'echo @@light; dumpsys deviceidle get light',
71  ]
72  if (pkg !== null && PACKAGE.test(pkg)) {
73    parts.push(`echo @@bucket; am get-standby-bucket ${pkg}`, `echo @@applocales; cmd locale get-app-locales ${pkg} 2>&1`)
74  }
75  return parts.join('; ')
76}
77
78export function splitSections(stdout: string): Map<string, string> {
79  const sections = new Map<string, string>()
80  let current = ''
81  let lines: string[] = []
82  const flush = () => {
83    if (current) sections.set(current, lines.join('\n').trim())
84  }
85  for (const line of stdout.replaceAll('\r', '').split('\n')) {
86    const marker = /^@@(\w+)$/.exec(line.trim())
87    if (marker) {
88      flush()
89      current = marker[1]!
90      lines = []
91    } else {
92      lines.push(line)
93    }
94  }
95  flush()
96  return sections
97}
98
99const BUCKETS: Record<string, string> = {
100  '5': 'exempted',
101  '10': 'active',
102  '20': 'working_set',
103  '30': 'frequent',
104  '40': 'rare',
105  '45': 'restricted',
106  '50': 'never',
107}
108
109export const BUCKET_NAMES = ['active', 'working_set', 'frequent', 'rare', 'restricted'] as const
110
111function isUnset(text: string | undefined): boolean {
112  return text === undefined || text === '' || text === 'null'
113}
114
115function looksLikeError(text: string): boolean {
116  return /unknown|not found|error|exception|usage:|permission denial/i.test(text)
117}
118
119/** Languages written right to left (`ar-XB`, Android's RTL pseudo-locale, included). */
120const RTL_LANGUAGES = /^(ar|fa|he|iw|ur|yi|ps|ckb|dv|ug|sd)([-_]|$)/i
121
122/**
123 * The RTL locale set as the app's own (per-app) locale to force RTL on API 33+.
124 * Not the `ar-XB` pseudo-locale: an app without `pseudoLocalesEnabled` drops it
125 * from its resources, so the configuration says ldrtl while the UI stays LTR
126 * (seen on an API 35 emulator). Hebrew keeps Latin digits, and an app with no
127 * Hebrew strings keeps its default text.
128 */
129export const RTL_LOCALE = 'he'
130
131const LOCALE_LIST = /^[A-Za-z0-9_,-]*$/
132
133/** `Locales for <pkg> for user 0 are [ar-XB,en-US]` -> `ar-XB,en-US`; null when unreadable. */
134export function parseAppLocales(text: string | undefined): string | null {
135  const list = text === undefined ? undefined : /\bare \[([^\]]*)\]/.exec(text)?.[1]
136  return list === undefined ? null : list.replace(/\s+/g, '')
137}
138
139export function isRtlLocaleList(list: string | null): boolean {
140  return list !== null && RTL_LANGUAGES.test(list.split(',')[0] ?? '')
141}
142
143function firstWord(text: string | undefined): string | null {
144  if (text === undefined || text === '' || looksLikeError(text)) return null
145  return text.split(/\s+/)[0] ?? null
146}
147
148export function parseState(serial: string, stdout: string, readAt: number): DeviceState {
149  const s = splitSections(stdout)
150  const night = s.get('night') ?? ''
151  const font = s.get('font')
152  const density = s.get('density') ?? ''
153  const physical = /Physical density:\s*(\d+)/i.exec(density)?.[1]
154  const override = /Override density:\s*(\d+)/i.exec(density)?.[1]
155  const rtl = s.get('rtl')
156  const appLocales = parseAppLocales(s.get('applocales'))
157  const layout = s.get('layout')
158  const anim = s.get('anim')
159  const bucketRaw = s.get('bucket')
160  const api = Number.parseInt(s.get('api') ?? '', 10)
161  const fontScale = isUnset(font) ? 1 : Number.parseFloat(font!)
162  const animScale = isUnset(anim) ? 1 : Number.parseFloat(anim!)
163  let bucket: string | null = null
164  if (bucketRaw !== undefined && bucketRaw !== '' && !looksLikeError(bucketRaw)) {
165    const word = bucketRaw.split(/\s+/)[0]!
166    bucket = BUCKETS[word] ?? word.toLowerCase()
167  }
168
169  return {
170    serial,
171    model: s.get('model') || null,
172    api: Number.isFinite(api) ? api : null,
173    isDark: /night mode:\s*yes/i.test(night) ? true : /night mode:\s*no/i.test(night) ? false : null,
174    fontScale: Number.isFinite(fontScale) ? fontScale : null,
175    density: override ? Number(override) : physical ? Number(physical) : null,
176    physicalDensity: physical ? Number(physical) : null,
177    isRtl: rtl === undefined && appLocales === null ? null : rtl?.trim() === '1' || isRtlLocaleList(appLocales),
178    appLocales,
179    hasLayoutBounds: layout === undefined ? null : layout.trim() === 'true',
180    hasAnimations: Number.isFinite(animScale) ? animScale !== 0 : null,
181    deepIdle: firstWord(s.get('deep')),
182    lightIdle: firstWord(s.get('light')),
183    bucket,
184    readAt,
185  }
186}
187
188// --- changes --------------------------------------------------------------
189
190export type DeviceChanges = {
191  dark?: boolean
192  fontScale?: number
193  density?: number | 'reset'
194  rtl?: boolean
195  layoutBounds?: boolean
196  animations?: boolean
197}
198
199export function readChanges(input: Record<string, unknown>): DeviceChanges | { error: string } {
200  const changes: DeviceChanges = {}
201  const bool = (key: string): boolean | undefined | 'bad' => {
202    const v = input[key]
203    if (v === undefined || v === null) return undefined
204    return typeof v === 'boolean' ? v : 'bad'
205  }
206  for (const key of ['dark', 'rtl', 'layoutBounds', 'animations'] as const) {
207    const v = bool(key)
208    if (v === 'bad') return { error: `${key} must be true or false.` }
209    if (v !== undefined) changes[key] = v
210  }
211  const font = input.fontScale
212  if (font !== undefined && font !== null) {
213    if (typeof font !== 'number' || !(font >= 0.5 && font <= 3)) {
214      return { error: 'fontScale must be a number from 0.5 to 3.' }
215    }
216    changes.fontScale = font
217  }
218  const density = input.density
219  if (density !== undefined && density !== null) {
220    if (density === 'reset') changes.density = 'reset'
221    else if (typeof density === 'number' && Number.isInteger(density) && density >= 72 && density <= 1000) {
222      changes.density = density
223    } else return { error: 'density must be an integer dpi from 72 to 1000, or "reset".' }
224  }
225  if (Object.keys(changes).length === 0) {
226    return { error: 'Name at least one of dark, fontScale, density, rtl, layoutBounds, animations.' }
227  }
228  return changes
229}
230
231export type ChangeOptions = {
232  /** Force-stop and relaunch this app afterwards. */
233  restartPkg?: string | null
234  /** The app whose per-app locale carries forced RTL on API 33+. */
235  pkg?: string | null
236  /** The app's own locales to put back when RTL goes off (`''` for none). */
237  restoreLocales?: string | null
238}
239
240const SDK_33 = '[ "$(getprop ro.build.version.sdk)" -ge 33 ]'
241
242// Forced RTL: `debug.force_rtl` is applied only on a configuration change or a
243// reboot on current Android (an app restart is not enough on API 35), so on
244// API 33+ the app gets an RTL per-app locale instead, which recreates its
245// activities at once. Without the app's package, or below API 33, the
246// developer setting is the fallback. `@@rtlvia` says which one ran.
247function rtlScript(on: boolean, pkg: string | null, restore: string | null): string[] {
248  const hasPkg = pkg !== null && PACKAGE.test(pkg)
249  const legacy = (v: 0 | 1) => `settings put global debug.force_rtl ${v}; setprop debug.force_rtl ${v} 2>/dev/null`
250  if (on) {
251    if (!hasPkg) return [legacy(1), 'echo @@rtlvia; echo force-rtl']
252    return [
253      `if ${SDK_33}; then echo @@rtlprev; cmd locale get-app-locales ${pkg}; ` +
254        `cmd locale set-app-locales ${pkg} --locales ${RTL_LOCALE}; echo @@rtlvia; echo app-locale; ` +
255        `else ${legacy(1)}; echo @@rtlvia; echo force-rtl; fi`,
256    ]
257  }
258  const cmds = [legacy(0)]
259  if (hasPkg) {
260    const back = restore !== null && restore !== '' && LOCALE_LIST.test(restore) ? ` --locales ${restore}` : ''
261    // Only an RTL per-app locale is taken back: one the person chose stays.
262    cmds.push(
263      `if ${SDK_33} && cmd locale get-app-locales ${pkg} | grep -qE "\\[(ar|fa|he|iw|ur|yi|ps|ckb|dv|ug|sd)[]_,-]"; ` +
264        `then cmd locale set-app-locales ${pkg}${back}; fi`,
265    )
266  }
267  return cmds
268}
269
270export function changeScript(changes: DeviceChanges, options: ChangeOptions = {}): string {
271  const cmds: string[] = []
272  if (changes.dark !== undefined) cmds.push(`cmd uimode night ${changes.dark ? 'yes' : 'no'}`)
273  if (changes.fontScale !== undefined) cmds.push(`settings put system font_scale ${changes.fontScale}`)
274  if (changes.density !== undefined) {
275    cmds.push(changes.density === 'reset' ? 'wm density reset' : `wm density ${changes.density}`)
276  }
277  if (changes.rtl !== undefined) cmds.push(...rtlScript(changes.rtl, options.pkg ?? null, options.restoreLocales ?? null))
278  if (changes.layoutBounds !== undefined) {
279    cmds.push(
280      `setprop debug.layout ${changes.layoutBounds ? 'true' : 'false'}`,
281      'service call activity 1599295570 >/dev/null 2>&1',
282    )
283  }
284  if (changes.animations !== undefined) {
285    const v = changes.animations ? 1 : 0
286    for (const key of ['window_animation_scale', 'transition_animation_scale', 'animator_duration_scale']) {
287      cmds.push(`settings put global ${key} ${v}`)
288    }
289  }
290  const restart = options.restartPkg ?? null
291  if (restart !== null && PACKAGE.test(restart)) {
292    cmds.push(
293      `am force-stop ${restart}`,
294      `monkey -p ${restart} -c android.intent.category.LAUNCHER 1 >/dev/null 2>&1`,
295    )
296  }
297  return cmds.join('; ')
298}
299
300export const DEFAULTS: DeviceChanges = {
301  dark: false,
302  fontScale: 1,
303  density: 'reset',
304  rtl: false,
305  layoutBounds: false,
306  animations: true,
307}
308
309export function dozeScript(state: 'idle' | 'off' | undefined, bucket: string | undefined, pkg: string | null): string {
310  const cmds: string[] = []
311  if (state === 'idle') {
312    cmds.push('dumpsys deviceidle enable', 'dumpsys battery unplug', 'dumpsys deviceidle force-idle')
313  }
314  if (state === 'off') cmds.push('dumpsys deviceidle unforce', 'dumpsys battery reset')
315  if (bucket !== undefined && pkg !== null) cmds.push(`am set-standby-bucket ${pkg} ${bucket}`)
316  return cmds.join('; ')
317}
318
319// --- cycles the band steps through ----------------------------------------
320
321export const FONT_STEPS = [1, 1.15, 1.3, 2] as const
322
323export function nextFontScale(current: number | null): number {
324  const now = current ?? 1
325  return FONT_STEPS.find(step => step > now + 0.001) ?? 1
326}
327
328export function nextDensity(state: DeviceState): number | 'reset' {
329  const physical = state.physicalDensity ?? state.density
330  if (physical === null) return 'reset'
331  const steps = [Math.round(physical * 1.15), Math.round(physical * 1.3), Math.round(physical * 0.85)]
332  const now = state.density ?? physical
333  if (now === physical) return steps[0]!
334  const at = steps.indexOf(now)
335  return at === -1 || at === steps.length - 1 ? 'reset' : steps[at + 1]!
336}
337
338// --- jobs -----------------------------------------------------------------
339
340// `JOB #u0a190/7: 4d5e6f7 com.example/.Svc` before API 34, and from API 34 a job
341// in a JobScheduler namespace (WorkManager 2.9+ uses one) reads
342// `JOB androidx.work.systemjobscheduler:u0a215/2: 67db562 @androidx.work.systemjobscheduler@com.example/...`.
343const JOB_LINE = /JOB (?:#|([^\s:#]+):)[^\s:/]*\/(-?\d+):\s+\S+\s+(?:@([^@\s]+)@)?([\w.]+)\/(\S+)/
344
345/** WorkManager's JobScheduler namespace on API 34+. */
346export const WORKMANAGER_NAMESPACE = 'androidx.work.systemjobscheduler'
347
348export function parseJobs(stdout: string, pkg: string | null): Job[] {
349  const jobs = new Map<string, Job>()
350  let current: Job | null = null
351  for (const raw of stdout.replaceAll('\r', '').split('\n')) {
352    const job = JOB_LINE.exec(raw)
353    if (job) {
354      const id = Number(job[2])
355      const namespace = job[1] ?? job[3] ?? null
356      const owner = job[4]!
357      if (pkg !== null && owner !== pkg) {
358        current = null
359        continue
360      }
361      const key = `${namespace ?? ''}/${id}`
362      current = jobs.get(key) ?? {
363        id,
364        namespace,
365        component: `${owner}/${job[5]}`,
366        workSpecId: null,
367        required: null,
368        unsatisfied: null,
369      }
370      jobs.set(key, current)
371      continue
372    }
373    if (current === null) continue
374    if (raw.trim() !== '' && !/^\s/.test(raw)) {
375      current = null
376      continue
377    }
378    const required = /Required constraints:\s*(.*)$/.exec(raw)?.[1]
379    if (required !== undefined && current.required === null) current.required = required.trim() || null
380    const unsatisfied = /Unsatisfied constraints:\s*(.*)$/.exec(raw)?.[1]
381    if (unsatisfied !== undefined && current.unsatisfied === null) {
382      current.unsatisfied = unsatisfied.trim() || null
383    }
384    const spec = /EXTRA_WORK_SPEC_ID=([\w-]+)/.exec(raw)?.[1]
385    if (spec !== undefined && current.workSpecId === null) current.workSpecId = spec
386  }
387  return [...jobs.values()]
388}
389
390/** The force-run command for one job, `-n` naming its namespace when it has one. */
391export function runJobScript(pkg: string, job: { id: number; namespace: string | null }): string {
392  return ['cmd jobscheduler run -f', job.namespace ? `-n ${job.namespace}` : '', pkg, String(job.id)]
393    .filter(Boolean)
394    .join(' ')
395}
396
397export function jobLabel(job: Job): string {
398  const extra = [
399    job.namespace ? `namespace ${job.namespace}` : null,
400    job.workSpecId ? `WorkSpec ${job.workSpecId}` : null,
401  ].filter(Boolean)
402  return `${job.id}${extra.length > 0 ? ` (${extra.join(', ')})` : ''}`
403}
404
405/** True when `cmd jobscheduler run` answered that the job does not exist. */
406export function isJobMissing(output: string): boolean {
407  return /Could not find job|no such job/i.test(output)
408}
409
410// --- the jobscheduler command fixer ----------------------------------------
411
412export type FixContext = {
413  pkg: string | null
414  jobs: readonly Job[]
415  serialToInsert: string | null
416}
417
418export type Fix = { command: string; notes: string[] } | { deny: string }
419
420const RUN = /\bcmd\s+jobscheduler\s+run\b/g
421const SEGMENT_END = /[;&|'"\n)`]/
422/** `adb [-s X] shell ` (optionally opening a quote) right before the match. */
423const ADB_SHELL_BEFORE = /\badb\b[^;&|\n]*?\bshell\s+["']?\s*$/
424
425export function fixJobschedulerCommand(command: string, ctx: FixContext): Fix {
426  const notes: string[] = []
427  let out = ''
428  let from = 0
429  for (const match of command.matchAll(RUN)) {
430    const start = match.index
431    if (!ADB_SHELL_BEFORE.test(command.slice(0, start))) continue
432    const afterRun = start + match[0].length
433    const rest = command.slice(afterRun)
434    const end = rest.search(SEGMENT_END)
435    const tail = end === -1 ? rest : rest.slice(0, end)
436    const trailing = /\s*$/.exec(tail)?.[0] ?? ''
437    const tokens = tail.trim().split(/\s+/).filter(Boolean)
438
439    let isForced = false
440    let isSatisfied = false
441    let hasNamespace = false
442    const flags: string[] = []
443    const positional: string[] = []
444    let isUnknown = false
445    for (let i = 0; i < tokens.length; i++) {
446      const t = tokens[i]!
447      if (t === '-f' || t === '--force') isForced = true
448      else if (t === '-s' || t === '--satisfied') isSatisfied = true
449      else if (['-u', '--user', '-n', '--namespace'].includes(t) && tokens[i + 1] !== undefined) {
450        if (t === '-n' || t === '--namespace') hasNamespace = true
451        flags.push(t, tokens[++i]!)
452      } else if (t.startsWith('-') && !/^-\d+$/.test(t)) flags.push(t)
453      else positional.push(t)
454    }
455    let pkg: string | null = null
456    let id: string | null = null
457    for (const p of positional) {
458      if (/^-?\d+$/.test(p) && id === null) id = p
459      else if (PACKAGE.test(p) && pkg === null) pkg = p
460      else isUnknown = true
461    }
462    if (isUnknown) continue
463
464    const segmentNotes: string[] = []
465    if (positional.length === 2 && positional[0] === id) segmentNotes.push('put the package before the job id')
466    if (!isForced && !isSatisfied) {
467      isForced = true
468      segmentNotes.push('added -f so the job runs even with unmet constraints')
469    }
470    if (pkg === null) {
471      if (ctx.pkg === null) {
472        return {
473          deny:
474            'android-device-console: `cmd jobscheduler run` needs the package before the job id ' +
475            '(`cmd jobscheduler run -f <package> <job-id>`), and no application id is known. ' +
476            'Add the package, or call mcp__android-device-console__run_worker.',
477        }
478      }
479      pkg = ctx.pkg
480      segmentNotes.push(`added the package ${pkg}`)
481    }
482    const known = ctx.jobs.filter(job => job.component.startsWith(`${pkg}/`))
483    let picked: Job | undefined
484    if (id === null) {
485      if (known.length === 1) {
486        picked = known[0]!
487        id = String(picked.id)
488        segmentNotes.push(`added job id ${id}, the only job scheduled for ${pkg}`)
489      } else {
490        const list = known.map(jobLabel).join(', ')
491        return {
492          deny:
493            `android-device-console: \`cmd jobscheduler run\` needs a job id after ${pkg}. ` +
494            (known.length > 1
495              ? `Scheduled jobs for ${pkg}: ${list}. Pick one.`
496              : `No scheduled job for ${pkg} is known; list them with \`adb shell dumpsys jobscheduler ${pkg}\`.`) +
497            ' Or call mcp__android-device-console__run_worker.',
498        }
499      }
500    }
501    if (!hasNamespace) {
502      const sameId = known.filter(job => String(job.id) === id)
503      const ns = picked?.namespace ?? (sameId.length === 1 ? sameId[0]!.namespace : null)
504      if (ns) {
505        flags.push('-n', ns)
506        segmentNotes.push(`added -n ${ns}, the JobScheduler namespace job ${id} is scheduled in`)
507      }
508    }
509    if (segmentNotes.length === 0) continue
510
511    const rebuilt = ['cmd jobscheduler run', isForced ? '-f' : '', isSatisfied ? '-s' : '', ...flags, pkg, id]
512      .filter(Boolean)
513      .join(' ')
514    out += command.slice(from, start) + rebuilt + trailing
515    from = afterRun + tail.length
516    notes.push(...segmentNotes)
517  }
518  let fixed = out + command.slice(from)
519
520  if (ctx.serialToInsert !== null && notes.length > 0 && !/\badb\s+-[sdet]\b/.test(fixed)) {
521    const before = fixed
522    fixed = fixed.replace(/\badb(\s+)shell\b/g, `adb -s ${ctx.serialToInsert}$1shell`)
523    if (fixed !== before) notes.push(`targeted ${ctx.serialToInsert} with -s`)
524  }
525  return { command: fixed, notes }
526}
527
528// --- the system-prompt section --------------------------------------------
529
530function onOff(value: boolean | null, unknown = 'unknown'): string {
531  return value === null ? unknown : value ? 'on' : 'off'
532}
533
534export function describeDevice(state: DeviceState, pkg: string | null): string {
535  const name = [state.serial, state.model, state.api !== null ? `API ${state.api}` : null]
536    .filter(Boolean)
537    .join(', ')
538  const density =
539    state.density === null
540      ? 'unknown'
541      : state.physicalDensity !== null && state.density !== state.physicalDensity
542        ? `${state.density} dpi (overridden; physical ${state.physicalDensity})`
543        : `${state.density} dpi (default)`
544  const ui = [
545    `dark mode ${onOff(state.isDark, 'auto/unknown')}`,
546    `font scale ${state.fontScale ?? 'unknown'}${state.fontScale !== null && state.fontScale !== 1 ? ' (not default)' : ''}`,
547    `density ${density}`,
548    `forced RTL ${onOff(state.isRtl)}${state.appLocales ? ` (app locales ${state.appLocales})` : ''}`,
549    `layout bounds ${onOff(state.hasLayoutBounds)}`,
550    `animations ${onOff(state.hasAnimations)}`,
551  ].join(', ')
552  const bg = [
553    state.deepIdle !== null ? `Doze deep ${state.deepIdle}` : null,
554    state.lightIdle !== null ? `light ${state.lightIdle}` : null,
555    pkg !== null && state.bucket !== null ? `${pkg} standby bucket ${state.bucket}` : null,
556  ]
557    .filter(Boolean)
558    .join(', ')
559  return [
560    `# Attached Android device (android-device-console)`,
561    `Target: ${name}.`,
562    `UI state now: ${ui}.`,
563    bg ? `Background state: ${bg}.` : '',
564    'Account for these when you judge a screenshot or UI behaviour on this device. ' +
565      'Change them with mcp__android-device-console__set_device_state and set_doze; ' +
566      'force-run a WorkManager job with run_worker.',
567  ]
568    .filter(Boolean)
569    .join('\n')
570}
571
types/index.d.ts 52 lines
1export type ExecMode = 'process' | 'bash'
2
3export type Device = { serial: string; state: string; model: string | null }
4
5export type DeviceState = {
6  serial: string
7  model: string | null
8  api: number | null
9  isDark: boolean | null
10  fontScale: number | null
11  density: number | null
12  physicalDensity: number | null
13  isRtl: boolean | null
14  /** The app's own (per-app) locales, `''` for none; null where unreadable. */
15  appLocales: string | null
16  hasLayoutBounds: boolean | null
17  hasAnimations: boolean | null
18  deepIdle: string | null
19  lightIdle: string | null
20  bucket: string | null
21  readAt: number
22}
23
24export type Job = {
25  id: number
26  /** The JobScheduler namespace (API 34+), such as WorkManager's; null for the default. */
27  namespace: string | null
28  component: string
29  workSpecId: string | null
30  required: string | null
31  unsatisfied: string | null
32}
33
34declare module 'claude-code' {
35  interface PluginState {
36    'android-device-console': {
37      mode: ExecMode | null
38      adb: string
39      probeNote: string | null
40      devices: Device[]
41      chosenSerial: string | null
42      device: DeviceState | null
43      jobs: Job[]
44      pkg: string | null
45      busy: string | null
46      lastMessage: string | null
47      /** The app's locales before forced RTL replaced them, put back when RTL goes off. */
48      rtlRestore: string | null
49    }
50  }
51}
52