@phone-use/sdk 0.4.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,890 @@
1
+ import { execFile, spawn } from 'node:child_process';
2
+ import { writeFile } from 'node:fs/promises';
3
+ import { promisify } from 'node:util';
4
+ import { BaseDeviceBackend } from '../backend.ts';
5
+ import type { DeviceConfig } from '../config.ts';
6
+ import type { OpenAppResult, PressTarget, Rect, ScrollDirection, Snapshot } from '../device.ts';
7
+ import { ActionFailedError, DeviceNotFoundError, PhoneUseError, TimeoutError } from '../errors.ts';
8
+ import { defaultExecRunner, type ExecRunner, isExecError } from '../exec.ts';
9
+ import { createDeviceHandle, type Device } from '../lifecycle.ts';
10
+ import { parseHierarchy } from './android-hierarchy.ts';
11
+
12
+ // ---------------------------------------------------------------------------
13
+ // The Android backend + engine: a real device or emulator over adb. adb already
14
+ // exposes everything the DeviceBackend contract wants, with no daemon:
15
+ //
16
+ // eyes `uiautomator dump` → the accessibility tree (labels, ids, bounds)
17
+ // `screencap -p` → a PNG of the screen
18
+ // hands `input tap/text/swipe/keyevent`
19
+ //
20
+ // Every device-side command is built from an argv and shell-quoted here,
21
+ // because `adb shell` joins its arguments with spaces and hands the string to
22
+ // the device's /bin/sh unescaped ("just like the real shell does", per adb's
23
+ // own source). Model-controlled strings — app names, URLs, typed text — reach
24
+ // this file, so nothing may be interpolated into a shell string raw.
25
+ //
26
+ // Verified on real hardware (OnePlus Nord AC2001, Android 12) and API 33/36
27
+ // emulators, including the full AndroidWorld suite (docs/41).
28
+ // ---------------------------------------------------------------------------
29
+
30
+ /** Android keycodes used below (KeyEvent constants). */
31
+ const KEYCODE = { home: 3, back: 4, enter: 66, del: 67, moveEnd: 123 } as const;
32
+
33
+ /** Where uiautomator writes its dump: /data/local/tmp round-trips on scoped-storage builds, /sdcard does not. */
34
+ const DUMP_PATH = '/data/local/tmp/phone-use-dump.xml';
35
+
36
+ /** A package id as Android accepts it: dotted Java-style segments. */
37
+ const PACKAGE_RE = /^[A-Za-z][\w]*(\.[A-Za-z][\w]*)+$/;
38
+ /** `com.pkg/.Activity` as `cmd package resolve-activity --brief` prints it. */
39
+ const ACTIVITY_RE = /^[\w.]+\/[\w.$]+$/;
40
+
41
+ /** Characters that need no quoting for the device's /bin/sh. */
42
+ const SHELL_SAFE = /^[A-Za-z0-9_@%+=:,./-]+$/;
43
+
44
+ /**
45
+ * Quote one argument for the device shell. `adb shell` does not escape its
46
+ * arguments, so this is the injection boundary for every device command.
47
+ */
48
+ export function shellQuote(arg: string): string {
49
+ if (arg.length > 0 && SHELL_SAFE.test(arg)) return arg;
50
+ return `'${arg.replace(/'/g, `'\\''`)}'`;
51
+ }
52
+
53
+ /** True when every char is printable ASCII — the range `input text` can deliver. */
54
+ export function isPrintableAscii(text: string): boolean {
55
+ for (let i = 0; i < text.length; i += 1) {
56
+ const c = text.charCodeAt(i);
57
+ if (c < 0x20 || c > 0x7e) return false;
58
+ }
59
+ return true;
60
+ }
61
+
62
+ /** Binary-stdout runner for `adb exec-out` (screencap). Resolves with the raw bytes. */
63
+ export type BinaryExecRunner = (
64
+ file: string,
65
+ args: string[],
66
+ opts?: { timeoutMs?: number | undefined },
67
+ ) => Promise<Uint8Array>;
68
+
69
+ const pExecFile = promisify(execFile);
70
+
71
+ const defaultBinaryExecRunner: BinaryExecRunner = async (file, args, opts) => {
72
+ const { stdout } = await pExecFile(file, args, {
73
+ encoding: 'buffer',
74
+ // A screencap PNG on a 1440×3200 panel can run past 10 MB.
75
+ maxBuffer: 64 * 1024 * 1024,
76
+ ...(opts?.timeoutMs === undefined ? {} : { timeout: opts.timeoutMs }),
77
+ });
78
+ return new Uint8Array(stdout);
79
+ };
80
+
81
+ const defaultSleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));
82
+
83
+ /** Options for {@link AndroidBackend} / {@link createAndroidBackend}. */
84
+ export type AndroidBackendOptions = {
85
+ /** adb serial to target (`adb -s`); omit when exactly one device is attached. */
86
+ serial?: string | undefined;
87
+ /** Path to the adb binary (default: `adb` on PATH). */
88
+ adbBin?: string | undefined;
89
+ /** Per-command ceiling in ms before a hung adb call rejects with TimeoutError (default 30_000). */
90
+ commandTimeoutMs?: number | undefined;
91
+ /** @internal test seam — DI'd process runner. */
92
+ exec?: ExecRunner | undefined;
93
+ /** @internal test seam — DI'd binary-stdout runner (screencap). */
94
+ execBinary?: BinaryExecRunner | undefined;
95
+ /** @internal test seam — DI'd delay. */
96
+ sleep?: ((ms: number) => Promise<void>) | undefined;
97
+ };
98
+
99
+ /**
100
+ * Drive an Android device or emulator over adb. Implements the
101
+ * {@link DeviceBackend} contract with uiautomator for the tree, screencap for
102
+ * pixels, and `input` for gestures. Stateless across calls except the ref → rect
103
+ * map from the last snapshot (what makes press-by-ref work).
104
+ */
105
+ export class AndroidBackend extends BaseDeviceBackend {
106
+ private readonly serial: string | undefined;
107
+ private readonly adbBin: string;
108
+ private readonly timeoutMs: number;
109
+ private readonly exec: ExecRunner;
110
+ private readonly execBinary: BinaryExecRunner;
111
+ private readonly sleep: (ms: number) => Promise<void>;
112
+ private rects = new Map<string, Rect>();
113
+ private sizeCache: { width: number; height: number } | null = null;
114
+ private launchablesCache: string[] | null = null;
115
+
116
+ constructor(opts: AndroidBackendOptions = {}) {
117
+ super('android-adb', [
118
+ 'snapshot',
119
+ 'screenshot',
120
+ 'press',
121
+ 'longPress',
122
+ 'fill',
123
+ 'type',
124
+ 'key',
125
+ 'scroll',
126
+ 'pan',
127
+ 'waitForText',
128
+ 'home',
129
+ 'back',
130
+ 'openApp',
131
+ 'openUrl',
132
+ 'listApps',
133
+ 'closeSession',
134
+ ]);
135
+ this.serial = opts.serial;
136
+ this.adbBin = opts.adbBin ?? 'adb';
137
+ this.timeoutMs = opts.commandTimeoutMs ?? 30_000;
138
+ this.exec = opts.exec ?? defaultExecRunner;
139
+ this.execBinary = opts.execBinary ?? defaultBinaryExecRunner;
140
+ this.sleep = opts.sleep ?? defaultSleep;
141
+ }
142
+
143
+ // ---- transport ----------------------------------------------------------
144
+
145
+ private withSerial(args: string[]): string[] {
146
+ return this.serial ? ['-s', this.serial, ...args] : args;
147
+ }
148
+
149
+ /** Normalize an exec failure into the PhoneUseError the contract promises. */
150
+ private fail(what: string, err: unknown): PhoneUseError {
151
+ if (err instanceof PhoneUseError) return err;
152
+ if (isExecError(err)) {
153
+ const stderr = (err.stderr ?? '').trim();
154
+ if (err.killed || err.signal) {
155
+ return new TimeoutError(`adb ${what} timed out after ${this.timeoutMs}ms`, { cause: err });
156
+ }
157
+ if (err.code === 'ENOENT') {
158
+ return new ActionFailedError(
159
+ `adb not found at "${this.adbBin}" — install the Android platform-tools or pass adbBin`,
160
+ { cause: err },
161
+ );
162
+ }
163
+ if (/not found|no devices\/emulators|device offline|more than one device|unauthorized/i.test(stderr)) {
164
+ return new DeviceNotFoundError(`adb ${what}: ${stderr}`, { cause: err });
165
+ }
166
+ return new ActionFailedError(
167
+ `adb ${what} failed (exit ${String(err.code ?? '?')}): ${stderr || err.message}`,
168
+ {
169
+ details: { backendCode: String(err.code ?? 'EXEC') },
170
+ cause: err,
171
+ },
172
+ );
173
+ }
174
+ return new ActionFailedError(`adb ${what} failed: ${String(err)}`, { cause: err });
175
+ }
176
+
177
+ /** Run an adb command, returning stdout text. */
178
+ private async adb(args: string[]): Promise<string> {
179
+ try {
180
+ const r = await this.exec(this.adbBin, this.withSerial(args), { timeoutMs: this.timeoutMs });
181
+ return r.stdout;
182
+ } catch (err) {
183
+ throw this.fail(args.slice(0, 2).join(' '), err);
184
+ }
185
+ }
186
+
187
+ /** `adb shell <argv>` — each argument quoted for the device shell. */
188
+ private shell(...argv: string[]): Promise<string> {
189
+ return this.adb(['shell', argv.map(shellQuote).join(' ')]);
190
+ }
191
+
192
+ /** `adb shell <literal>` for the few CONSTANT commands that need a pipe. Never pass input. */
193
+ private shellRaw(literal: string): Promise<string> {
194
+ return this.adb(['shell', literal]);
195
+ }
196
+
197
+ private center(ref: string): { x: number; y: number } {
198
+ const rect = this.rects.get(ref);
199
+ if (!rect) throw new ActionFailedError(`unknown element ${ref} — take a fresh snapshot first`);
200
+ return { x: Math.round(rect.x + rect.width / 2), y: Math.round(rect.y + rect.height / 2) };
201
+ }
202
+
203
+ /** The screen size input coordinates map to (an `Override size` wins over `Physical size`). */
204
+ private async screenSize(): Promise<{ width: number; height: number }> {
205
+ if (this.sizeCache) return this.sizeCache;
206
+ const out = await this.shell('wm', 'size');
207
+ const m = out.match(/Override size:\s*(\d+)x(\d+)/) ?? out.match(/Physical size:\s*(\d+)x(\d+)/);
208
+ if (!m) throw new ActionFailedError(`could not read screen size from: ${out.trim()}`);
209
+ this.sizeCache = { width: Number(m[1]), height: Number(m[2]) };
210
+ return this.sizeCache;
211
+ }
212
+
213
+ // ---- eyes ---------------------------------------------------------------
214
+
215
+ override async snapshot(opts?: { interactiveOnly?: boolean | undefined }): Promise<Snapshot> {
216
+ const size = await this.screenSize();
217
+ // A dump can fail while the UI is mid-animation; retry briefly. An EMPTY
218
+ // dump is a failure, not an empty screen: reporting zero nodes as success
219
+ // sent agents scroll-hunting for controls that were on screen the whole time.
220
+ let xml = '';
221
+ let lastError: unknown;
222
+ for (let attempt = 0; attempt < 3; attempt += 1) {
223
+ try {
224
+ await this.shell('uiautomator', 'dump', DUMP_PATH);
225
+ xml = await this.adb(['exec-out', 'cat', DUMP_PATH]);
226
+ } catch (err) {
227
+ lastError = err;
228
+ }
229
+ if (xml.includes('<node')) break;
230
+ await this.sleep(400);
231
+ }
232
+ if (!xml.includes('<node')) {
233
+ throw new ActionFailedError(
234
+ 'uiautomator dump produced no UI nodes after 3 attempts — the UI may be mid-transition or uiautomator ' +
235
+ 'may be wedged (try `adb shell uiautomator dump` by hand)',
236
+ { cause: lastError },
237
+ );
238
+ }
239
+ const parsed = parseHierarchy(xml, { interactiveOnly: opts?.interactiveOnly ?? false });
240
+ this.rects = parsed.rects;
241
+ // Prefer the resumed activity's package: the dump's first node is usually
242
+ // the status bar (com.android.systemui), so the tree's own package would
243
+ // mislabel every screen as systemui.
244
+ const appName = (await this.foregroundPackage().catch(() => undefined)) ?? parsed.packageName;
245
+ return {
246
+ // Synthetic root: the SDK derives the viewport from an Application/Window
247
+ // node and FALLS BACK TO 1000px when none exists. uiautomator dumps have
248
+ // no such node, so on a 2400px screen everything below y≈1000 was
249
+ // classified off-viewport. Give the tree a true-sized root.
250
+ nodes: [
251
+ {
252
+ ref: '@a0',
253
+ type: 'Application',
254
+ role: 'Application',
255
+ label: appName ?? 'android',
256
+ rect: { x: 0, y: 0, width: size.width, height: size.height },
257
+ },
258
+ ...parsed.nodes,
259
+ ],
260
+ ...(appName ? { appName, appBundleId: appName } : {}),
261
+ };
262
+ }
263
+
264
+ /** The package of the resumed (frontmost) activity, for the snapshot header. */
265
+ private async foregroundPackage(): Promise<string | undefined> {
266
+ // The key varies by Android version (ResumedActivity / mResumedActivity /
267
+ // topResumedActivity); the record shape does not: …{hash u0 com.pkg/.Act t123}
268
+ const out = await this.shellRaw('dumpsys activity activities | grep -m1 ResumedActivity');
269
+ return out.match(/\su0\s+([\w.]+)\//)?.[1];
270
+ }
271
+
272
+ override async screenshot(opts: { path: string }): Promise<{ path: string }> {
273
+ let png: Uint8Array;
274
+ try {
275
+ png = await this.execBinary(this.adbBin, this.withSerial(['exec-out', 'screencap', '-p']), {
276
+ timeoutMs: this.timeoutMs,
277
+ });
278
+ } catch (err) {
279
+ throw this.fail('screencap', err);
280
+ }
281
+ await writeFile(opts.path, png);
282
+ return { path: opts.path };
283
+ }
284
+
285
+ // ---- hands --------------------------------------------------------------
286
+
287
+ override async press(target: PressTarget): Promise<void> {
288
+ const { x, y } = 'ref' in target ? this.center(target.ref) : target;
289
+ await this.shell('input', 'tap', String(Math.round(x)), String(Math.round(y)));
290
+ }
291
+
292
+ override async longPress(ref: string, durationMs = 700): Promise<void> {
293
+ const { x, y } = this.center(ref);
294
+ // A zero-distance swipe with a duration is Android's long-press.
295
+ await this.shell(
296
+ 'input',
297
+ 'swipe',
298
+ String(x),
299
+ String(y),
300
+ String(x),
301
+ String(y),
302
+ String(Math.max(400, durationMs)),
303
+ );
304
+ }
305
+
306
+ override async fill(ref: string, text: string): Promise<void> {
307
+ const { x, y } = this.center(ref);
308
+ await this.shell('input', 'tap', String(x), String(y));
309
+ await this.sleep(120);
310
+ await this.clearFocusedField();
311
+ await this.typeText(text);
312
+ }
313
+
314
+ /**
315
+ * Empty the currently-focused text field. Ctrl+A then DEL (`input
316
+ * keycombination`, Android 12+); a short MOVE_END + backspace sweep stays as
317
+ * the fallback for the rare field that ignores the select combo. Replaces a
318
+ * 60-backspace sweep that left residue on long fields and made repeated
319
+ * `type`s ACCUMULATE, sending the agent into retry loops.
320
+ */
321
+ private async clearFocusedField(): Promise<void> {
322
+ await this.shell('input', 'keycombination', '113', '29').catch(() => undefined); // Ctrl(113)+A(29)
323
+ await this.shell('input', 'keyevent', String(KEYCODE.del));
324
+ await this.shell('input', 'keyevent', String(KEYCODE.moveEnd));
325
+ for (let i = 0; i < 8; i += 1) await this.shell('input', 'keyevent', String(KEYCODE.del));
326
+ }
327
+
328
+ override async typeText(text: string): Promise<void> {
329
+ if (!text) return;
330
+ // ASCII goes through `input text` as ONE quoted argument (spaces included —
331
+ // the `%s` convention only existed because unquoted spaces split the
332
+ // argument). Non-ASCII makes Android's own `input text` throw, so it rides
333
+ // the ADB Keyboard IME when present. Newlines become ENTER and \b becomes
334
+ // DEL so multi-line and corrective input work through the same call.
335
+ for (const [i, line] of text.split('\n').entries()) {
336
+ if (i > 0) await this.shell('input', 'keyevent', String(KEYCODE.enter));
337
+ for (const [j, chunk] of line.split('\b').entries()) {
338
+ if (j > 0) await this.shell('input', 'keyevent', String(KEYCODE.del));
339
+ if (!chunk) continue;
340
+ if (isPrintableAscii(chunk)) {
341
+ // Bounded per call: adb's command line has a ~4 KiB ceiling.
342
+ for (let at = 0; at < chunk.length; at += 400) {
343
+ await this.shell('input', 'text', chunk.slice(at, at + 400));
344
+ }
345
+ } else {
346
+ await this.typeUnicode(chunk);
347
+ }
348
+ }
349
+ }
350
+ }
351
+
352
+ /** The IME id of senzhk/ADBKeyBoard, the de-facto Unicode input path for adb. */
353
+ private static readonly ADB_IME = 'com.android.adbkeyboard/.AdbIME';
354
+
355
+ /**
356
+ * Type non-ASCII via the ADB Keyboard broadcast. The IME must be the ACTIVE
357
+ * keyboard; some OEM builds deny the shell WRITE_SECURE_SETTINGS so `ime set`
358
+ * cannot switch it. We try (works on stock builds) and restore the previous
359
+ * keyboard afterwards when we did the switching. Never a silent drop.
360
+ */
361
+ private async typeUnicode(text: string): Promise<void> {
362
+ const ime = AndroidBackend.ADB_IME;
363
+ const installed = (
364
+ await this.shell('pm', 'list', 'packages', 'com.android.adbkeyboard').catch(() => '')
365
+ ).includes('com.android.adbkeyboard');
366
+ if (!installed) {
367
+ throw new ActionFailedError(
368
+ `Android cannot type non-ASCII text ("${text.slice(0, 20)}…") over adb: the platform's own \`input text\` ` +
369
+ 'rejects it and no built-in alternative exists. ASCII works. For accented or non-Latin text, tap the ' +
370
+ 'field and use the on-screen keyboard (tap the keys), or paste text the user already has on the clipboard. ' +
371
+ '(Dev/test devices only: the ADB Keyboard IME — github.com/senzhk/ADBKeyBoard — enables Unicode over adb.)',
372
+ );
373
+ }
374
+ const previous = (
375
+ await this.shell('settings', 'get', 'secure', 'default_input_method').catch(() => '')
376
+ ).trim();
377
+ let switched = false;
378
+ if (previous !== ime) {
379
+ await this.shell('ime', 'enable', ime).catch(() => undefined);
380
+ await this.shell('ime', 'set', ime).catch(() => undefined);
381
+ const now = (
382
+ await this.shell('settings', 'get', 'secure', 'default_input_method').catch(() => '')
383
+ ).trim();
384
+ switched = now === ime;
385
+ if (!switched) {
386
+ throw new ActionFailedError(
387
+ 'Android cannot type non-ASCII text over adb on this device: the ADB Keyboard IME is installed but ' +
388
+ 'the OS blocks adb from activating it (WRITE_SECURE_SETTINGS denied). ASCII works. For accented or ' +
389
+ 'non-Latin text, tap the field and use the on-screen keyboard, or paste from the clipboard. ' +
390
+ '(Dev/test: enable ADB Keyboard once in Settings → Languages & input → Manage keyboards.)',
391
+ );
392
+ }
393
+ await this.sleep(300);
394
+ }
395
+ try {
396
+ const b64 = Buffer.from(text, 'utf8').toString('base64');
397
+ await this.shell('am', 'broadcast', '-a', 'ADB_INPUT_B64', '--es', 'msg', b64);
398
+ await this.sleep(150);
399
+ } finally {
400
+ if (switched && previous) await this.shell('ime', 'set', previous).catch(() => undefined);
401
+ }
402
+ }
403
+
404
+ override async pressKey(_key: 'return'): Promise<void> {
405
+ await this.shell('input', 'keyevent', String(KEYCODE.enter));
406
+ }
407
+
408
+ override async scroll(direction: ScrollDirection): Promise<void> {
409
+ const { width, height } = await this.screenSize();
410
+ // NEVER swipe through the soft keyboard: Gboard reads a drag across it as
411
+ // glide typing and types a word into the focused field (measured live —
412
+ // every scroll typed "by"). With the IME shown, anchor the gesture in the
413
+ // keyboard-free upper band instead.
414
+ const imeShown = (await this.shell('dumpsys', 'input_method').catch(() => '')).includes(
415
+ 'mInputShown=true',
416
+ );
417
+ const cx = Math.round(width / 2);
418
+ const cy = imeShown ? Math.round(height * 0.3) : Math.round(height / 2);
419
+ const dx = Math.round(width * 0.35);
420
+ const dy = imeShown ? Math.round(height * 0.15) : Math.round(height * 0.35);
421
+ // Swipe the finger OPPOSITE to the content you want revealed.
422
+ const from = { up: [cx, cy - dy], down: [cx, cy + dy], left: [cx - dx, cy], right: [cx + dx, cy] };
423
+ const to = { up: [cx, cy + dy], down: [cx, cy - dy], left: [cx + dx, cy], right: [cx - dx, cy] };
424
+ const [fx, fy] = from[direction] as [number, number];
425
+ const [tx, ty] = to[direction] as [number, number];
426
+ await this.shell('input', 'swipe', String(fx), String(fy), String(tx), String(ty), '300');
427
+ }
428
+
429
+ override async pan(x: number, y: number, dx: number, dy: number, durationMs = 300): Promise<void> {
430
+ await this.shell(
431
+ 'input',
432
+ 'swipe',
433
+ String(Math.round(x)),
434
+ String(Math.round(y)),
435
+ String(Math.round(x + dx)),
436
+ String(Math.round(y + dy)),
437
+ String(Math.max(50, durationMs)),
438
+ );
439
+ }
440
+
441
+ override async waitForText(text: string, timeoutMs = 8000): Promise<void> {
442
+ const deadline = Date.now() + timeoutMs;
443
+ const needle = text.toLowerCase();
444
+ for (;;) {
445
+ const snap = await this.snapshot();
446
+ if (snap.nodes.some((n) => (n.label ?? '').toLowerCase().includes(needle))) return;
447
+ if (Date.now() >= deadline) throw new TimeoutError(`"${text}" did not appear within ${timeoutMs}ms`);
448
+ await this.sleep(500);
449
+ }
450
+ }
451
+
452
+ override async home(): Promise<void> {
453
+ await this.shell('input', 'keyevent', String(KEYCODE.home));
454
+ }
455
+
456
+ override async back(): Promise<void> {
457
+ await this.shell('input', 'keyevent', String(KEYCODE.back));
458
+ }
459
+
460
+ // ---- apps ---------------------------------------------------------------
461
+
462
+ /** Packages that expose a launcher icon — the set `open <name>` can resolve to. */
463
+ private async launchables(): Promise<string[]> {
464
+ if (this.launchablesCache) return this.launchablesCache;
465
+ const out = await this.shell(
466
+ 'cmd',
467
+ 'package',
468
+ 'query-activities',
469
+ '-a',
470
+ 'android.intent.action.MAIN',
471
+ '-c',
472
+ 'android.intent.category.LAUNCHER',
473
+ ).catch(() => '');
474
+ const pkgs = [...new Set([...out.matchAll(/packageName=([\w.]+)/g)].map((m) => m[1]!))];
475
+ if (pkgs.length) this.launchablesCache = pkgs; // never cache an empty (transient) result
476
+ return pkgs;
477
+ }
478
+
479
+ /**
480
+ * Resolve a human app name to an installed package: agents say "Markor" or
481
+ * "Simple Calendar", `am` needs `net.gsantner.markor`. Score each launchable
482
+ * package by how many of the query's words appear in its id; best (shortest
483
+ * on a tie) wins. An exact package id passes straight through. Returns
484
+ * undefined when nothing matches and the query is not a package id at all.
485
+ */
486
+ private async resolvePackage(query: string): Promise<string | undefined> {
487
+ const launch = await this.launchables();
488
+ if (launch.includes(query)) return query;
489
+ // A package-shaped query is an id, not a name: never fuzzy-match it (the
490
+ // `com` in `com.example.hidden` would otherwise "match" com.android.settings).
491
+ if (PACKAGE_RE.test(query)) return query;
492
+ const norm = (v: string) => v.toLowerCase().replace(/[^a-z0-9]/g, '');
493
+ const words = query
494
+ .toLowerCase()
495
+ .split(/[^a-z0-9]+/)
496
+ .filter(Boolean);
497
+ let best: { pkg: string; score: number } | null = null;
498
+ for (const pkg of launch) {
499
+ const np = norm(pkg);
500
+ const score = words.filter((w) => np.includes(w)).length;
501
+ if (score === 0) continue;
502
+ if (!best || score > best.score || (score === best.score && pkg.length < best.pkg.length)) {
503
+ best = { pkg, score };
504
+ }
505
+ }
506
+ return best?.pkg;
507
+ }
508
+
509
+ override async openApp(opts: {
510
+ app?: string | undefined;
511
+ url?: string | undefined;
512
+ relaunch?: boolean | undefined;
513
+ }): Promise<OpenAppResult> {
514
+ if (opts.url) {
515
+ await this.shell('am', 'start', '-a', 'android.intent.action.VIEW', '-d', opts.url);
516
+ return {};
517
+ }
518
+ if (!opts.app) throw new ActionFailedError('openApp needs an app name/package id or a url');
519
+ const pkg = await this.resolvePackage(opts.app);
520
+ if (!pkg) {
521
+ throw new DeviceNotFoundError(
522
+ `no installed app matches "${opts.app}" — pass a launcher name or a package id (see listApps)`,
523
+ );
524
+ }
525
+ if (opts.relaunch) await this.shell('am', 'force-stop', pkg).catch(() => undefined);
526
+ // Launch via the resolved launcher activity + `am start`. `monkey` returns a
527
+ // spurious non-zero exit on keyless AVDs and sometimes fails to launch at
528
+ // all, so it is only the fallback when the activity can't be resolved.
529
+ const activity = (
530
+ await this.shell(
531
+ 'cmd',
532
+ 'package',
533
+ 'resolve-activity',
534
+ '--brief',
535
+ '-c',
536
+ 'android.intent.category.LAUNCHER',
537
+ pkg,
538
+ ).catch(() => '')
539
+ )
540
+ .trim()
541
+ .split('\n')
542
+ .pop()
543
+ ?.trim();
544
+ if (activity && ACTIVITY_RE.test(activity)) {
545
+ await this.shell('am', 'start', '-n', activity);
546
+ } else {
547
+ await this.shell('monkey', '-p', pkg, '-c', 'android.intent.category.LAUNCHER', '1').catch(
548
+ () => undefined,
549
+ );
550
+ }
551
+ return { appBundleId: pkg };
552
+ }
553
+
554
+ override async listApps(): Promise<string[]> {
555
+ const out = await this.shell('pm', 'list', 'packages');
556
+ return out
557
+ .split('\n')
558
+ .map((l) => l.replace(/^package:/, '').trim())
559
+ .filter(Boolean)
560
+ .sort();
561
+ }
562
+
563
+ override closeSession(): Promise<void> {
564
+ // adb is stateless — nothing to tear down.
565
+ return Promise.resolve();
566
+ }
567
+ }
568
+
569
+ /**
570
+ * Backend factory for the `android-adb` registry entry: an
571
+ * {@link AndroidDeviceConfig} pins the serial; anything else targets the single
572
+ * attached device.
573
+ */
574
+ export function createAndroidBackend(config?: DeviceConfig | AndroidBackendOptions): AndroidBackend {
575
+ if (config && 'platform' in config) {
576
+ return new AndroidBackend(config.platform === 'android' ? { serial: config.serial } : {});
577
+ }
578
+ return new AndroidBackend(config);
579
+ }
580
+
581
+ // ---------------------------------------------------------------------------
582
+ // The Android engine (engine-as-object, the ios.ts twin): android.connect()
583
+ // attaches to a device adb already sees; android.launch() boots a dedicated
584
+ // emulator instance from an AVD and returns a Device that kills it on close.
585
+ // ---------------------------------------------------------------------------
586
+
587
+ /** One row of `adb devices -l`. */
588
+ export type AndroidDeviceInfo = {
589
+ /** adb serial, e.g. `emulator-5554` or `R58M12ABCDE`. */
590
+ serial: string;
591
+ /** adb state: `device` (ready), `unauthorized`, `offline`, … */
592
+ state: string;
593
+ /** Model name when adb reports one. */
594
+ model?: string | undefined;
595
+ };
596
+
597
+ type CommonAndroidOptions = {
598
+ /** Path to the adb binary (default: `adb` on PATH). */
599
+ adbBin?: string | undefined;
600
+ /** Idle lease window in ms (false disables). Default 180_000 (3 min). */
601
+ idleTimeoutMs?: number | false | undefined;
602
+ /** Observer for reaper-initiated closes. */
603
+ onIdleClose?: ((device: Device) => void) | undefined;
604
+ /** Initial %name% secret values (see Device.secrets). */
605
+ secrets?: Record<string, string> | undefined;
606
+ /** @internal harness seam — supplies the verb core (see createDeviceHandle). */
607
+ coreFactory?:
608
+ | ((backend: import('../backend.ts').DeviceBackend) => import('../observe.ts').DeviceCore)
609
+ | undefined;
610
+ /** @internal test seam — DI'd process runner. */
611
+ exec?: ExecRunner | undefined;
612
+ /** @internal test seam — DI'd binary-stdout runner. */
613
+ execBinary?: BinaryExecRunner | undefined;
614
+ /** @internal test seam — DI'd delay. */
615
+ sleep?: ((ms: number) => Promise<void>) | undefined;
616
+ };
617
+
618
+ /** Options for `android.connect()`. */
619
+ export type AndroidConnectOptions = CommonAndroidOptions;
620
+
621
+ /** Options for `android.list()`. */
622
+ export type AndroidListOptions = {
623
+ /** Path to the adb binary (default: `adb` on PATH). */
624
+ adbBin?: string | undefined;
625
+ /** @internal test seam — DI'd process runner. */
626
+ exec?: ExecRunner | undefined;
627
+ };
628
+
629
+ /** The child-process handle the emulator spawner returns (what `android.launch()` kills on close). */
630
+ export type EmulatorProcess = {
631
+ /** Send a signal to the emulator process. */
632
+ kill(signal?: NodeJS.Signals): void;
633
+ /** Subscribe once to process exit. */
634
+ once(event: 'exit', listener: () => void): unknown;
635
+ /** True once the process has exited. */
636
+ readonly exited: boolean;
637
+ };
638
+
639
+ /** Options for `android.launch()` — which AVD, which port, headless or not. */
640
+ export type AndroidLaunchOptions = CommonAndroidOptions & {
641
+ /** The AVD name (`emulator -list-avds`). Required. */
642
+ avd: string;
643
+ /** Console port; the serial becomes `emulator-<port>`. Default 5554. Must be even. */
644
+ port?: number | undefined;
645
+ /** Path to the `emulator` binary (default: `$ANDROID_HOME/emulator/emulator`, else `emulator` on PATH). */
646
+ emulatorBin?: string | undefined;
647
+ /** Run without a window (default true — cloud hosts have no display). */
648
+ headless?: boolean | undefined;
649
+ /** `-read-only`: lets several instances of ONE AVD run at once (what a cloud worker wants). */
650
+ readOnly?: boolean | undefined;
651
+ /** Extra emulator arguments appended verbatim (e.g. `-gpu swiftshader_indirect`). */
652
+ extraArgs?: string[] | undefined;
653
+ /** Boot ceiling in ms for `sys.boot_completed` (default 180_000). */
654
+ bootTimeoutMs?: number | undefined;
655
+ /** @internal test seam — boot-poll interval (default 1000). */
656
+ pollIntervalMs?: number | undefined;
657
+ /** @internal test seam — DI'd emulator spawner. */
658
+ spawn?: ((file: string, args: string[]) => EmulatorProcess) | undefined;
659
+ };
660
+
661
+ /** Parse `adb devices -l` (header dropped). */
662
+ function parseDevices(stdout: string): AndroidDeviceInfo[] {
663
+ return stdout
664
+ .split('\n')
665
+ .slice(1)
666
+ .map((line) => line.trim())
667
+ .filter((line) => line.length > 0 && !line.startsWith('*'))
668
+ .map((line) => {
669
+ const [serial = '', state = 'unknown', ...rest] = line.split(/\s+/);
670
+ const model = rest.find((f) => f.startsWith('model:'))?.slice('model:'.length);
671
+ return { serial, state, ...(model ? { model } : {}) };
672
+ })
673
+ .filter((d) => d.serial.length > 0);
674
+ }
675
+
676
+ async function runAdb(exec: ExecRunner, adbBin: string, args: string[], timeoutMs: number): Promise<string> {
677
+ try {
678
+ return (await exec(adbBin, args, { timeoutMs })).stdout;
679
+ } catch (err) {
680
+ if (isExecError(err)) {
681
+ if (err.killed || err.signal)
682
+ throw new TimeoutError(`adb ${args[0]} timed out after ${timeoutMs}ms`, { cause: err });
683
+ if (err.code === 'ENOENT') {
684
+ throw new ActionFailedError(`adb not found at "${adbBin}" — install the Android platform-tools`, {
685
+ cause: err,
686
+ });
687
+ }
688
+ throw new ActionFailedError(`adb ${args[0]} failed: ${(err.stderr ?? err.message).trim()}`, {
689
+ cause: err,
690
+ });
691
+ }
692
+ throw new ActionFailedError(`adb ${args[0]} failed: ${String(err)}`, { cause: err });
693
+ }
694
+ }
695
+
696
+ /** Devices adb currently sees, with their state. */
697
+ async function list(options: AndroidListOptions = {}): Promise<AndroidDeviceInfo[]> {
698
+ const exec = options.exec ?? defaultExecRunner;
699
+ return parseDevices(await runAdb(exec, options.adbBin ?? 'adb', ['devices', '-l'], 15_000));
700
+ }
701
+
702
+ function finishHandle(
703
+ row: AndroidDeviceInfo,
704
+ createdByUs: boolean,
705
+ opts: CommonAndroidOptions,
706
+ doClose: () => Promise<void>,
707
+ ): Device {
708
+ const backend = new AndroidBackend({
709
+ serial: row.serial,
710
+ adbBin: opts.adbBin,
711
+ exec: opts.exec,
712
+ execBinary: opts.execBinary,
713
+ sleep: opts.sleep,
714
+ });
715
+ return createDeviceHandle({
716
+ id: row.serial,
717
+ platform: 'android',
718
+ name: row.model,
719
+ backend,
720
+ createdByUs,
721
+ idleTimeoutMs: opts.idleTimeoutMs,
722
+ onIdleClose: opts.onIdleClose,
723
+ secrets: opts.secrets,
724
+ coreFactory: opts.coreFactory,
725
+ doClose,
726
+ });
727
+ }
728
+
729
+ const FIX_USB_DEBUGGING =
730
+ 'enable Developer options → USB debugging on the phone, connect over USB (or `adb connect <ip:port>`), ' +
731
+ 'and confirm with `adb devices`';
732
+
733
+ /**
734
+ * Attach to a device adb already sees. With a serial, that device; without,
735
+ * the single ready device (several attached → an error naming them, so verbs
736
+ * never land on the wrong phone). `close()` releases the handle and never
737
+ * shuts the device down — it was yours before we connected.
738
+ */
739
+ async function connect(serial?: string, options: AndroidConnectOptions = {}): Promise<Device> {
740
+ const exec = options.exec ?? defaultExecRunner;
741
+ const devices = await list({ adbBin: options.adbBin, exec });
742
+ let row: AndroidDeviceInfo | undefined;
743
+ if (serial !== undefined) {
744
+ row = devices.find((d) => d.serial === serial);
745
+ if (!row) {
746
+ const seen = devices.map((d) => `${d.serial} (${d.state})`).join(', ') || 'none';
747
+ throw new DeviceNotFoundError(`no adb device with serial ${serial} — attached: ${seen}`);
748
+ }
749
+ } else {
750
+ const ready = devices.filter((d) => d.state === 'device');
751
+ if (ready.length === 0) {
752
+ const unauthorized = devices.find((d) => d.state === 'unauthorized');
753
+ throw new DeviceNotFoundError(
754
+ unauthorized
755
+ ? `device ${unauthorized.serial} is attached but unauthorized — unlock it and accept the "Allow USB debugging" prompt`
756
+ : `no Android device attached — ${FIX_USB_DEBUGGING}`,
757
+ );
758
+ }
759
+ if (ready.length > 1) {
760
+ throw new DeviceNotFoundError(
761
+ `several Android devices attached (${ready.map((d) => d.serial).join(', ')}) — pass a serial`,
762
+ );
763
+ }
764
+ row = ready[0]!;
765
+ }
766
+ if (row.state !== 'device') {
767
+ throw new DeviceNotFoundError(
768
+ row.state === 'unauthorized'
769
+ ? `device ${row.serial} is unauthorized — unlock it and accept the "Allow USB debugging" prompt`
770
+ : `device ${row.serial} is ${row.state} — ${FIX_USB_DEBUGGING}`,
771
+ );
772
+ }
773
+ return finishHandle(row, false, options, () => Promise.resolve());
774
+ }
775
+
776
+ function defaultEmulatorBin(): string {
777
+ const root = process.env.ANDROID_HOME ?? process.env.ANDROID_SDK_ROOT;
778
+ return root ? `${root}/emulator/emulator` : 'emulator';
779
+ }
780
+
781
+ function defaultSpawn(file: string, args: string[]): EmulatorProcess {
782
+ const child = spawn(file, args, { stdio: 'ignore' });
783
+ let exited = false;
784
+ child.once('exit', () => {
785
+ exited = true;
786
+ });
787
+ // A spawn failure (ENOENT) surfaces as 'error'; treat it as an exit so the
788
+ // boot wait below fails fast instead of polling a serial that never appears.
789
+ child.once('error', () => {
790
+ exited = true;
791
+ child.emit('exit');
792
+ });
793
+ return {
794
+ kill: (signal) => {
795
+ child.kill(signal);
796
+ },
797
+ once: (event, listener) => child.once(event, listener),
798
+ get exited() {
799
+ return exited;
800
+ },
801
+ };
802
+ }
803
+
804
+ /**
805
+ * Boot a DEDICATED emulator instance from an AVD and return a {@link Device}
806
+ * pinned to its serial (`emulator-<port>`). `close()` kills the instance.
807
+ * `readOnly: true` lets several instances of one AVD run side by side, which
808
+ * is how a cloud worker turns one golden image into N phones.
809
+ */
810
+ async function launch(options: AndroidLaunchOptions): Promise<Device> {
811
+ const exec = options.exec ?? defaultExecRunner;
812
+ const sleep = options.sleep ?? defaultSleep;
813
+ const adbBin = options.adbBin ?? 'adb';
814
+ const port = options.port ?? 5554;
815
+ if (port % 2 !== 0 || port < 5554 || port > 5682) {
816
+ throw new ActionFailedError(`emulator port must be an even number in 5554..5682, got ${port}`);
817
+ }
818
+ const serial = `emulator-${port}`;
819
+ const bootTimeoutMs = options.bootTimeoutMs ?? 180_000;
820
+ const pollMs = options.pollIntervalMs ?? 1000;
821
+ const spawnEmulator = options.spawn ?? defaultSpawn;
822
+
823
+ const args = [
824
+ '-avd',
825
+ options.avd,
826
+ '-port',
827
+ String(port),
828
+ '-no-boot-anim',
829
+ '-no-audio',
830
+ ...(options.headless === false ? [] : ['-no-window']),
831
+ ...(options.readOnly ? ['-read-only'] : []),
832
+ ...(options.extraArgs ?? []),
833
+ ];
834
+ const child = spawnEmulator(options.emulatorBin ?? defaultEmulatorBin(), args);
835
+
836
+ const stop = async (): Promise<void> => {
837
+ await runAdb(exec, adbBin, ['-s', serial, 'emu', 'kill'], 10_000).catch(() => undefined);
838
+ if (child.exited) return;
839
+ await new Promise<void>((resolve) => {
840
+ const timer = setTimeout(() => {
841
+ child.kill('SIGKILL');
842
+ resolve();
843
+ }, 10_000);
844
+ child.once('exit', () => {
845
+ clearTimeout(timer);
846
+ resolve();
847
+ });
848
+ });
849
+ };
850
+
851
+ try {
852
+ const deadline = Date.now() + bootTimeoutMs;
853
+ await runAdb(exec, adbBin, ['-s', serial, 'wait-for-device'], bootTimeoutMs);
854
+ for (;;) {
855
+ if (child.exited) throw new ActionFailedError(`emulator ${serial} exited during boot`);
856
+ const out = await runAdb(
857
+ exec,
858
+ adbBin,
859
+ ['-s', serial, 'shell', 'getprop', 'sys.boot_completed'],
860
+ 15_000,
861
+ ).catch(() => '');
862
+ if (out.trim() === '1') break;
863
+ if (Date.now() >= deadline) {
864
+ throw new TimeoutError(`emulator ${serial} did not finish booting within ${bootTimeoutMs}ms`);
865
+ }
866
+ await sleep(pollMs);
867
+ }
868
+ } catch (err) {
869
+ // We started it and it never came up — never leak a headless emulator.
870
+ await stop().catch(() => undefined);
871
+ throw err;
872
+ }
873
+
874
+ return finishHandle({ serial, state: 'device', model: options.avd }, true, options, stop);
875
+ }
876
+
877
+ /**
878
+ * The Android engine object (the `ios` twin): `android.connect()` for a device
879
+ * adb already sees, `android.launch()` for a dedicated emulator instance,
880
+ * `android.list()` to see what is attached. All return/describe the same
881
+ * Device type the iOS engine does.
882
+ */
883
+ export const android = {
884
+ /** Attach to an attached device or running emulator by serial (no-arg: the single ready device). */
885
+ connect,
886
+ /** Boot a dedicated emulator instance from an AVD and return a Device pinned to it. */
887
+ launch,
888
+ /** Devices adb currently sees, with their state. */
889
+ list,
890
+ } as const;