simframe 0.6.1 → 0.7.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,968 @@
1
+ // The Android backend: everything that shells out to the Android SDK, plus the
2
+ // emulator's own console socket where that is faster than adb.
3
+ //
4
+ // Nothing outside src/platform/ may import this file. Callers go through
5
+ // src/platform/index.js — see the note there about why every function here is
6
+ // module-private and reachable only through the `platform` object at the bottom.
7
+ //
8
+ // Measured on this machine (M-series, Android 16 / API 36, Small_Phone_API_36,
9
+ // 720x1280 @320dpi), medians of 5-7 runs — the numbers that shaped the choices
10
+ // below, and the reason two of them are not the obvious command:
11
+ //
12
+ // emulator console `screenrecord screenshot <dir>` 20 ms, written host-side
13
+ // adb exec-out screencap -p 113 ms, 9 KB over adb
14
+ // adb exec-out screencap (raw RGBA, 3.7 MB) 218 ms — the transfer, not the encode
15
+ // adb shell getprop x4 in one hop 28 ms
16
+ // adb shell dumpsys package <pkg> 130 ms
17
+ // uiautomator dump 2,012 ms ← see docs/DEFERRED.md
18
+ //
19
+ // The console path wins because the emulator writes the PNG to the host
20
+ // filesystem itself: there is no device-to-host transfer at all. adb screencap
21
+ // stays as the fallback for the case where the console is unreachable.
22
+ import { execFile, execFileSync } from 'node:child_process';
23
+ import fs from 'node:fs';
24
+ import http2 from 'node:http2';
25
+ import net from 'node:net';
26
+ import os from 'node:os';
27
+ import path from 'node:path';
28
+ import { promisify } from 'node:util';
29
+
30
+ const run = promisify(execFile);
31
+
32
+ // Same reasoning as the iOS backend: a listing costs enough to dominate a warm
33
+ // read, so the parsed list is cached for a few seconds.
34
+ const DEVICE_CACHE_MS = 4000;
35
+ let deviceCache = { at: 0, devices: null, inflight: null };
36
+
37
+ let adbCache = null;
38
+
39
+ /**
40
+ * Where adb is.
41
+ *
42
+ * PATH first, because a developer who put it there meant it. Then the two
43
+ * environment variables Google has used, then the default install location on
44
+ * macOS. Resolved once: this is called on every device operation.
45
+ */
46
+ function adbPath() {
47
+ if (adbCache) return adbCache;
48
+ const candidates = [];
49
+ const roots = [process.env.ANDROID_HOME, process.env.ANDROID_SDK_ROOT, path.join(os.homedir(), 'Library/Android/sdk')];
50
+ try {
51
+ candidates.push(execFileSync('command', ['-v', 'adb'], { encoding: 'utf8', shell: true }).trim());
52
+ } catch {
53
+ /* not on PATH; the SDK locations below are the usual case */
54
+ }
55
+ for (const root of roots) if (root) candidates.push(path.join(root, 'platform-tools', 'adb'));
56
+ for (const candidate of candidates) {
57
+ if (candidate && fs.existsSync(candidate)) {
58
+ adbCache = candidate;
59
+ return adbCache;
60
+ }
61
+ }
62
+ throw new Error(
63
+ 'adb not found — install Android platform-tools, or set ANDROID_HOME to your SDK ' +
64
+ '(looked on PATH and in platform-tools/ under ANDROID_HOME, ANDROID_SDK_ROOT and ~/Library/Android/sdk)',
65
+ );
66
+ }
67
+
68
+ /** One adb invocation. `serial` is null for commands that are not about a device. */
69
+ async function adb(serial, args, opts = {}) {
70
+ const argv = serial ? ['-s', serial, ...args] : args;
71
+ return run(adbPath(), argv, { timeout: 20_000, maxBuffer: 8 << 20, ...opts });
72
+ }
73
+
74
+ /** adb's own message, which is in stderr, rather than execFile's "Command failed". */
75
+ function detailOf(err) {
76
+ const stderr = (err.stderr || '').trim().split('\n').filter(Boolean).pop();
77
+ return stderr || err.message;
78
+ }
79
+
80
+ /** @returns {Promise<Array<{udid: string, name: string, runtime: string, state: string}>>} */
81
+ async function listDevices({ maxAgeMs = DEVICE_CACHE_MS } = {}) {
82
+ if (deviceCache.devices && Date.now() - deviceCache.at <= maxAgeMs) return deviceCache.devices;
83
+ if (deviceCache.inflight) return deviceCache.inflight;
84
+ deviceCache.inflight = fetchDevices()
85
+ .then((devices) => {
86
+ deviceCache = { at: Date.now(), devices, inflight: null };
87
+ return devices;
88
+ })
89
+ .catch((err) => {
90
+ deviceCache.inflight = null;
91
+ throw err;
92
+ });
93
+ return deviceCache.inflight;
94
+ }
95
+
96
+ async function fetchDevices() {
97
+ let stdout = '';
98
+ try {
99
+ ({ stdout } = await adb(null, ['devices', '-l']));
100
+ } catch (err) {
101
+ // No adb, or no adb server: no Android devices, which is not an error on a
102
+ // machine that only has simulators. A missing toolchain is doctor's business
103
+ // (see `toolchain` below), not something that should break `simframe devices`.
104
+ if (/adb not found/.test(err.message)) return [];
105
+ throw new Error(`adb devices failed: ${detailOf(err)}`);
106
+ }
107
+ const serials = [];
108
+ for (const line of stdout.split('\n').slice(1)) {
109
+ const [serial, state] = line.trim().split(/\s+/);
110
+ if (serial && state) serials.push({ serial, adbState: state });
111
+ }
112
+ const out = [];
113
+ for (const { serial, adbState } of serials) {
114
+ // Every property in one hop. The iOS a11y read taught this the expensive
115
+ // way: eight attributes in one call instead of eight calls was 112 hops
116
+ // down to 14, and the same arithmetic applies to a device shell.
117
+ let props = [];
118
+ if (adbState === 'device') {
119
+ try {
120
+ const { stdout: raw } = await adb(serial, [
121
+ 'shell',
122
+ 'getprop ro.boot.qemu.avd_name; getprop ro.product.model; ' +
123
+ 'getprop ro.build.version.release; getprop ro.build.version.sdk; getprop sys.boot_completed',
124
+ ]);
125
+ props = raw.replace(/\r/g, '').split('\n');
126
+ } catch {
127
+ /* the device answered `adb devices` and not a shell: treat it as offline */
128
+ }
129
+ }
130
+ const [avdName, model, release, sdk, bootCompleted] = props;
131
+ out.push({
132
+ udid: serial,
133
+ name: avdName || model || serial,
134
+ runtime: release ? `Android ${release} (API ${sdk})` : 'Android',
135
+ // A device that is present but has not finished booting is not one a flow
136
+ // may be pointed at, and `Booting` says which of the two it is rather
137
+ // than flattening both to "not booted".
138
+ state: adbState !== 'device' ? 'Shutdown' : bootCompleted?.trim() === '1' ? 'Booted' : 'Booting',
139
+ });
140
+ }
141
+ return out;
142
+ }
143
+
144
+ async function bootedDevices(opts) {
145
+ return (await listDevices(opts)).filter((d) => d.state === 'Booted');
146
+ }
147
+
148
+ /** An AVD name is `Small_Phone_API_36`; nobody wants to type the underscores. */
149
+ const loose = (s) => String(s ?? '').toLowerCase().replace(/[\s_]+/g, ' ').trim();
150
+
151
+ /**
152
+ * Resolve a user-supplied device string (serial, AVD name, or substring) to one
153
+ * booted device. Same shape as the iOS backend, including the marked ambiguity
154
+ * error the seam relies on to refuse rather than guess.
155
+ */
156
+ async function resolveDevice(query, opts) {
157
+ const all = await listDevices(opts);
158
+ const booted = all.filter((d) => d.state === 'Booted');
159
+ if (!query) {
160
+ if (booted.length === 0) {
161
+ const booting = all.filter((d) => d.state === 'Booting');
162
+ throw new Error(
163
+ booting.length
164
+ ? `no booted emulator yet — ${booting.map((d) => d.name).join(', ')} is still starting`
165
+ : 'no booted emulator (start one with `emulator -avd <name>`)',
166
+ );
167
+ }
168
+ return booted[0];
169
+ }
170
+ const q = loose(query);
171
+ for (const pool of [booted, all]) {
172
+ const exact = pool.find((d) => loose(d.udid) === q || loose(d.name) === q);
173
+ if (exact) return exact;
174
+ const partial = pool.filter((d) => loose(d.name).includes(q));
175
+ if (partial.length === 1) return partial[0];
176
+ if (partial.length > 1) {
177
+ throw Object.assign(
178
+ new Error(`"${query}" matches ${partial.length} devices: ${partial.map((d) => d.name).join(', ')}`),
179
+ { ambiguous: true },
180
+ );
181
+ }
182
+ }
183
+ throw new Error(`no emulator matches "${query}"; booted: ${booted.map((d) => d.name).join(', ') || 'none'}`);
184
+ }
185
+
186
+ function isBootedSync(udid) {
187
+ try {
188
+ const state = execFileSync(adbPath(), ['-s', udid, 'get-state'], { encoding: 'utf8', timeout: 10_000 }).trim();
189
+ return state === 'device';
190
+ } catch {
191
+ /* same convention as the iOS backend: an unreadable state is not a death */
192
+ return true;
193
+ }
194
+ }
195
+
196
+ /**
197
+ * Does this backend own that device id, judged without touching the device?
198
+ *
199
+ * Only emulators, and only the serial the emulator itself uses:
200
+ * `emulator-<console port>`. Deliberately narrow — physical devices are a
201
+ * non-goal, and a backend that claimed every non-UUID string would swallow a
202
+ * mistyped simulator udid and report it as a missing emulator.
203
+ */
204
+ function ownsUdid(udid) {
205
+ return /^emulator-\d+$/.test(String(udid ?? ''));
206
+ }
207
+
208
+ // --- the emulator console --------------------------------------------------
209
+ //
210
+ // `emulator-5554` means console port 5554. The token in
211
+ // ~/.emulator_console_auth_token is what the emulator wrote there for whoever
212
+ // can read the file; it is not a secret of ours to handle, and it never leaves
213
+ // this process.
214
+
215
+ function consolePort(udid) {
216
+ const port = /^emulator-(\d+)$/.exec(String(udid))?.[1];
217
+ return port ? Number(port) : null;
218
+ }
219
+
220
+ function consoleToken() {
221
+ return fs.readFileSync(path.join(os.homedir(), '.emulator_console_auth_token'), 'utf8').trim();
222
+ }
223
+
224
+ /**
225
+ * The emulator console, held open per device.
226
+ *
227
+ * The console is a line protocol: every command is answered with `OK` or `KO`,
228
+ * and the greeting is itself terminated by an `OK`, so the handshake is two
229
+ * responses — the greeting, then the answer to `auth` — after which it is
230
+ * strictly request/response.
231
+ *
232
+ * Held open rather than opened per command, for two reasons. The capture loop
233
+ * asks for a frame several times a second, and a connect plus an auth is not
234
+ * free. And a
235
+ * gesture is not one command — a tap is a down, a hold and an up, a swipe is a
236
+ * run of moves with time between them — and paying for a handshake between the
237
+ * down and the up would make the timing a fiction.
238
+ *
239
+ * Commands are serialised on the session. Two callers sharing one socket
240
+ * interleaving their writes would each read the other's `OK`.
241
+ */
242
+ class ConsoleSession {
243
+ constructor(sock, port) {
244
+ this.sock = sock;
245
+ this.port = port;
246
+ this.buf = '';
247
+ this.waiting = null;
248
+ this.tail = Promise.resolve();
249
+ this.dead = null;
250
+ sock.setEncoding('utf8');
251
+ sock.on('data', (chunk) => {
252
+ this.buf += chunk;
253
+ const done = /(OK|KO)([^\n]*)\r?\n/.exec(this.buf);
254
+ if (!done || !this.waiting) return;
255
+ const text = this.buf;
256
+ this.buf = '';
257
+ const settle = this.waiting;
258
+ this.waiting = null;
259
+ if (done[1] === 'KO') settle.reject(new Error(`emulator console refused: ${done[2].trim() || 'KO'}`));
260
+ else settle.resolve(text);
261
+ });
262
+ const die = (err) => {
263
+ this.dead = err ?? new Error(`emulator console on ${port} closed`);
264
+ // A close with a command outstanding is that command failing, not it
265
+ // succeeding. Anything else is nobody's error to hear about: a session
266
+ // closed on purpose must not surface as an unhandled rejection, which is
267
+ // exactly what a stored `close` promise did.
268
+ if (this.waiting) {
269
+ const settle = this.waiting;
270
+ this.waiting = null;
271
+ settle.reject(this.dead);
272
+ }
273
+ };
274
+ sock.on('error', die);
275
+ sock.on('close', () => die());
276
+ }
277
+
278
+ get usable() {
279
+ return !this.dead && !this.sock.destroyed;
280
+ }
281
+
282
+ /** Wait for one OK/KO. Used for the greeting, and by `send`. */
283
+ answer({ timeoutMs = 10_000 } = {}) {
284
+ if (this.dead) return Promise.reject(this.dead);
285
+ return new Promise((resolve, reject) => {
286
+ const timer = setTimeout(() => {
287
+ this.waiting = null;
288
+ this.sock.destroy();
289
+ reject(new Error(`emulator console on ${this.port} did not answer within ${timeoutMs}ms`));
290
+ }, timeoutMs);
291
+ this.waiting = {
292
+ resolve: (v) => { clearTimeout(timer); resolve(v); },
293
+ reject: (e) => { clearTimeout(timer); reject(e); },
294
+ };
295
+ });
296
+ }
297
+
298
+ /** One command, queued behind anything already in flight on this socket. */
299
+ send(line, opts) {
300
+ const mine = this.tail.then(async () => {
301
+ if (!this.usable) throw this.dead ?? new Error('emulator console is closed');
302
+ const answer = this.answer(opts);
303
+ this.sock.write(`${line}\n`);
304
+ return answer;
305
+ });
306
+ // The queue must survive a failed command, or one refusal wedges the
307
+ // session for every caller behind it.
308
+ this.tail = mine.then(() => undefined, () => undefined);
309
+ return mine;
310
+ }
311
+
312
+ close() {
313
+ try {
314
+ this.sock.write('quit\n');
315
+ } catch {
316
+ /* already gone */
317
+ }
318
+ this.sock.destroy();
319
+ }
320
+ }
321
+
322
+ const sessions = new Map();
323
+
324
+ /** The open session for a device, reconnected if the last one went away. */
325
+ async function sessionFor(udid, { timeoutMs = 10_000 } = {}) {
326
+ const existing = sessions.get(udid);
327
+ if (existing) {
328
+ const session = await existing;
329
+ if (session.usable) return session;
330
+ sessions.delete(udid);
331
+ }
332
+ const opening = (async () => {
333
+ const port = consolePort(udid);
334
+ if (!port) throw new Error(`${udid} is not an emulator serial`);
335
+ const session = new ConsoleSession(net.connect(port, '127.0.0.1'), port);
336
+ await session.answer({ timeoutMs });
337
+ await session.send(`auth ${consoleToken()}`, { timeoutMs });
338
+ return session;
339
+ })();
340
+ sessions.set(udid, opening);
341
+ try {
342
+ return await opening;
343
+ } catch (err) {
344
+ sessions.delete(udid);
345
+ throw err;
346
+ }
347
+ }
348
+
349
+ /** Run commands on the device's console, in order. */
350
+ async function consoleScript(udid, lines, { timeoutMs = 10_000 } = {}) {
351
+ const session = await sessionFor(udid, { timeoutMs });
352
+ const out = [];
353
+ for (const line of lines) out.push(await session.send(line, { timeoutMs }));
354
+ return out.join('');
355
+ }
356
+
357
+ // --- the emulator's gRPC endpoint -------------------------------------------
358
+ //
359
+ // Reached with nothing but `node:http2`, because a unary gRPC call is a plain
360
+ // HTTP/2 POST: a five-byte frame header in front of the message, `grpc-status`
361
+ // in the trailers, and that is the whole protocol for this purpose. No
362
+ // dependency, which is what makes it usable here at all.
363
+ //
364
+ // It exists for one thing so far: the clipboard. `cmd clipboard` does not exist
365
+ // on API 36 and `service call clipboard` depends on transaction numbers that
366
+ // move between platform versions, so this was written up as "no path to the
367
+ // Android clipboard" until the emulator's own service definitions turned out to
368
+ // declare `setClipboard`, `getClipboard` and `streamClipboard`. `ClipData` is
369
+ // the simplest message protobuf can express — one string field — so the encoder
370
+ // below is three lines rather than a library.
371
+
372
+ /** Where a running emulator writes its own port and token. */
373
+ function runningAvdDirs() {
374
+ return [
375
+ path.join(os.homedir(), 'Library/Caches/TemporaryItems/avd/running'),
376
+ process.env.XDG_RUNTIME_DIR ? path.join(process.env.XDG_RUNTIME_DIR, 'avd/running') : null,
377
+ path.join(os.tmpdir(), `android-${os.userInfo().username}`, 'avd/running'),
378
+ ].filter(Boolean);
379
+ }
380
+
381
+ const endpointCache = new Map();
382
+
383
+ /**
384
+ * The gRPC port and token for a device.
385
+ *
386
+ * The emulator writes both into a per-process ini alongside the AVD it is
387
+ * running, which is how this avoids hardcoding 8554 and works with a second
388
+ * emulator on another port. The token is a local credential the emulator wrote
389
+ * for whoever can read the file — the same status as the console auth token —
390
+ * and it is read at call time, never logged and never stored anywhere else.
391
+ */
392
+ function grpcEndpoint(udid) {
393
+ const cached = endpointCache.get(udid);
394
+ if (cached && Date.now() - cached.at < DEVICE_CACHE_MS) return cached.endpoint;
395
+ const serial = consolePort(udid);
396
+ if (!serial) throw new Error(`${udid} is not an emulator serial`);
397
+ for (const dir of runningAvdDirs()) {
398
+ let names = [];
399
+ try {
400
+ names = fs.readdirSync(dir).filter((f) => /^pid_\d+\.ini$/.test(f));
401
+ } catch {
402
+ continue;
403
+ }
404
+ for (const name of names) {
405
+ let text = '';
406
+ try {
407
+ text = fs.readFileSync(path.join(dir, name), 'utf8');
408
+ } catch {
409
+ continue;
410
+ }
411
+ const field = (key) => new RegExp(`^${key.replace('.', '\\.')}=(.*)$`, 'm').exec(text)?.[1]?.trim();
412
+ if (field('port.serial') !== String(serial)) continue;
413
+ const port = Number(field('grpc.port'));
414
+ const token = field('grpc.token');
415
+ if (!port) continue;
416
+ const endpoint = { port, token: token || null };
417
+ endpointCache.set(udid, { at: Date.now(), endpoint });
418
+ return endpoint;
419
+ }
420
+ }
421
+ throw new Error(
422
+ `could not find the gRPC endpoint for ${udid} — no running-AVD record names console port ${serial}`,
423
+ );
424
+ }
425
+
426
+ /** A length-delimited protobuf string field. */
427
+ function protoString(fieldNumber, value) {
428
+ const body = Buffer.from(String(value), 'utf8');
429
+ const length = [];
430
+ let remaining = body.length;
431
+ do {
432
+ length.push((remaining & 0x7f) | (remaining > 0x7f ? 0x80 : 0));
433
+ remaining >>>= 7;
434
+ } while (remaining > 0);
435
+ return Buffer.concat([Buffer.from([(fieldNumber << 3) | 2]), Buffer.from(length), body]);
436
+ }
437
+
438
+ /** Read the first length-delimited field out of a protobuf message. */
439
+ function firstString(message) {
440
+ if (!message.length || (message[0] >> 3) !== 1) return '';
441
+ let offset = 1;
442
+ let length = 0;
443
+ let shift = 0;
444
+ for (;;) {
445
+ const byte = message[offset];
446
+ offset += 1;
447
+ length |= (byte & 0x7f) << shift;
448
+ if (!(byte & 0x80)) break;
449
+ shift += 7;
450
+ }
451
+ return message.subarray(offset, offset + length).toString('utf8');
452
+ }
453
+
454
+ function grpcCall(udid, method, message, { timeoutMs = 10_000 } = {}) {
455
+ const { port, token } = grpcEndpoint(udid);
456
+ return new Promise((resolve, reject) => {
457
+ const client = http2.connect(`http://127.0.0.1:${port}`);
458
+ const fail = (err) => {
459
+ client.close();
460
+ reject(err);
461
+ };
462
+ client.on('error', fail);
463
+ const header = Buffer.alloc(5);
464
+ header.writeUInt8(0, 0);
465
+ header.writeUInt32BE(message.length, 1);
466
+ const req = client.request({
467
+ ':method': 'POST',
468
+ ':path': `/android.emulation.control.EmulatorController/${method}`,
469
+ 'content-type': 'application/grpc',
470
+ te: 'trailers',
471
+ ...(token ? { authorization: `Bearer ${token}` } : {}),
472
+ });
473
+ req.setTimeout(timeoutMs, () => fail(new Error(`emulator gRPC ${method} timed out after ${timeoutMs}ms`)));
474
+ const chunks = [];
475
+ let status = null;
476
+ let detail = '';
477
+ const readStatus = (headers) => {
478
+ if (headers['grpc-status'] == null) return;
479
+ status = Number(headers['grpc-status']);
480
+ detail = headers['grpc-message'] ?? '';
481
+ };
482
+ req.on('response', readStatus);
483
+ req.on('trailers', readStatus);
484
+ req.on('error', fail);
485
+ req.on('data', (chunk) => chunks.push(chunk));
486
+ req.on('end', () => {
487
+ client.close();
488
+ if (status !== 0) {
489
+ // 16 is UNAUTHENTICATED, which here means the token was missing or
490
+ // stale rather than anything the caller did wrong.
491
+ const why = status === 16 ? 'the emulator refused the gRPC token' : `grpc-status ${status}`;
492
+ return reject(new Error(`${method} failed: ${why}${detail ? ` (${detail})` : ''}`));
493
+ }
494
+ // Strip the five-byte frame header the response carries too.
495
+ resolve(Buffer.concat(chunks).subarray(5));
496
+ });
497
+ req.end(Buffer.concat([header, message]));
498
+ });
499
+ }
500
+
501
+ /** A PNG that has not been written all the way to its IEND chunk is a truncated read. */
502
+ function completePng(file) {
503
+ try {
504
+ const fd = fs.openSync(file, 'r');
505
+ try {
506
+ const { size } = fs.fstatSync(fd);
507
+ if (size < 20) return false;
508
+ // The last chunk of a PNG is 12 bytes: a zero length, the type `IEND`,
509
+ // and a CRC. The type is therefore four bytes in from the end of those
510
+ // twelve — not four bytes in from the end of the file, which is the CRC
511
+ // and never spells anything.
512
+ const tail = Buffer.alloc(12);
513
+ fs.readSync(fd, tail, 0, 12, size - 12);
514
+ return tail.toString('latin1', 4, 8) === 'IEND';
515
+ } finally {
516
+ fs.closeSync(fd);
517
+ }
518
+ } catch {
519
+ return false;
520
+ }
521
+ }
522
+
523
+ /**
524
+ * A screenshot, host-side where possible.
525
+ *
526
+ * `screenrecord screenshot <dir>` makes the emulator write the PNG onto the
527
+ * host filesystem itself — 20 ms against 113 ms for `adb exec-out screencap -p`,
528
+ * because nothing crosses the adb transport. Its filename carries a
529
+ * one-second-resolution timestamp, so it goes into a directory of its own per
530
+ * call rather than into a shared one where two frames in the same second would
531
+ * collide.
532
+ *
533
+ * The console answers OK before the file is necessarily complete, so the read
534
+ * waits for a PNG that ends in IEND rather than for a file that exists.
535
+ *
536
+ * `mask` is a simctl concept (the device bezel) and has no Android meaning.
537
+ */
538
+ async function screenshot(udid, outFile, { mask: _mask = 'ignored' } = {}) {
539
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'simframe-shot-'));
540
+ try {
541
+ await consoleScript(udid, [`screenrecord screenshot ${dir}`]);
542
+ const deadline = Date.now() + 2000;
543
+ for (;;) {
544
+ const shot = fs.readdirSync(dir).map((f) => path.join(dir, f)).find(completePng);
545
+ if (shot) {
546
+ fs.renameSync(shot, outFile);
547
+ return;
548
+ }
549
+ if (Date.now() > deadline) throw new Error('the emulator console wrote no complete PNG within 2s');
550
+ await new Promise((r) => setTimeout(r, 3));
551
+ }
552
+ } catch (consoleErr) {
553
+ // Fall back to adb, and say what the faster path complained about: a silent
554
+ // fallback to a path five times slower is the failure mode this project has
555
+ // been bitten by twice.
556
+ try {
557
+ const { stdout } = await adb(udid, ['exec-out', 'screencap', '-p'], { encoding: 'buffer', timeout: 20_000 });
558
+ fs.writeFileSync(outFile, stdout);
559
+ process.env.SIMFRAME_QUIET === '1' ||
560
+ process.stderr.write(`simframe: emulator console unavailable (${consoleErr.message}); used adb screencap\n`);
561
+ } catch (adbErr) {
562
+ throw new Error(`screenshot failed: ${detailOf(adbErr)} (console path: ${consoleErr.message})`);
563
+ }
564
+ } finally {
565
+ fs.rmSync(dir, { recursive: true, force: true });
566
+ }
567
+ }
568
+
569
+ /**
570
+ * The device's real geometry.
571
+ *
572
+ * Without this, `deviceGeometry` fell through to a guess derived from the ring
573
+ * image — an Android emulator reported "393x700pt", which is the capture size
574
+ * and not a coordinate space anything on the device has ever heard of. Every
575
+ * tap point derived from it would have been wrong, silently, which is the worst
576
+ * available outcome for an input path.
577
+ *
578
+ * `wm size` is physical pixels and `wm density` is dpi; Android's density
579
+ * independent pixel is 1/160th of an inch, so the scale factor is dpi/160 and
580
+ * the point size is the pixel size divided by it. Both in one shell hop.
581
+ */
582
+ const geometryCache = new Map();
583
+
584
+ async function geometry(udid) {
585
+ const cached = geometryCache.get(udid);
586
+ if (cached && Date.now() - cached.at < DEVICE_CACHE_MS) return cached.geo;
587
+ const { stdout } = await adb(udid, ['shell', 'wm size; wm density']);
588
+ const text = stdout.replace(/\r/g, '');
589
+ const size = /Physical size:\s*(\d+)x(\d+)/.exec(text);
590
+ const dpi = /Physical density:\s*(\d+)/.exec(text);
591
+ if (!size || !dpi) throw new Error(`could not read the screen geometry: ${text.trim() || 'no answer'}`);
592
+ const density = Number(dpi[1]) / 160;
593
+ const geo = {
594
+ pixelWidth: Number(size[1]),
595
+ pixelHeight: Number(size[2]),
596
+ density,
597
+ pointWidth: Math.round(Number(size[1]) / density),
598
+ pointHeight: Math.round(Number(size[2]) / density),
599
+ };
600
+ geometryCache.set(udid, { at: Date.now(), geo });
601
+ return geo;
602
+ }
603
+
604
+ // --- input ------------------------------------------------------------------
605
+ //
606
+ // `event mouse <x> <y> <device> <buttonstate>` with device 0 is the touch
607
+ // screen, and buttonstate 1 and 0 are down and up. It takes **device pixels**,
608
+ // which was settled by watching the kernel rather than by reading the help
609
+ // text: sending (360, 640) on a 720x1280 screen makes the touch driver report
610
+ // 0x3fff on both axes, exactly half of its 0-32767 range. In device-independent
611
+ // pixels 360 would have been the full width and reported the maximum.
612
+ //
613
+ // Everything above the boundary works in points, so the conversion happens
614
+ // here, at the only place that knows the density.
615
+
616
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
617
+
618
+ /** Points to device pixels, clamped to the screen so a bad point cannot be silently off-device. */
619
+ function toPixels(geo, x, y) {
620
+ const px = Math.round(x * geo.density);
621
+ const py = Math.round(y * geo.density);
622
+ if (px < 0 || py < 0 || px > geo.pixelWidth || py > geo.pixelHeight) {
623
+ throw new Error(
624
+ `${Math.round(x)},${Math.round(y)}pt is off a ${geo.pointWidth}x${geo.pointHeight}pt screen`,
625
+ );
626
+ }
627
+ return { x: px, y: py };
628
+ }
629
+
630
+ /**
631
+ * A tap: down, a hold, up.
632
+ *
633
+ * The hold is not decoration. A down and an up in the same millisecond is not
634
+ * something a finger can do, and Android's own gesture detectors time
635
+ * touches — a tap with no duration is exactly the "teleporting tap" this
636
+ * project rules out on iOS. 60 ms is a short human tap; anything over 500 ms is
637
+ * a long press, and that is the same code path.
638
+ */
639
+ async function tap(udid, x, y, { durationMs = 60 } = {}) {
640
+ const geo = await geometry(udid);
641
+ const p = toPixels(geo, x, y);
642
+ const session = await sessionFor(udid);
643
+ await session.send(`event mouse ${p.x} ${p.y} 0 1`);
644
+ await sleep(Math.max(1, durationMs));
645
+ await session.send(`event mouse ${p.x} ${p.y} 0 0`);
646
+ }
647
+
648
+ /** How many moves a swipe is made of. Enough to be a gesture, few enough to keep the timing. */
649
+ const SWIPE_STEPS = 12;
650
+
651
+ /**
652
+ * A swipe: down, a run of moves with time between them, up.
653
+ *
654
+ * Eased rather than linear, because a real finger accelerates and decelerates
655
+ * and Android's fling detector reads velocity off the last few moves. A linear
656
+ * drag that stops dead reads as a drag; an eased one that is still moving at
657
+ * the end reads as a fling, and which of those you get changes where a list
658
+ * lands.
659
+ */
660
+ async function swipe(udid, from, to, { durationMs = 300 } = {}) {
661
+ const geo = await geometry(udid);
662
+ const start = toPixels(geo, from.x, from.y);
663
+ const end = toPixels(geo, to.x, to.y);
664
+ const session = await sessionFor(udid);
665
+ const gap = Math.max(1, Math.round(durationMs / SWIPE_STEPS));
666
+ await session.send(`event mouse ${start.x} ${start.y} 0 1`);
667
+ for (let step = 1; step < SWIPE_STEPS; step += 1) {
668
+ const t = step / SWIPE_STEPS;
669
+ // Ease in-out: slow at both ends, quickest in the middle.
670
+ const eased = t < 0.5 ? 2 * t * t : 1 - 2 * (1 - t) * (1 - t);
671
+ const x = Math.round(start.x + (end.x - start.x) * eased);
672
+ const y = Math.round(start.y + (end.y - start.y) * eased);
673
+ await sleep(gap);
674
+ await session.send(`event mouse ${x} ${y} 0 1`);
675
+ }
676
+ await sleep(gap);
677
+ await session.send(`event mouse ${end.x} ${end.y} 0 0`);
678
+ }
679
+
680
+ /**
681
+ * Type text as keystrokes.
682
+ *
683
+ * `event text` takes the rest of the line, so a newline cannot be sent through
684
+ * it and is refused rather than silently dropped — `key enter` is the way to
685
+ * send one, and a caller that meant a line break should say so.
686
+ */
687
+ async function text(udid, value) {
688
+ const string = String(value);
689
+ if (/[\r\n]/.test(string)) {
690
+ throw new Error('a newline cannot be typed through the emulator console — send an `enter` key instead');
691
+ }
692
+ const session = await sessionFor(udid);
693
+ await session.send(`event text ${string}`);
694
+ }
695
+
696
+ /**
697
+ * The hardware and system keys, by name.
698
+ *
699
+ * Through `adb shell input keyevent`, not through raw kernel codes. On iOS the
700
+ * unverified Indigo button codes are refused outright because a wrong one can
701
+ * crash `backboardd` — here the public API takes names, so there is nothing to
702
+ * guess at and the whole vocabulary is safe to offer. It costs about 35 ms.
703
+ */
704
+ const KEYS = {
705
+ home: 'KEYCODE_HOME',
706
+ back: 'KEYCODE_BACK',
707
+ recents: 'KEYCODE_APP_SWITCH',
708
+ appswitch: 'KEYCODE_APP_SWITCH',
709
+ power: 'KEYCODE_POWER',
710
+ lock: 'KEYCODE_POWER',
711
+ volumeup: 'KEYCODE_VOLUME_UP',
712
+ volumedown: 'KEYCODE_VOLUME_DOWN',
713
+ enter: 'KEYCODE_ENTER',
714
+ tab: 'KEYCODE_TAB',
715
+ escape: 'KEYCODE_ESCAPE',
716
+ backspace: 'KEYCODE_DEL',
717
+ delete: 'KEYCODE_DEL',
718
+ menu: 'KEYCODE_MENU',
719
+ search: 'KEYCODE_SEARCH',
720
+ paste: 'KEYCODE_PASTE',
721
+ };
722
+
723
+ async function key(udid, name) {
724
+ const wanted = String(name).toLowerCase().replace(/[\s_-]+/g, '');
725
+ const keycode = KEYS[wanted]
726
+ ?? (/^keycode_[a-z0-9_]+$/i.test(String(name)) ? String(name).toUpperCase() : null)
727
+ ?? (/^\d+$/.test(String(name)) ? String(name) : null);
728
+ if (!keycode) {
729
+ throw new Error(`unknown key "${name}" on Android — one of: ${Object.keys(KEYS).join(', ')}`);
730
+ }
731
+ await adb(udid, ['shell', 'input', 'keyevent', keycode]);
732
+ }
733
+
734
+ /**
735
+ * This backend's own input path, or null if it has none.
736
+ *
737
+ * iOS returns null here: its input is Indigo HID inside the daemon, which is
738
+ * simframe's own engine rather than anything the platform provides. Android's
739
+ * is the emulator console, host-side, with no adb in the gesture path at all.
740
+ */
741
+ function inputDriver() {
742
+ return {
743
+ id: 'console',
744
+ detail: 'emulator console (event mouse/text), host-side',
745
+ tap,
746
+ swipe,
747
+ text,
748
+ key,
749
+ };
750
+ }
751
+
752
+ /**
753
+ * Launch a package's launcher activity.
754
+ *
755
+ * `am start` needs a component, not a package, so the launcher activity is
756
+ * resolved first. `args` and `env` are simctl concepts: an Android app has no
757
+ * argv and no environment of its own, and quietly dropping them would let a
758
+ * flow think it had launched an app in a mode it never launched in.
759
+ */
760
+ async function launchApp(udid, bundleId, { args = [], env = {}, terminateFirst = false } = {}) {
761
+ if (args.length || Object.keys(env).length) {
762
+ throw new Error(
763
+ 'launch arguments and environment are simctl-only — an Android app has no argv or environment ' +
764
+ '(use intent extras from the app side, or drop them for this platform)',
765
+ );
766
+ }
767
+ let component;
768
+ try {
769
+ const { stdout } = await adb(udid, ['shell', 'cmd', 'package', 'resolve-activity', '--brief', bundleId]);
770
+ component = stdout.replace(/\r/g, '').trim().split('\n').pop();
771
+ } catch (err) {
772
+ throw new Error(`could not launch ${bundleId}: ${detailOf(err)}`);
773
+ }
774
+ if (!component || !component.includes('/')) {
775
+ throw new Error(`could not launch ${bundleId}: no launcher activity (is the package installed?)`);
776
+ }
777
+ // Relaunching means starting at the app's root, and on Android
778
+ // `force-stop` then `am start` does not: the platform restores the task's
779
+ // saved activity stack, so a "relaunched" Settings came back on the search
780
+ // screen a previous step had left it on — a flow testing the screen it was
781
+ // already on, which is the exact bug `relaunch` exists to prevent on iOS.
782
+ // `-S` stops the app and `--activity-clear-task` drops the restored stack.
783
+ const fresh = terminateFirst ? ['-S', '--activity-clear-task'] : [];
784
+ // `-W` waits for the activity to be idle and `-S` makes it a cold start:
785
+ // measured at 6.1 s for Settings on an idle emulator, and it exceeded the
786
+ // 20 s default while OCR and capture were competing for the same cores. The
787
+ // bound belongs to the app's start-up, not to adb.
788
+ const { stdout, stderr } = await adb(udid, ['shell', 'am', 'start', '-W', ...fresh, '-n', component], {
789
+ timeout: 60_000,
790
+ });
791
+ const said = `${stdout}${stderr}`;
792
+ // `am start` reports its failures on stdout and exits 0 — the same silent
793
+ // success `pm grant` has, and the reason setPermission below reads back.
794
+ const error = /^Error:.*$/m.exec(said);
795
+ if (error) throw new Error(`could not launch ${bundleId}: ${error[0].replace(/^Error:\s*/, '')}`);
796
+ }
797
+
798
+ async function terminateApp(udid, bundleId) {
799
+ await adb(udid, ['shell', 'am', 'force-stop', bundleId]);
800
+ }
801
+
802
+ async function openUrl(udid, url) {
803
+ const { stdout, stderr } = await adb(udid, ['shell', 'am', 'start', '-a', 'android.intent.action.VIEW', '-d', url]);
804
+ const error = /^Error:.*$/m.exec(`${stdout}${stderr}`);
805
+ if (error) throw new Error(`could not open ${url}: ${error[0].replace(/^Error:\s*/, '')}`);
806
+ }
807
+
808
+ /**
809
+ * The permission names simframe accepts on Android, and what each one is.
810
+ *
811
+ * These are not simctl's names and could not be: the two platforms do not have
812
+ * the same permissions. `reminders`, `siri`, `motion` and `media-library` have
813
+ * no Android equivalent and are absent rather than mapped to something close;
814
+ * `notifications` is real here and has no iOS equivalent. A name that means
815
+ * different things on two platforms would be worse than a name that only exists
816
+ * on one.
817
+ */
818
+ const PERMISSION_MAP = {
819
+ calendar: ['android.permission.READ_CALENDAR', 'android.permission.WRITE_CALENDAR'],
820
+ camera: ['android.permission.CAMERA'],
821
+ contacts: ['android.permission.READ_CONTACTS', 'android.permission.WRITE_CONTACTS'],
822
+ location: ['android.permission.ACCESS_COARSE_LOCATION', 'android.permission.ACCESS_FINE_LOCATION'],
823
+ 'location-always': ['android.permission.ACCESS_BACKGROUND_LOCATION'],
824
+ microphone: ['android.permission.RECORD_AUDIO'],
825
+ notifications: ['android.permission.POST_NOTIFICATIONS'],
826
+ phone: ['android.permission.READ_PHONE_STATE'],
827
+ photos: ['android.permission.READ_MEDIA_IMAGES', 'android.permission.READ_MEDIA_VIDEO'],
828
+ };
829
+
830
+ const PERMISSION_SERVICES = ['all', ...Object.keys(PERMISSION_MAP)];
831
+
832
+ /** What the device says is actually granted, which is not always what was asked for. */
833
+ async function grantedPermissions(udid, bundleId) {
834
+ const { stdout } = await adb(udid, ['shell', 'dumpsys', 'package', bundleId]);
835
+ const granted = new Map();
836
+ for (const [, name, value] of stdout.matchAll(/^\s+(android\.permission\.[A-Z_]+): granted=(true|false)/gm)) {
837
+ granted.set(name, value === 'true');
838
+ }
839
+ return granted;
840
+ }
841
+
842
+ /**
843
+ * Grant, revoke or reset a runtime permission, and then check that it happened.
844
+ *
845
+ * The read-back is the whole point. `pm grant` for a permission the package
846
+ * never declared prints nothing, writes nothing and exits 0 — measured — so a
847
+ * grant that reports success proves only that adb ran. This asks the device
848
+ * what it now believes and reports that instead.
849
+ */
850
+ async function setPermission(udid, action, service, bundleId) {
851
+ const verb = String(action).toLowerCase();
852
+ if (!['grant', 'revoke', 'reset'].includes(verb)) {
853
+ throw new Error(`permission action must be grant, revoke or reset (got "${action}")`);
854
+ }
855
+ if (!PERMISSION_SERVICES.includes(service)) {
856
+ throw new Error(`unknown permission "${service}" on Android — one of: ${PERMISSION_SERVICES.join(', ')}`);
857
+ }
858
+ if (!bundleId) {
859
+ throw new Error('Android permissions are per app — name the package to grant it to');
860
+ }
861
+ const wanted = service === 'all' ? Object.values(PERMISSION_MAP).flat() : PERMISSION_MAP[service];
862
+
863
+ if (verb === 'reset') {
864
+ await adb(udid, ['shell', 'pm', 'reset-permissions', '-p', bundleId]);
865
+ return `reset all permissions for ${bundleId}`;
866
+ }
867
+
868
+ const declared = await grantedPermissions(udid, bundleId);
869
+ const undeclared = wanted.filter((p) => !declared.has(p));
870
+ const actionable = wanted.filter((p) => declared.has(p));
871
+ if (!actionable.length) {
872
+ throw new Error(
873
+ `${bundleId} does not declare ${undeclared.join(', ')}, so ${verb} would do nothing ` +
874
+ '(an app can only be granted permissions it asks for)',
875
+ );
876
+ }
877
+ for (const permission of actionable) {
878
+ await adb(udid, ['shell', 'pm', verb, bundleId, permission]);
879
+ }
880
+ const after = await grantedPermissions(udid, bundleId);
881
+ const disagreed = actionable.filter((p) => after.get(p) !== (verb === 'grant'));
882
+ if (disagreed.length) {
883
+ throw new Error(`${verb} ${service} did not take effect for ${disagreed.join(', ')} — the device still disagrees`);
884
+ }
885
+ const skipped = undeclared.length ? ` (${bundleId} does not declare ${undeclared.join(', ')})` : '';
886
+ return `${verb === 'grant' ? 'granted' : 'revoked'} ${service} for ${bundleId}${skipped}`;
887
+ }
888
+
889
+ /**
890
+ * Put text on the device clipboard.
891
+ *
892
+ * Not over adb, which has no path to it: `cmd clipboard` does not exist on API
893
+ * 36 and `service call clipboard` depends on transaction numbers that move
894
+ * between platform versions. The emulator's gRPC endpoint declares
895
+ * `setClipboard(ClipData)` and that is the whole answer — about 48 ms, no
896
+ * dependency, no helper app on the device.
897
+ */
898
+ async function setPasteboard(udid, value) {
899
+ await grpcCall(udid, 'setClipboard', protoString(1, String(value)));
900
+ }
901
+
902
+ /** What the device currently holds. Mostly here to make the setter checkable. */
903
+ async function getPasteboard(udid) {
904
+ return firstString(await grpcCall(udid, 'getClipboard', Buffer.alloc(0)));
905
+ }
906
+
907
+ /**
908
+ * The prerequisites `simframe doctor` reports for this backend. Returned rather
909
+ * than printed so doctor stays one renderer: a backend says what it needs, and
910
+ * a machine missing it is told which tool, not which platform.
911
+ */
912
+ function toolchain() {
913
+ try {
914
+ const version = execFileSync(adbPath(), ['version'], { encoding: 'utf8' }).trim().split('\n')[0];
915
+ return [{ name: 'adb', level: 'ok', detail: version }];
916
+ } catch (err) {
917
+ // Optional, not broken: a machine with no Android SDK has not degraded from
918
+ // anything, and doctor's `optional` level exists for exactly this.
919
+ return [{ name: 'adb', level: 'optional', detail: `${err.message.split('—')[0].trim()} — Android devices unavailable` }];
920
+ }
921
+ }
922
+
923
+ /**
924
+ * What this backend can currently do.
925
+ *
926
+ * Frames: yes — the whole Node capture loop runs on Android unchanged, because
927
+ * it asks the boundary for a screenshot and a resize and both are real here.
928
+ * There is no framebuffer engine, so `screenshot` is not a downgrade on this
929
+ * platform and doctor must not report it as one.
930
+ *
931
+ * Input and the accessibility tree: not yet, and said so rather than answered
932
+ * with the iOS driver's name. Both paths are measured and unwired — the console's
933
+ * `event mouse` puts a real down/move/up on the touch screen in ~20 ms, and
934
+ * `uiautomator dump` costs 2 s a read, which is the interesting problem.
935
+ */
936
+ function capabilities() {
937
+ return {
938
+ captureEngines: ['screenshot'],
939
+ input: { supported: true, via: 'console' },
940
+ ax: {
941
+ supported: false,
942
+ note: 'not built for Android yet — `uiautomator dump` costs ~2s a read; see docs/DEFERRED.md',
943
+ },
944
+ };
945
+ }
946
+
947
+ /** @type {import('./index.js').Platform} */
948
+ export const platform = {
949
+ id: 'android',
950
+ deviceNoun: 'emulator',
951
+ listDevices,
952
+ bootedDevices,
953
+ resolveDevice,
954
+ isBootedSync,
955
+ ownsUdid,
956
+ geometry,
957
+ inputDriver,
958
+ screenshot,
959
+ launchApp,
960
+ terminateApp,
961
+ openUrl,
962
+ setPermission,
963
+ setPasteboard,
964
+ getPasteboard,
965
+ permissionServices: () => PERMISSION_SERVICES,
966
+ capabilities,
967
+ toolchain,
968
+ };