staysfixed 0.3.0 → 0.4.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.
Files changed (47) hide show
  1. package/README.md +534 -402
  2. package/package.json +8 -3
  3. package/src/cli/index.js +14 -0
  4. package/src/v2/adapters/android-driver.js +1705 -0
  5. package/src/v2/adapters/android.js +1117 -0
  6. package/src/v2/adapters/contract.js +565 -0
  7. package/src/v2/adapters/electron.js +1594 -0
  8. package/src/v2/adapters/http.js +733 -0
  9. package/src/v2/adapters/ios-driver.js +1551 -0
  10. package/src/v2/adapters/ios.js +989 -0
  11. package/src/v2/adapters/isolate.js +739 -0
  12. package/src/v2/adapters/process.js +920 -0
  13. package/src/v2/adapters/source.js +1241 -0
  14. package/src/v2/adapters/web-driver.js +1532 -0
  15. package/src/v2/adapters/web.js +1009 -0
  16. package/src/v2/adapters/windows.js +1329 -0
  17. package/src/v2/browsers.js +1203 -0
  18. package/src/v2/cause.js +364 -0
  19. package/src/v2/check.js +1331 -0
  20. package/src/v2/ci.js +1209 -0
  21. package/src/v2/cli.js +657 -0
  22. package/src/v2/cluster.js +372 -0
  23. package/src/v2/coverage.js +1116 -0
  24. package/src/v2/detect.js +1199 -0
  25. package/src/v2/doctor.js +1690 -0
  26. package/src/v2/escalate.js +679 -0
  27. package/src/v2/init.js +1394 -0
  28. package/src/v2/intent.js +659 -0
  29. package/src/v2/journeys/from-routes.js +498 -0
  30. package/src/v2/journeys/from-suite.js +988 -0
  31. package/src/v2/journeys/index.js +651 -0
  32. package/src/v2/journeys/record.js +516 -0
  33. package/src/v2/mcp/server.js +374 -0
  34. package/src/v2/mcp/tools.js +1571 -0
  35. package/src/v2/normalise.js +783 -0
  36. package/src/v2/observation.js +877 -0
  37. package/src/v2/rank.js +672 -0
  38. package/src/v2/reference.js +1051 -0
  39. package/src/v2/remote.js +911 -0
  40. package/src/v2/run.js +964 -0
  41. package/src/v2/sealed.js +564 -0
  42. package/src/v2/selfcheck.js +564 -0
  43. package/src/v2/ship.js +684 -0
  44. package/src/v2/store.js +703 -0
  45. package/src/v2/types.js +503 -0
  46. package/src/v2/waiver.js +511 -0
  47. package/src/watch/panel.js +73 -44
@@ -0,0 +1,1705 @@
1
+ /**
2
+ * The Android machinery, kept apart from the policy.
3
+ *
4
+ * Everything in this file is mechanical: find the tools, talk to a device, read an APK,
5
+ * read what is on the screen, press something, write down what went out. No decisions about
6
+ * what is worth observing and no `Observation` is built here — that is `android.js`, and the
7
+ * split is the same one `web.js` and `web-driver.js` already use, for the same reason: the
8
+ * mechanical half is the half that has to be tested against a real device, and it should be
9
+ * possible to do that without dragging the engine in.
10
+ *
11
+ * Two things in here are worth knowing about before reading the rest.
12
+ *
13
+ * FIRST — reading an APK needs nothing installed. `aapt2 dump badging` is the usual way to
14
+ * ask an APK what is inside it, and it needs a Java runtime, which is exactly the thing a
15
+ * stranger's machine is least likely to have. So the ZIP and the binary XML are both read
16
+ * here, in about three hundred lines, with nothing but `node:zlib`. The whole contract
17
+ * channel — the package name, the version, every permission it asks for, every activity,
18
+ * service, receiver and provider it declares and which of them are open to other apps — is
19
+ * therefore free on any machine, before anything is installed and before an emulator exists.
20
+ * That is the Android equivalent of reading 452 message channels out of a desktop app
21
+ * without running it.
22
+ *
23
+ * SECOND — nothing is ever addressed by where it is on screen. A screen coordinate changes
24
+ * when the font scale changes, when a keyboard opens, when a phrase gets longer in another
25
+ * language; comparing on coordinates would report a difference every time and finding a
26
+ * button by coordinate would press the wrong thing. So every control is addressed by what it
27
+ * IS — its resource id, failing that what a screen reader would call it, failing that its
28
+ * text, failing that its kind and its position among its own siblings — and a tap works by
29
+ * looking that address up in the tree that is on screen right now and pressing the middle of
30
+ * whatever it found. The coordinate is worked out fresh every single time and is never
31
+ * stored, never compared.
32
+ */
33
+
34
+ import fs from 'node:fs';
35
+ import fsp from 'node:fs/promises';
36
+ import path from 'node:path';
37
+ import os from 'node:os';
38
+ import zlib from 'node:zlib';
39
+ import http from 'node:http';
40
+ import net from 'node:net';
41
+ import { execFile, spawn } from 'node:child_process';
42
+
43
+ // ---------------------------------------------------------------------------
44
+ // Finding the tools
45
+ // ---------------------------------------------------------------------------
46
+
47
+ /**
48
+ * Where an Android SDK lives on this machine, best guess first.
49
+ *
50
+ * The environment variables are checked before the usual folders because somebody who set
51
+ * one meant it. `ANDROID_HOME` is the old name and `ANDROID_SDK_ROOT` the new one; both are
52
+ * still in the wild and both are read.
53
+ *
54
+ * @returns {string[]}
55
+ */
56
+ export function sdkCandidates() {
57
+ const home = os.homedir();
58
+ return [
59
+ process.env.ANDROID_HOME,
60
+ process.env.ANDROID_SDK_ROOT,
61
+ path.join(home, 'Library/Android/sdk'),
62
+ path.join(home, 'Android/Sdk'),
63
+ path.join(home, 'AppData/Local/Android/Sdk'),
64
+ '/usr/local/share/android-sdk',
65
+ '/opt/android-sdk',
66
+ ].filter(/** @returns {p is string} */ (p) => typeof p === 'string' && p !== '');
67
+ }
68
+
69
+ /**
70
+ * @param {string} file
71
+ * @returns {boolean}
72
+ */
73
+ function runnable(file) {
74
+ try {
75
+ fs.accessSync(file, fs.constants.X_OK);
76
+ return fs.statSync(file).isFile();
77
+ } catch {
78
+ return false;
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Look for one program in the SDK, then on the PATH.
84
+ *
85
+ * @param {string[]} relatives Places inside an SDK, best first.
86
+ * @param {string} onPath What it is called if it happens to be on the PATH.
87
+ * @returns {string|null}
88
+ */
89
+ function findTool(relatives, onPath) {
90
+ for (const sdk of sdkCandidates()) {
91
+ for (const rel of relatives) {
92
+ const full = path.join(sdk, rel);
93
+ if (runnable(full)) return full;
94
+ }
95
+ }
96
+ for (const dir of (process.env.PATH ?? '').split(path.delimiter)) {
97
+ if (dir === '') continue;
98
+ const full = path.join(dir, onPath);
99
+ if (runnable(full)) return full;
100
+ }
101
+ return null;
102
+ }
103
+
104
+ /** @returns {string|null} */
105
+ export function findAdb() {
106
+ return findTool(['platform-tools/adb', 'platform-tools/adb.exe'], process.platform === 'win32' ? 'adb.exe' : 'adb');
107
+ }
108
+
109
+ /** @returns {string|null} */
110
+ export function findEmulator() {
111
+ return findTool(['emulator/emulator', 'emulator/emulator.exe'], process.platform === 'win32' ? 'emulator.exe' : 'emulator');
112
+ }
113
+
114
+ /**
115
+ * Every AVD this machine has, read straight off disk.
116
+ *
117
+ * Read from the folder rather than asked of `emulator -list-avds`, because the folder
118
+ * answers instantly and also says which system image each one uses, which is what decides
119
+ * whether the device can be rooted — and being able to root decides whether the files an app
120
+ * writes can be seen at all.
121
+ *
122
+ * @returns {{name: string, target: string, image: string, playStore: boolean, dir: string}[]}
123
+ */
124
+ export function listAvds() {
125
+ const dir = process.env.ANDROID_AVD_HOME ?? path.join(os.homedir(), '.android', 'avd');
126
+ /** @type {{name: string, target: string, image: string, playStore: boolean, dir: string}[]} */
127
+ const out = [];
128
+ /** @type {string[]} */
129
+ let entries;
130
+ try {
131
+ entries = fs.readdirSync(dir);
132
+ } catch {
133
+ return out;
134
+ }
135
+ for (const entry of entries) {
136
+ if (!entry.endsWith('.ini') || entry.includes('.avd')) continue;
137
+ const name = entry.slice(0, -4);
138
+ const avdDir = path.join(dir, `${name}.avd`);
139
+ /** @type {Record<string, string>} */
140
+ const config = {};
141
+ try {
142
+ for (const line of fs.readFileSync(path.join(avdDir, 'config.ini'), 'utf8').split('\n')) {
143
+ const at = line.indexOf('=');
144
+ if (at > 0) config[line.slice(0, at).trim()] = line.slice(at + 1).trim();
145
+ }
146
+ } catch {
147
+ continue;
148
+ }
149
+ out.push({
150
+ name,
151
+ target: config.target ?? 'unknown',
152
+ image: config['image.sysdir.1'] ?? '',
153
+ playStore: config['PlayStore.enabled'] === 'true',
154
+ dir: avdDir,
155
+ });
156
+ }
157
+ return out;
158
+ }
159
+
160
+ // ---------------------------------------------------------------------------
161
+ // Talking to a device
162
+ // ---------------------------------------------------------------------------
163
+
164
+ /**
165
+ * @typedef {object} Ran
166
+ * @property {boolean} ok
167
+ * @property {number} code
168
+ * @property {string} out
169
+ * @property {string} err
170
+ * @property {number} ms
171
+ */
172
+
173
+ /**
174
+ * Run one program and wait for it.
175
+ *
176
+ * `execFile` is used rather than a shell so nothing in a device's output can ever be read as
177
+ * a command. The timeout is generous by default because installing an APK on a cold emulator
178
+ * genuinely does take the better part of a minute, and a timeout that fires during a normal
179
+ * install would report the install as a difference.
180
+ *
181
+ * @param {string} file
182
+ * @param {string[]} args
183
+ * @param {{timeoutMs?: number, signal?: AbortSignal, cwd?: string, input?: string, maxBuffer?: number}} [opts]
184
+ * @returns {Promise<Ran>}
185
+ */
186
+ export function run(file, args, opts = {}) {
187
+ const started = Date.now();
188
+ return new Promise((resolve) => {
189
+ const child = execFile(
190
+ file,
191
+ args,
192
+ {
193
+ timeout: opts.timeoutMs ?? 120000,
194
+ signal: opts.signal,
195
+ cwd: opts.cwd,
196
+ maxBuffer: opts.maxBuffer ?? 32 * 1024 * 1024,
197
+ encoding: 'utf8',
198
+ },
199
+ (error, stdout, stderr) => {
200
+ const anyError = /** @type {any} */ (error);
201
+ resolve({
202
+ ok: !error,
203
+ code: anyError?.code === undefined ? (error ? 1 : 0) : Number(anyError.code) || 0,
204
+ out: String(stdout ?? ''),
205
+ err: String(stderr ?? '') || (error ? String(anyError.message ?? error) : ''),
206
+ ms: Date.now() - started,
207
+ });
208
+ },
209
+ );
210
+ if (opts.input !== undefined && child.stdin) child.stdin.end(opts.input);
211
+ });
212
+ }
213
+
214
+ /**
215
+ * One device, and every way of asking it something.
216
+ *
217
+ * A tiny object rather than free functions taking a serial everywhere, because the serial is
218
+ * on literally every call and forgetting it on one of them would silently talk to somebody
219
+ * else's phone.
220
+ */
221
+ export class Device {
222
+ /**
223
+ * @param {string} adb Path to adb.
224
+ * @param {string} serial e.g. `emulator-5566`.
225
+ * @param {{signal?: AbortSignal, log?: (m: string) => void}} [opts]
226
+ */
227
+ constructor(adb, serial, opts = {}) {
228
+ this.adb = adb;
229
+ this.serial = serial;
230
+ this.signal = opts.signal;
231
+ this.log = opts.log ?? (() => {});
232
+ /** Set once we know whether `adb root` worked, so we stop asking. */
233
+ this.rooted = /** @type {boolean|null} */ (null);
234
+ }
235
+
236
+ /**
237
+ * @param {string[]} args
238
+ * @param {{timeoutMs?: number, input?: string}} [opts]
239
+ * @returns {Promise<Ran>}
240
+ */
241
+ cmd(args, opts = {}) {
242
+ return run(this.adb, ['-s', this.serial, ...args], { ...opts, signal: this.signal });
243
+ }
244
+
245
+ /**
246
+ * Run a shell command on the device.
247
+ *
248
+ * The command is passed as a single string because that is what `adb shell` does with it
249
+ * anyway, and pretending otherwise would only hide the fact. Callers must not build one of
250
+ * these out of anything a device told them.
251
+ *
252
+ * @param {string} command
253
+ * @param {{timeoutMs?: number}} [opts]
254
+ * @returns {Promise<Ran>}
255
+ */
256
+ shell(command, opts = {}) {
257
+ return this.cmd(['shell', command], opts);
258
+ }
259
+
260
+ /**
261
+ * Run something and get raw bytes back — a screenshot, a file.
262
+ * @param {string} command
263
+ * @returns {Promise<Buffer>}
264
+ */
265
+ bytes(command) {
266
+ return new Promise((resolve, reject) => {
267
+ const child = spawn(this.adb, ['-s', this.serial, 'exec-out', command], { signal: this.signal });
268
+ /** @type {Buffer[]} */
269
+ const chunks = [];
270
+ child.stdout.on('data', (b) => chunks.push(b));
271
+ child.on('error', reject);
272
+ child.on('close', () => resolve(Buffer.concat(chunks)));
273
+ });
274
+ }
275
+
276
+ /**
277
+ * Ask for root, and remember the answer.
278
+ *
279
+ * A Play Store system image refuses, permanently, and that refusal is not a failure — it
280
+ * is a fact about the device that changes what can be seen, and the adapter reports it as
281
+ * missing coverage rather than pretending the files it cannot read are unchanged.
282
+ *
283
+ * @returns {Promise<boolean>}
284
+ */
285
+ async root() {
286
+ if (this.rooted !== null) return this.rooted;
287
+ const asked = await this.cmd(['root'], { timeoutMs: 30000 });
288
+ if (/cannot run as root|not allowed/i.test(`${asked.out}${asked.err}`)) {
289
+ this.rooted = false;
290
+ return false;
291
+ }
292
+ await this.cmd(['wait-for-device'], { timeoutMs: 60000 });
293
+ const who = await this.shell('id -u', { timeoutMs: 15000 });
294
+ this.rooted = who.out.trim() === '0';
295
+ return this.rooted;
296
+ }
297
+
298
+ /**
299
+ * Wait until the device has actually finished booting.
300
+ *
301
+ * Three questions rather than one, and they are not the same question. `adb wait-for-device`
302
+ * returns as soon as the daemon answers, which is long before anything can be installed;
303
+ * `sys.boot_completed` means the system is up; and the package manager can still be busy
304
+ * for a few seconds after that, which is exactly when an install fails with a message that
305
+ * looks like a real problem.
306
+ *
307
+ * @param {number} [timeoutMs]
308
+ * @returns {Promise<{ready: boolean, ms: number, why: string}>}
309
+ */
310
+ async waitUntilReady(timeoutMs = 300000) {
311
+ const started = Date.now();
312
+ await this.cmd(['wait-for-device'], { timeoutMs });
313
+ while (Date.now() - started < timeoutMs) {
314
+ const booted = await this.shell('getprop sys.boot_completed', { timeoutMs: 15000 });
315
+ if (booted.out.trim() === '1') {
316
+ const pm = await this.shell('pm path android', { timeoutMs: 20000 });
317
+ if (pm.ok) return { ready: true, ms: Date.now() - started, why: 'the device finished booting and the package manager is answering' };
318
+ }
319
+ await pause(1000, this.signal);
320
+ }
321
+ return { ready: false, ms: Date.now() - started, why: `the device did not finish booting within ${Math.round(timeoutMs / 1000)} seconds` };
322
+ }
323
+
324
+ /**
325
+ * @returns {Promise<Record<string, string>>}
326
+ */
327
+ async facts() {
328
+ const asked = await this.shell(
329
+ 'getprop ro.build.version.release; getprop ro.build.version.sdk; getprop ro.product.model; getprop ro.build.fingerprint; getprop ro.hardware',
330
+ { timeoutMs: 20000 },
331
+ );
332
+ const [release, sdk, model, fingerprint, hardware] = asked.out.split('\n').map((s) => s.trim());
333
+ return { release, sdk, model, fingerprint, hardware, emulator: String(/goldfish|ranchu/.test(hardware ?? '')) };
334
+ }
335
+ }
336
+
337
+ /**
338
+ * Every device adb can see, with enough about each to choose one.
339
+ *
340
+ * @param {string} adb
341
+ * @param {AbortSignal} [signal]
342
+ * @returns {Promise<{serial: string, state: string, emulator: boolean, avd: string|null}[]>}
343
+ */
344
+ export async function listDevices(adb, signal) {
345
+ const asked = await run(adb, ['devices', '-l'], { timeoutMs: 30000, signal });
346
+ /** @type {{serial: string, state: string, emulator: boolean, avd: string|null}[]} */
347
+ const out = [];
348
+ for (const line of asked.out.split('\n').slice(1)) {
349
+ const bits = line.trim().split(/\s+/);
350
+ if (bits.length < 2 || bits[0] === '') continue;
351
+ out.push({
352
+ serial: bits[0],
353
+ state: bits[1],
354
+ emulator: bits[0].startsWith('emulator-'),
355
+ avd: (/\bdevice:([^\s]+)/.exec(line) ?? [])[1] ?? null,
356
+ });
357
+ }
358
+ return out;
359
+ }
360
+
361
+ /**
362
+ * @param {number} ms
363
+ * @param {AbortSignal} [signal]
364
+ * @returns {Promise<void>}
365
+ */
366
+ export function pause(ms, signal) {
367
+ return new Promise((resolve, reject) => {
368
+ if (signal?.aborted) return reject(new Error('cancelled'));
369
+ const timer = setTimeout(() => {
370
+ signal?.removeEventListener('abort', onAbort);
371
+ resolve();
372
+ }, ms);
373
+ const onAbort = () => {
374
+ clearTimeout(timer);
375
+ reject(new Error('cancelled'));
376
+ };
377
+ signal?.addEventListener('abort', onAbort, { once: true });
378
+ });
379
+ }
380
+
381
+ // ---------------------------------------------------------------------------
382
+ // Reading an APK without installing anything
383
+ // ---------------------------------------------------------------------------
384
+
385
+ /**
386
+ * Pull one file out of a ZIP.
387
+ *
388
+ * An APK is a ZIP, and the only entry needed here is the manifest, so this is the smallest
389
+ * reader that can honestly claim to work: find the end-of-directory record, walk the central
390
+ * directory, and inflate the one entry asked for. Written rather than depended on because
391
+ * the whole point of this half of the file is that reading an APK costs a stranger nothing.
392
+ *
393
+ * @param {Buffer} zip
394
+ * @param {string} wanted Exact entry name, e.g. `AndroidManifest.xml`.
395
+ * @returns {Buffer|null}
396
+ */
397
+ export function readZipEntry(zip, wanted) {
398
+ // The end-of-directory record is at the very end unless there is a comment, which for an
399
+ // APK there is not; 64k back covers the legal maximum comment anyway.
400
+ let eocd = -1;
401
+ for (let i = zip.length - 22; i >= Math.max(0, zip.length - 66000); i -= 1) {
402
+ if (zip.readUInt32LE(i) === 0x06054b50) {
403
+ eocd = i;
404
+ break;
405
+ }
406
+ }
407
+ if (eocd < 0) return null;
408
+
409
+ const entries = zip.readUInt16LE(eocd + 10);
410
+ let at = zip.readUInt32LE(eocd + 16);
411
+
412
+ for (let n = 0; n < entries; n += 1) {
413
+ if (at + 46 > zip.length || zip.readUInt32LE(at) !== 0x02014b50) return null;
414
+ const method = zip.readUInt16LE(at + 10);
415
+ const compressed = zip.readUInt32LE(at + 20);
416
+ const nameLength = zip.readUInt16LE(at + 28);
417
+ const extraLength = zip.readUInt16LE(at + 30);
418
+ const commentLength = zip.readUInt16LE(at + 32);
419
+ const localAt = zip.readUInt32LE(at + 42);
420
+ const name = zip.toString('utf8', at + 46, at + 46 + nameLength);
421
+
422
+ if (name === wanted) {
423
+ if (zip.readUInt32LE(localAt) !== 0x04034b50) return null;
424
+ const localName = zip.readUInt16LE(localAt + 26);
425
+ const localExtra = zip.readUInt16LE(localAt + 28);
426
+ const dataAt = localAt + 30 + localName + localExtra;
427
+ const raw = zip.subarray(dataAt, dataAt + compressed);
428
+ if (method === 0) return Buffer.from(raw);
429
+ if (method === 8) {
430
+ try {
431
+ return zlib.inflateRawSync(raw);
432
+ } catch {
433
+ return null;
434
+ }
435
+ }
436
+ return null;
437
+ }
438
+ at += 46 + nameLength + extraLength + commentLength;
439
+ }
440
+ return null;
441
+ }
442
+
443
+ /**
444
+ * The android: attributes that matter, by number, for the case where a build tool dropped
445
+ * their names from the string pool.
446
+ *
447
+ * aapt2 keeps the names, so this is a fallback rather than the main path — but some
448
+ * obfuscators and some older toolchains do not, and an adapter that reported "this APK
449
+ * declares no activities" because of that would be lying rather than failing.
450
+ *
451
+ * @type {Record<number, string>}
452
+ */
453
+ const ANDROID_ATTRS = {
454
+ 0x01010000: 'theme',
455
+ 0x01010001: 'label',
456
+ 0x01010003: 'name',
457
+ 0x01010006: 'permission',
458
+ 0x0101000e: 'enabled',
459
+ 0x0101000f: 'debuggable',
460
+ 0x01010010: 'exported',
461
+ 0x01010011: 'process',
462
+ 0x01010018: 'authorities',
463
+ 0x0101001e: 'value',
464
+ 0x01010020: 'resource',
465
+ 0x0101020c: 'minSdkVersion',
466
+ 0x0101021b: 'versionCode',
467
+ 0x0101021c: 'versionName',
468
+ 0x01010270: 'targetSdkVersion',
469
+ 0x010103f6: 'usesCleartextTraffic',
470
+ };
471
+
472
+ /**
473
+ * Read Android's binary XML.
474
+ *
475
+ * The format is a list of chunks: a string pool, an optional map from string index to
476
+ * resource id, then start and end tags carrying attributes. Everything is little-endian and
477
+ * everything is padded to four bytes. What comes back is a plain tree, which is all anything
478
+ * here needs.
479
+ *
480
+ * @param {Buffer} buf
481
+ * @returns {{name: string, attrs: Record<string, string|number|boolean>, children: any[]}|null}
482
+ */
483
+ export function parseBinaryXml(buf) {
484
+ if (buf.length < 8 || buf.readUInt16LE(0) !== 0x0003) return null;
485
+
486
+ /** @type {string[]} */
487
+ let pool = [];
488
+ /** @type {number[]} */
489
+ let resourceMap = [];
490
+
491
+ /**
492
+ * @param {number} at
493
+ * @returns {string[]}
494
+ */
495
+ const readStringPool = (at) => {
496
+ const headerSize = buf.readUInt16LE(at + 2);
497
+ const count = buf.readUInt32LE(at + 8);
498
+ const flags = buf.readUInt32LE(at + 16);
499
+ const stringsStart = buf.readUInt32LE(at + 20);
500
+ const utf8 = (flags & (1 << 8)) !== 0;
501
+ /** @type {string[]} */
502
+ const strings = [];
503
+ for (let i = 0; i < count; i += 1) {
504
+ const offset = at + stringsStart + buf.readUInt32LE(at + headerSize + i * 4);
505
+ if (offset >= buf.length) {
506
+ strings.push('');
507
+ continue;
508
+ }
509
+ if (utf8) {
510
+ // Two lengths, characters then bytes, each one or two bytes depending on the top bit.
511
+ let p = offset;
512
+ const skip = (/** @type {number} */ q) => (buf[q] & 0x80 ? 2 : 1);
513
+ p += skip(p);
514
+ const byteLenAt = p;
515
+ const byteLen = buf[byteLenAt] & 0x80 ? ((buf[byteLenAt] & 0x7f) << 8) | buf[byteLenAt + 1] : buf[byteLenAt];
516
+ p += skip(byteLenAt);
517
+ strings.push(buf.toString('utf8', p, p + byteLen));
518
+ } else {
519
+ const chars = buf[offset] & 0x80 ? (((buf[offset] & 0x7f) << 8) | buf[offset + 1]) : buf.readUInt16LE(offset);
520
+ const p = offset + (buf[offset] & 0x80 ? 4 : 2);
521
+ strings.push(buf.toString('utf16le', p, p + chars * 2));
522
+ }
523
+ }
524
+ return strings;
525
+ };
526
+
527
+ /** @param {number} index */
528
+ const str = (index) => (index >= 0 && index < pool.length ? pool[index] : '');
529
+
530
+ /** @type {{name: string, attrs: Record<string, string|number|boolean>, children: any[]}|null} */
531
+ let root = null;
532
+ /** @type {{name: string, attrs: Record<string, string|number|boolean>, children: any[]}[]} */
533
+ const stack = [];
534
+
535
+ let at = buf.readUInt16LE(2); // past the file header
536
+ while (at + 8 <= buf.length) {
537
+ const type = buf.readUInt16LE(at);
538
+ const size = buf.readUInt32LE(at + 4);
539
+ if (size < 8 || at + size > buf.length) break;
540
+
541
+ if (type === 0x0001) {
542
+ pool = readStringPool(at);
543
+ } else if (type === 0x0180) {
544
+ const headerSize = buf.readUInt16LE(at + 2);
545
+ resourceMap = [];
546
+ for (let p = at + headerSize; p + 4 <= at + size; p += 4) resourceMap.push(buf.readUInt32LE(p));
547
+ } else if (type === 0x0102) {
548
+ // The tag's own fields sit in a second header that begins 16 bytes in, and every
549
+ // offset below is counted from THERE rather than from the start of the chunk. Getting
550
+ // that wrong reads the right number of attributes out of the wrong place and hands
551
+ // back a manifest full of empty strings, which looks like an app that declares nothing.
552
+ const nameIndex = buf.readUInt32LE(at + 20);
553
+ const attrStart = buf.readUInt16LE(at + 24);
554
+ const attrSize = buf.readUInt16LE(at + 26);
555
+ const attrCount = buf.readUInt16LE(at + 28);
556
+ /** @type {Record<string, string|number|boolean>} */
557
+ const attrs = {};
558
+ for (let i = 0; i < attrCount; i += 1) {
559
+ const a = at + 16 + attrStart + i * attrSize;
560
+ if (a + 20 > buf.length) break;
561
+ const attrNameIndex = buf.readUInt32LE(a + 4);
562
+ const rawValueIndex = buf.readUInt32LE(a + 8);
563
+ const dataType = buf[a + 15];
564
+ const data = buf.readUInt32LE(a + 16);
565
+ let key = str(attrNameIndex);
566
+ if (key === '' && attrNameIndex < resourceMap.length) key = ANDROID_ATTRS[resourceMap[attrNameIndex]] ?? `attr:0x${resourceMap[attrNameIndex].toString(16)}`;
567
+ if (key === '') key = `attr:${attrNameIndex}`;
568
+
569
+ /** @type {string|number|boolean} */
570
+ let value;
571
+ if (dataType === 0x03) value = str(rawValueIndex === 0xffffffff ? data : rawValueIndex);
572
+ else if (dataType === 0x12) value = data !== 0;
573
+ else if (dataType === 0x10) value = data | 0;
574
+ else if (dataType === 0x11) value = `0x${data.toString(16)}`;
575
+ else if (dataType === 0x01) value = `@${data.toString(16)}`; // a reference into resources
576
+ else value = rawValueIndex !== 0xffffffff && str(rawValueIndex) !== '' ? str(rawValueIndex) : data;
577
+ attrs[key] = value;
578
+ }
579
+ const node = { name: str(nameIndex), attrs, children: /** @type {any[]} */ ([]) };
580
+ if (stack.length > 0) stack[stack.length - 1].children.push(node);
581
+ else root = node;
582
+ stack.push(node);
583
+ } else if (type === 0x0103) {
584
+ stack.pop();
585
+ }
586
+ at += size;
587
+ }
588
+ return root;
589
+ }
590
+
591
+ /**
592
+ * @typedef {object} ApkComponent
593
+ * @property {'activity'|'service'|'receiver'|'provider'} kind
594
+ * @property {string} name Fully qualified, with a leading dot expanded.
595
+ * @property {boolean} exported True when another app can reach it. This is the security
596
+ * surface, and a component that quietly became exported is
597
+ * exactly the kind of change nobody notices.
598
+ * @property {string|null} permission
599
+ * @property {string[]} actions Intent actions it answers to.
600
+ * @property {string[]} categories
601
+ * @property {boolean} launcher True for the one the home screen opens.
602
+ */
603
+
604
+ /**
605
+ * @typedef {object} ApkFacts
606
+ * @property {boolean} ok
607
+ * @property {string} why
608
+ * @property {string} pkg
609
+ * @property {string|null} versionName
610
+ * @property {number|null} versionCode
611
+ * @property {number|null} minSdk
612
+ * @property {number|null} targetSdk
613
+ * @property {boolean} debuggable
614
+ * @property {boolean} cleartext Whether it is allowed to talk plain HTTP. Decides whether
615
+ * a proxy can read anything at all beyond the address.
616
+ * @property {string[]} permissions
617
+ * @property {ApkComponent[]} components
618
+ * @property {string|null} launchActivity
619
+ * @property {string} sha256
620
+ * @property {number} bytes
621
+ */
622
+
623
+ /**
624
+ * Everything an APK will tell you before it is installed.
625
+ *
626
+ * This is the contract channel for Android, and it is worth being clear about how much it
627
+ * is: every permission the app asks for, every activity, service, broadcast receiver and
628
+ * content provider it declares, which of those any other app on the phone can reach, and
629
+ * which intents each one answers. A walkthrough sees the two screens somebody remembered to
630
+ * open. This sees every door.
631
+ *
632
+ * @param {string} apkPath
633
+ * @returns {Promise<ApkFacts>}
634
+ */
635
+ export async function readApk(apkPath) {
636
+ /** @type {ApkFacts} */
637
+ const blank = {
638
+ ok: false, why: '', pkg: '', versionName: null, versionCode: null, minSdk: null, targetSdk: null,
639
+ debuggable: false, cleartext: false, permissions: [], components: [], launchActivity: null, sha256: '', bytes: 0,
640
+ };
641
+ /** @type {Buffer} */
642
+ let zip;
643
+ try {
644
+ zip = await fsp.readFile(apkPath);
645
+ } catch (error) {
646
+ return { ...blank, why: `The APK at ${apkPath} could not be read: ${/** @type {Error} */ (error).message}` };
647
+ }
648
+ const { createHash } = await import('node:crypto');
649
+ const sha256 = createHash('sha256').update(zip).digest('hex');
650
+
651
+ const manifest = readZipEntry(zip, 'AndroidManifest.xml');
652
+ if (!manifest) return { ...blank, sha256, bytes: zip.length, why: 'This file does not contain an AndroidManifest.xml, so it is not an APK.' };
653
+ const tree = parseBinaryXml(manifest);
654
+ if (!tree || tree.name !== 'manifest') {
655
+ return { ...blank, sha256, bytes: zip.length, why: 'The manifest inside this APK is not in a shape this can read.' };
656
+ }
657
+
658
+ const pkg = String(tree.attrs.package ?? '');
659
+ /** @type {string[]} */
660
+ const permissions = [];
661
+ /** @type {ApkComponent[]} */
662
+ const components = [];
663
+ let minSdk = /** @type {number|null} */ (null);
664
+ let targetSdk = /** @type {number|null} */ (null);
665
+ let debuggable = false;
666
+ let cleartext = false;
667
+ let launchActivity = /** @type {string|null} */ (null);
668
+
669
+ /** @param {string} name */
670
+ const expand = (name) => (name.startsWith('.') ? `${pkg}${name}` : name.includes('.') ? name : `${pkg}.${name}`);
671
+
672
+ for (const child of tree.children) {
673
+ if (child.name === 'uses-permission' || child.name === 'uses-permission-sdk-23') {
674
+ if (child.attrs.name) permissions.push(String(child.attrs.name));
675
+ } else if (child.name === 'uses-sdk') {
676
+ if (child.attrs.minSdkVersion !== undefined) minSdk = Number(child.attrs.minSdkVersion) || null;
677
+ if (child.attrs.targetSdkVersion !== undefined) targetSdk = Number(child.attrs.targetSdkVersion) || null;
678
+ } else if (child.name === 'application') {
679
+ debuggable = child.attrs.debuggable === true;
680
+ cleartext = child.attrs.usesCleartextTraffic === true;
681
+ for (const part of child.children) {
682
+ /** @type {ApkComponent['kind']|null} */
683
+ const kind = part.name === 'activity' || part.name === 'activity-alias' ? 'activity'
684
+ : part.name === 'service' ? 'service'
685
+ : part.name === 'receiver' ? 'receiver'
686
+ : part.name === 'provider' ? 'provider'
687
+ : null;
688
+ if (!kind) continue;
689
+ /** @type {string[]} */
690
+ const actions = [];
691
+ /** @type {string[]} */
692
+ const categories = [];
693
+ for (const filter of part.children) {
694
+ if (filter.name !== 'intent-filter') continue;
695
+ for (const entry of filter.children) {
696
+ if (entry.name === 'action' && entry.attrs.name) actions.push(String(entry.attrs.name));
697
+ if (entry.name === 'category' && entry.attrs.name) categories.push(String(entry.attrs.name));
698
+ }
699
+ }
700
+ const launcher = actions.includes('android.intent.action.MAIN') && categories.includes('android.intent.category.LAUNCHER');
701
+ const name = expand(String(part.attrs.name ?? part.attrs.targetActivity ?? ''));
702
+ // A component with an intent filter is reachable by default; without one it is not.
703
+ const exported = part.attrs.exported === true || (part.attrs.exported === undefined && actions.length > 0);
704
+ components.push({ kind, name, exported, permission: part.attrs.permission ? String(part.attrs.permission) : null, actions, categories, launcher });
705
+ if (launcher && !launchActivity) launchActivity = name;
706
+ }
707
+ }
708
+ }
709
+
710
+ return {
711
+ ok: pkg !== '',
712
+ why: pkg !== ''
713
+ ? `${pkg} — ${components.length} component${components.length === 1 ? '' : 's'} and ${permissions.length} permission${permissions.length === 1 ? '' : 's'}, read straight out of the APK without installing it or needing Java.`
714
+ : 'The manifest in this APK has no package name.',
715
+ pkg,
716
+ versionName: tree.attrs.versionName !== undefined ? String(tree.attrs.versionName) : null,
717
+ versionCode: tree.attrs.versionCode !== undefined ? Number(tree.attrs.versionCode) || null : null,
718
+ minSdk,
719
+ targetSdk,
720
+ debuggable,
721
+ cleartext,
722
+ permissions: [...new Set(permissions)].sort(),
723
+ components: components.sort((a, b) => `${a.kind}:${a.name}`.localeCompare(`${b.kind}:${b.name}`)),
724
+ launchActivity,
725
+ sha256,
726
+ bytes: zip.length,
727
+ };
728
+ }
729
+
730
+ // ---------------------------------------------------------------------------
731
+ // Holding the device still
732
+ // ---------------------------------------------------------------------------
733
+
734
+ /**
735
+ * The settings that make one run look like the next.
736
+ *
737
+ * Every one of these is a thing that would otherwise differ between two runs and show up as
738
+ * a difference the tool would then have to explain away. Animations are the loudest by far:
739
+ * with them on, a screen read a fraction of a second after a tap is halfway through a fade
740
+ * and half its controls are not there yet, which reports as controls appearing and vanishing
741
+ * — the very worst kind of difference, and pure noise.
742
+ *
743
+ * `key` is what `settings put` is given; `where` is which of the three settings tables it
744
+ * lives in. They are not interchangeable and putting one in the wrong table silently does
745
+ * nothing, which is the failure that looks like the setting simply did not help.
746
+ *
747
+ * @type {{where: 'global'|'system'|'secure', key: string, value: string, why: string}[]}
748
+ */
749
+ export const STILLNESS = [
750
+ { where: 'global', key: 'window_animation_scale', value: '0', why: 'windows appearing and disappearing instantly instead of fading' },
751
+ { where: 'global', key: 'transition_animation_scale', value: '0', why: 'screens replacing each other instantly instead of sliding' },
752
+ { where: 'global', key: 'animator_duration_scale', value: '0', why: 'controls inside a screen not animating' },
753
+ { where: 'global', key: 'always_finish_activities', value: '0', why: 'screens not being destroyed the moment you leave them' },
754
+ { where: 'system', key: 'font_scale', value: '1.0', why: 'text the same size every run, so what fits on screen never changes' },
755
+ { where: 'system', key: 'screen_off_timeout', value: '1800000', why: 'the screen never going dark in the middle of a walkthrough' },
756
+ { where: 'system', key: 'accelerometer_rotation', value: '0', why: 'the screen never rotating on its own' },
757
+ { where: 'secure', key: 'immersive_mode_confirmations', value: 'confirmed', why: 'no first-time full-screen prompt covering the app' },
758
+ { where: 'secure', key: 'spell_checker_enabled', value: '0', why: 'no red underlines appearing under typed text' },
759
+ { where: 'secure', key: 'show_ime_with_hard_keyboard', value: '0', why: 'the on-screen keyboard not covering half the screen' },
760
+ ];
761
+
762
+ /**
763
+ * @typedef {object} Stillness
764
+ * @property {string[]} pinned What was fixed, in plain English.
765
+ * @property {string[]} couldNot What could not be fixed, in plain English. Honesty channel.
766
+ * @property {string} timezone
767
+ * @property {string} locale
768
+ * @property {string|null} clock What the device clock was set to, when it could be set.
769
+ */
770
+
771
+ /**
772
+ * Fix everything about the device that would otherwise wobble.
773
+ *
774
+ * The clock is the one that needs root, and on a Play Store system image root is refused
775
+ * forever — so a device that cannot have its clock stopped says so here rather than quietly
776
+ * producing a timestamp difference on every run for the rest of its life.
777
+ *
778
+ * @param {Device} device
779
+ * @param {{clock?: string, timezone?: string, locale?: string}} want
780
+ * @returns {Promise<Stillness>}
781
+ */
782
+ export async function holdStill(device, want) {
783
+ /** @type {string[]} */
784
+ const pinned = [];
785
+ /** @type {string[]} */
786
+ const couldNot = [];
787
+
788
+ for (const setting of STILLNESS) {
789
+ const put = await device.shell(`settings put ${setting.where} ${setting.key} ${setting.value}`, { timeoutMs: 20000 });
790
+ const read = await device.shell(`settings get ${setting.where} ${setting.key}`, { timeoutMs: 20000 });
791
+ if (put.ok && read.out.trim() === setting.value) pinned.push(setting.why);
792
+ else couldNot.push(`${setting.why} — the device would not accept ${setting.key}`);
793
+ }
794
+
795
+ const timezone = want.timezone ?? 'UTC';
796
+ const setZone = await device.shell(`service call alarm 3 s16 ${timezone}`, { timeoutMs: 20000 });
797
+ const zoneNow = (await device.shell('getprop persist.sys.timezone', { timeoutMs: 15000 })).out.trim();
798
+ if (zoneNow === timezone) pinned.push(`the clock reading ${timezone} rather than wherever this machine happens to be`);
799
+ else couldNot.push(`the time zone is ${zoneNow || 'unknown'} and would not move to ${timezone}${setZone.err ? ` (${setZone.err.trim()})` : ''}`);
800
+
801
+ const locale = want.locale ?? (await device.shell('getprop persist.sys.locale', { timeoutMs: 15000 })).out.trim() ?? 'en-US';
802
+
803
+ /** @type {string|null} */
804
+ let clock = null;
805
+ if (want.clock) {
806
+ const rooted = await device.root();
807
+ if (!rooted) {
808
+ couldNot.push('the date and time could not be frozen: this device refuses root, which a Play Store system image always does. Anything the app shows with a date in it will differ between runs and be treated as the product\'s own wobble.');
809
+ } else {
810
+ // Android's `date` is toybox, and it takes either MMDDhhmm or an epoch with an @ on
811
+ // the front. It does NOT take an ISO timestamp, and it fails with "bad date" rather
812
+ // than doing anything, which is easy to miss and leaves every run on a different clock.
813
+ const seconds = Math.floor(new Date(want.clock).getTime() / 1000);
814
+ await device.shell('settings put global auto_time 0', { timeoutMs: 20000 });
815
+ const set = await device.shell(`date -u @${seconds}`, { timeoutMs: 20000 });
816
+ if (set.ok && !/bad date/i.test(set.out + set.err)) {
817
+ clock = want.clock;
818
+ pinned.push(`the device believing it is ${want.clock}`);
819
+ } else {
820
+ couldNot.push(`the date could not be set to ${want.clock}: ${set.err.trim() || 'the device refused'}`);
821
+ }
822
+ }
823
+ }
824
+
825
+ // A device that thinks it has no internet keeps showing a warning triangle and keeps
826
+ // retrying in the background, both of which move. Better to leave the network up and stop
827
+ // the traffic at the proxy, where it can also be written down.
828
+ await device.shell('input keyevent KEYCODE_WAKEUP', { timeoutMs: 15000 });
829
+ await device.shell('wm dismiss-keyguard', { timeoutMs: 15000 });
830
+
831
+ return { pinned, couldNot, timezone: zoneNow || timezone, locale, clock };
832
+ }
833
+
834
+ // ---------------------------------------------------------------------------
835
+ // Reading the screen — meaning, never markup
836
+ // ---------------------------------------------------------------------------
837
+
838
+ /**
839
+ * One thing on screen, as the accessibility layer describes it.
840
+ *
841
+ * @typedef {object} Node
842
+ * @property {string} kind The widget class, short: `Button`, `EditText`, `TextView`.
843
+ * @property {string} id The resource id, without the package on the front.
844
+ * @property {string} name What a screen reader would say: the description, or the text.
845
+ * @property {string} text
846
+ * @property {string} desc
847
+ * @property {boolean} enabled
848
+ * @property {boolean} checkable
849
+ * @property {boolean} checked
850
+ * @property {boolean} clickable
851
+ * @property {boolean} focused
852
+ * @property {boolean} scrollable
853
+ * @property {boolean} password
854
+ * @property {boolean} selected
855
+ * @property {string} pkg
856
+ * @property {[number, number, number, number]} bounds Only ever used to press it. Never stored.
857
+ * @property {string} address The stable address this node is filed under.
858
+ * @property {number} depth
859
+ */
860
+
861
+ /** Widgets that are furniture: they carry no meaning of their own and only add noise. */
862
+ const FURNITURE = new Set(['FrameLayout', 'LinearLayout', 'RelativeLayout', 'ViewGroup', 'View', 'ScrollView', 'ConstraintLayout', 'CoordinatorLayout', 'NestedScrollView', 'RecyclerView', 'ListView']);
863
+
864
+ /**
865
+ * @param {string} value
866
+ * @returns {string}
867
+ */
868
+ function tidy(value) {
869
+ return String(value ?? '').replace(/\s+/g, ' ').trim();
870
+ }
871
+
872
+ /**
873
+ * Turn the accessibility dump into a flat list with a stable address on each entry.
874
+ *
875
+ * The address is the whole design of this file in one function. It is built out of what a
876
+ * control IS, in this order:
877
+ *
878
+ * 1. Its resource id. A developer put that there deliberately and it survives everything.
879
+ * 2. What a screen reader would call it — the content description.
880
+ * 3. Its own text.
881
+ * 4. Its kind plus which one it is among its siblings of the same kind.
882
+ *
883
+ * Nothing about where it is on screen goes in. Two runs on the same device would agree about
884
+ * coordinates, but a font-scale change, a longer translation or an open keyboard would not,
885
+ * and an address that moves is an address that reports a false difference. Falling all the
886
+ * way through to `Button#2` is the honest bottom of the ladder: it says the tool is counting
887
+ * rather than recognising, and that is worth knowing when a finding comes from one.
888
+ *
889
+ * @param {string} xml
890
+ * @param {{pkg?: string, keepFurniture?: boolean}} [opts]
891
+ * @returns {Node[]}
892
+ */
893
+ export function readTree(xml, opts = {}) {
894
+ /** @type {Node[]} */
895
+ const nodes = [];
896
+ /** @type {{path: string, counts: Map<string, number>}[]} */
897
+ const stack = [];
898
+ const tokens = String(xml).matchAll(/<(\/?)(hierarchy|node)\b([^>]*?)(\/?)>/g);
899
+
900
+ for (const token of tokens) {
901
+ const closing = token[1] === '/';
902
+ const tag = token[2];
903
+ const attrText = token[3] ?? '';
904
+ const selfClosing = token[4] === '/';
905
+
906
+ if (tag === 'hierarchy') {
907
+ if (!closing) stack.push({ path: '', counts: new Map() });
908
+ else stack.pop();
909
+ continue;
910
+ }
911
+ if (closing) {
912
+ stack.pop();
913
+ continue;
914
+ }
915
+
916
+ /** @type {Record<string, string>} */
917
+ const attrs = {};
918
+ for (const pair of attrText.matchAll(/([a-zA-Z-]+)="([^"]*)"/g)) attrs[pair[1]] = pair[2];
919
+
920
+ const kind = (attrs.class ?? '').split('.').pop() ?? '';
921
+ const rawId = attrs['resource-id'] ?? '';
922
+ const id = rawId.includes('/') ? rawId.split('/')[1] : rawId;
923
+ const text = tidy(attrs.text);
924
+ const desc = tidy(attrs['content-desc']);
925
+ const parent = stack[stack.length - 1] ?? { path: '', counts: new Map() };
926
+
927
+ // Whether this thing can be operated decides whether its own words are allowed to
928
+ // identify it. A button's label IS what the button is, and naming it by its label is
929
+ // what makes the address survive a redesign. A paragraph's words are DATA — they are
930
+ // the very thing a change is likely to alter — so naming a paragraph by its words would
931
+ // turn every edited sentence into a control that vanished and another that appeared,
932
+ // which is the loudest and least useful difference this tool can report.
933
+ const operable = attrs.clickable === 'true' || attrs.checkable === 'true' || attrs['long-clickable'] === 'true'
934
+ || attrs.focusable === 'true' || attrs.scrollable === 'true' || kind === 'EditText';
935
+
936
+ /** @type {string} */
937
+ let own;
938
+ if (id !== '') own = `${kind}:${id}`;
939
+ else if (desc !== '') own = `${kind}:${desc}`;
940
+ else if (operable && text !== '' && text.length <= 64) own = `${kind}:${text}`;
941
+ else {
942
+ const seen = (parent.counts.get(kind) ?? 0) + 1;
943
+ parent.counts.set(kind, seen);
944
+ own = `${kind}#${seen}`;
945
+ }
946
+ // Only an ancestor that is something in its own right — one with an id, a description or
947
+ // an operable label — becomes part of the address. Wrapping a screen in one more layout
948
+ // to fix its spacing is a restyle, and a restyle must report nothing at all.
949
+ const named = id !== '' || desc !== '' || (operable && text !== '');
950
+ const address = parent.path === '' ? own : `${parent.path}/${own}`;
951
+
952
+ const bounds = /\[(\d+),(\d+)\]\[(\d+),(\d+)\]/.exec(attrs.bounds ?? '');
953
+ /** @type {Node} */
954
+ const node = {
955
+ kind,
956
+ id,
957
+ name: desc || text,
958
+ text,
959
+ desc,
960
+ enabled: attrs.enabled === 'true',
961
+ checkable: attrs.checkable === 'true',
962
+ checked: attrs.checked === 'true',
963
+ clickable: attrs.clickable === 'true',
964
+ focused: attrs.focused === 'true',
965
+ scrollable: attrs.scrollable === 'true',
966
+ password: attrs.password === 'true',
967
+ selected: attrs.selected === 'true',
968
+ pkg: attrs.package ?? '',
969
+ bounds: bounds
970
+ ? [Number(bounds[1]), Number(bounds[2]), Number(bounds[3]), Number(bounds[4])]
971
+ : [0, 0, 0, 0],
972
+ address,
973
+ depth: stack.length,
974
+ };
975
+
976
+ const wanted = (!opts.pkg || node.pkg === opts.pkg) && (opts.keepFurniture || !FURNITURE.has(kind) || node.clickable || node.scrollable);
977
+ if (wanted) nodes.push(node);
978
+ if (!selfClosing) stack.push({ path: named ? address : parent.path, counts: parent.counts });
979
+ }
980
+ return nodes;
981
+ }
982
+
983
+ /**
984
+ * Ask the device what is on screen.
985
+ *
986
+ * The dump is written to the device and read back rather than taken off the command's own
987
+ * output, because `uiautomator dump` prints a line of its own first and some builds of it
988
+ * mangle the XML on the way through the shell. Two attempts, because the dumper genuinely
989
+ * does fail with "could not get idle state" when something on screen is still moving — which
990
+ * is worth one retry and is worth reporting if it survives one.
991
+ *
992
+ * @param {Device} device
993
+ * @returns {Promise<{ok: boolean, xml: string, why: string}>}
994
+ */
995
+ export async function dumpScreen(device) {
996
+ const remote = '/data/local/tmp/staysfixed-screen.xml';
997
+ /** @type {string} */
998
+ let why = '';
999
+ for (let attempt = 0; attempt < 2; attempt += 1) {
1000
+ const dumped = await device.shell(`uiautomator dump --compressed ${remote}`, { timeoutMs: 60000 });
1001
+ if (/dumped to/i.test(dumped.out)) {
1002
+ const read = await device.shell(`cat ${remote}`, { timeoutMs: 60000 });
1003
+ if (read.out.includes('<hierarchy')) return { ok: true, xml: read.out, why: 'read what is on screen' };
1004
+ why = 'the screen was dumped but came back empty';
1005
+ } else {
1006
+ why = tidy(dumped.out || dumped.err) || 'the device would not dump what is on screen';
1007
+ }
1008
+ await pause(1200, device.signal);
1009
+ }
1010
+ return { ok: false, xml: '', why };
1011
+ }
1012
+
1013
+ /**
1014
+ * Wait until the screen stops changing.
1015
+ *
1016
+ * The same idea as `settle` in the freeze layer and for the same reason: reading a screen
1017
+ * that is still moving is the single biggest source of false differences on a phone. Two
1018
+ * identical dumps in a row is the signal. If it never settles, that is said out loud rather
1019
+ * than papered over — a screen that will not sit still is itself a finding.
1020
+ *
1021
+ * @param {Device} device
1022
+ * @param {{tries?: number, gapMs?: number}} [opts]
1023
+ * @returns {Promise<{ok: boolean, xml: string, tries: number, settled: boolean, why: string}>}
1024
+ */
1025
+ export async function settleScreen(device, opts = {}) {
1026
+ const tries = opts.tries ?? 6;
1027
+ const gap = opts.gapMs ?? 400;
1028
+ let previous = '';
1029
+ let last = { ok: false, xml: '', why: 'nothing was read' };
1030
+ for (let i = 0; i < tries; i += 1) {
1031
+ last = await dumpScreen(device);
1032
+ if (!last.ok) return { ...last, tries: i + 1, settled: false };
1033
+ const shape = readTree(last.xml).map((n) => `${n.address}|${n.text}|${n.enabled}|${n.checked}`).join('\n');
1034
+ if (shape === previous) return { ok: true, xml: last.xml, tries: i + 1, settled: true, why: 'the screen stopped changing' };
1035
+ previous = shape;
1036
+ await pause(gap, device.signal);
1037
+ }
1038
+ return { ok: last.ok, xml: last.xml, tries, settled: false, why: `the screen was still changing after ${tries} looks, so what follows may be halfway through something` };
1039
+ }
1040
+
1041
+ // ---------------------------------------------------------------------------
1042
+ // Pressing things — found by identifier, pressed by whatever coordinate it has today
1043
+ // ---------------------------------------------------------------------------
1044
+
1045
+ /**
1046
+ * Find one control by what it is.
1047
+ *
1048
+ * Accepts any of the four ways of naming a thing and tries them in the order that puts the
1049
+ * most deliberate first. Returns every match, so a caller can refuse to act when a name
1050
+ * turns out to mean two things — pressing the first of two identical buttons is how a
1051
+ * walkthrough silently starts doing something different from what it did last time.
1052
+ *
1053
+ * @param {Node[]} nodes
1054
+ * @param {{id?: string, name?: string, text?: string, kind?: string, address?: string}} want
1055
+ * @returns {Node[]}
1056
+ */
1057
+ export function findNodes(nodes, want) {
1058
+ return nodes.filter((node) => {
1059
+ if (want.address !== undefined && node.address !== want.address) return false;
1060
+ if (want.id !== undefined && node.id !== want.id) return false;
1061
+ if (want.name !== undefined && node.name.toLowerCase() !== want.name.toLowerCase()) return false;
1062
+ if (want.text !== undefined && node.text.toLowerCase() !== want.text.toLowerCase()) return false;
1063
+ if (want.kind !== undefined && node.kind !== want.kind) return false;
1064
+ return want.address !== undefined || want.id !== undefined || want.name !== undefined || want.text !== undefined || want.kind !== undefined;
1065
+ });
1066
+ }
1067
+
1068
+ /**
1069
+ * Press one control, having found it by name.
1070
+ *
1071
+ * The middle of whatever the tree says its edges are right now — worked out on this run, for
1072
+ * this device, at this font size, and thrown away immediately afterwards.
1073
+ *
1074
+ * @param {Device} device
1075
+ * @param {Node} node
1076
+ * @returns {Promise<Ran>}
1077
+ */
1078
+ export function pressNode(device, node) {
1079
+ const [left, top, right, bottom] = node.bounds;
1080
+ return device.shell(`input tap ${Math.round((left + right) / 2)} ${Math.round((top + bottom) / 2)}`, { timeoutMs: 30000 });
1081
+ }
1082
+
1083
+ /**
1084
+ * Type into whatever has the cursor.
1085
+ *
1086
+ * `input text` cannot carry a space or several kinds of punctuation through the shell, so
1087
+ * the text is sent in pieces with explicit space keys between them. Anything outside plain
1088
+ * ASCII is refused rather than mangled, because half-typed text looks exactly like a bug in
1089
+ * the app.
1090
+ *
1091
+ * @param {Device} device
1092
+ * @param {string} text
1093
+ * @returns {Promise<{ok: boolean, why: string}>}
1094
+ */
1095
+ export async function typeText(device, text) {
1096
+ if (/[^\x20-\x7e]/.test(text)) {
1097
+ return { ok: false, why: 'that text has characters the device keyboard cannot be told to type, so nothing was typed rather than typing something slightly different' };
1098
+ }
1099
+ const words = text.split(' ');
1100
+ for (let i = 0; i < words.length; i += 1) {
1101
+ if (words[i] !== '') {
1102
+ const safe = words[i].replace(/(["'`\\$&|;<>()*?~^#])/g, '\\$1');
1103
+ const typed = await device.shell(`input text ${safe}`, { timeoutMs: 30000 });
1104
+ if (!typed.ok) return { ok: false, why: `the device would not type "${words[i]}"` };
1105
+ }
1106
+ if (i < words.length - 1) await device.shell('input keyevent KEYCODE_SPACE', { timeoutMs: 20000 });
1107
+ }
1108
+ return { ok: true, why: `typed ${text.length} characters` };
1109
+ }
1110
+
1111
+ // ---------------------------------------------------------------------------
1112
+ // What went out — the wire
1113
+ // ---------------------------------------------------------------------------
1114
+
1115
+ /**
1116
+ * One call the device tried to make.
1117
+ *
1118
+ * @typedef {object} Call
1119
+ * @property {'plain'|'encrypted'} how `encrypted` means all that was seen is the host and
1120
+ * the port: the request itself was inside TLS and was
1121
+ * never opened. That is the honest limit of a proxy
1122
+ * and it is recorded rather than glossed over.
1123
+ * @property {string} method
1124
+ * @property {string} host
1125
+ * @property {number} port
1126
+ * @property {string} route The address with the changing parts taken out.
1127
+ * @property {boolean} allowed
1128
+ * @property {string} why
1129
+ */
1130
+
1131
+ /**
1132
+ * @typedef {object} Wire
1133
+ * @property {number} port
1134
+ * @property {Call[]} calls
1135
+ * @property {() => Promise<Call[]>} stop
1136
+ */
1137
+
1138
+ /**
1139
+ * Take the changing parts out of an address.
1140
+ *
1141
+ * A call to `/api/notes/8f2c1` and a call to `/api/notes/91aa4` are the same call, and
1142
+ * reporting them as different is how a tool teaches somebody to ignore it.
1143
+ *
1144
+ * @param {string} url
1145
+ * @returns {string}
1146
+ */
1147
+ export function shapeOfUrl(url) {
1148
+ const withoutQuery = String(url).split('?')[0];
1149
+ return withoutQuery
1150
+ .replace(/\/[0-9a-f]{8,}(?=\/|$)/gi, '/<an id>')
1151
+ .replace(/\/\d+(?=\/|$)/g, '/<a number>')
1152
+ .replace(/\/[0-9a-f]{8}-[0-9a-f-]{27}(?=\/|$)/gi, '/<an id>');
1153
+ }
1154
+
1155
+ /**
1156
+ * Whether a call is one this tool must never let happen.
1157
+ *
1158
+ * Deliberately blunt, and deliberately erring towards refusing. The cost of refusing a
1159
+ * harmless call is one line in the report saying it was not checked; the cost of allowing a
1160
+ * real one is somebody's money.
1161
+ *
1162
+ * @param {string} method
1163
+ * @param {string} url
1164
+ * @returns {{safe: boolean, why: string}}
1165
+ */
1166
+ export function looksIrreversible(method, url) {
1167
+ const target = String(url).toLowerCase();
1168
+ const writing = !['GET', 'HEAD', 'OPTIONS'].includes(String(method).toUpperCase());
1169
+ const money = /\b(pay|payment|charge|checkout|billing|invoice|subscribe|refund|purchase|order|stripe|paypal|braintree|adyen)\b/.test(target);
1170
+ const message = /\b(sms|email|mail|notify|notification|push|message|send|twilio|sendgrid|whatsapp|telegram)\b/.test(target);
1171
+ const destroying = /\b(delete|remove|destroy|drop|wipe|purge|deactivate|cancel)\b/.test(target);
1172
+ if (money) return { safe: false, why: 'this looks like it spends money' };
1173
+ if (message) return { safe: false, why: 'this looks like it sends a message to somebody' };
1174
+ if (writing && destroying) return { safe: false, why: 'this looks like it destroys data' };
1175
+ return { safe: true, why: '' };
1176
+ }
1177
+
1178
+ /**
1179
+ * Hosts that belong to the phone rather than to the app.
1180
+ *
1181
+ * A device-wide proxy cannot tell which process made a call — it sees a socket, not a
1182
+ * program — so calls are attributed by who they are to. Everything Google's own services do
1183
+ * in the background is filed separately, and this limit is stated in the adapter's own
1184
+ * description rather than hidden here.
1185
+ */
1186
+ const DEVICE_HOSTS = /(^|\.)(google|googleapis|gstatic|android|gvt1|gvt2|doubleclick|crashlytics|firebaseinstallations)\.com$|(^|\.)google\.[a-z.]+$/i;
1187
+
1188
+ /**
1189
+ * @param {string} host
1190
+ * @returns {boolean}
1191
+ */
1192
+ export function isDeviceHost(host) {
1193
+ return DEVICE_HOSTS.test(String(host).split(':')[0]);
1194
+ }
1195
+
1196
+ /**
1197
+ * Watch every call the device makes, and stop the ones that must not happen.
1198
+ *
1199
+ * A proxy on this machine, pointed at by the device's own proxy setting, is the practical
1200
+ * answer on Android: it needs no certificate, no root, and no change to the app. What it
1201
+ * buys is the whole of the effects channel — every address the app reaches for, in order,
1202
+ * with the method — and what it costs is the inside of anything encrypted, which stays shut.
1203
+ * That is exactly the "observed at the call boundary, refused at the effect" line the design
1204
+ * draws, and it is drawn here rather than argued about.
1205
+ *
1206
+ * By default NOTHING is forwarded. The device is talking to a wall that writes everything
1207
+ * down, which also removes the largest single source of wobble on a phone: the real internet.
1208
+ *
1209
+ * @param {{allowTo?: string[], log?: (m: string) => void}} [opts]
1210
+ * @returns {Promise<Wire>}
1211
+ */
1212
+ export async function watchTheWire(opts = {}) {
1213
+ /** @type {Call[]} */
1214
+ const calls = [];
1215
+ const allow = new Set((opts.allowTo ?? []).map((h) => h.toLowerCase()));
1216
+
1217
+ /**
1218
+ * @param {string} host
1219
+ * @param {number} port
1220
+ * @param {string} method
1221
+ * @param {string} route
1222
+ * @param {'plain'|'encrypted'} how
1223
+ * @returns {Call}
1224
+ */
1225
+ const record = (host, port, method, route, how) => {
1226
+ const verdict = looksIrreversible(method, `${host}${route}`);
1227
+ const allowed = allow.has(host.toLowerCase()) && verdict.safe;
1228
+ const call = {
1229
+ how, method, host, port, route,
1230
+ allowed,
1231
+ why: allowed
1232
+ ? 'let through, because this project said this address is its own'
1233
+ : verdict.safe
1234
+ ? 'written down and stopped here: nothing this run does is allowed off this machine'
1235
+ : `written down and stopped here: ${verdict.why}`,
1236
+ };
1237
+ calls.push(call);
1238
+ opts.log?.(`the app called ${method} ${host}${route} — ${call.why}`);
1239
+ return call;
1240
+ };
1241
+
1242
+ const server = http.createServer((request, response) => {
1243
+ let url;
1244
+ try {
1245
+ url = new URL(request.url ?? '/', 'http://unknown');
1246
+ } catch {
1247
+ url = new URL('http://unknown/');
1248
+ }
1249
+ record(url.hostname, Number(url.port) || 80, request.method ?? 'GET', shapeOfUrl(url.pathname), 'plain');
1250
+ // A refusal has to look like a refusal from the network, not like a working server
1251
+ // answering oddly, or the app under test takes a different path than it would in life.
1252
+ response.writeHead(503, { 'content-type': 'text/plain', 'x-stays-fixed': 'stopped here on purpose' });
1253
+ response.end('This call was written down and stopped by Stays Fixed. Nothing left this machine.\n');
1254
+ });
1255
+
1256
+ server.on('connect', (request, socket) => {
1257
+ const [host, port] = String(request.url ?? '').split(':');
1258
+ record(host, Number(port) || 443, 'CONNECT', '/<inside TLS, never opened>', 'encrypted');
1259
+ socket.end('HTTP/1.1 502 Bad Gateway\r\nX-Stays-Fixed: stopped here on purpose\r\n\r\n');
1260
+ });
1261
+
1262
+ // Anything that reaches the port without speaking HTTP is hung up on rather than left open.
1263
+ server.on('clientError', (_error, socket) => {
1264
+ if (socket instanceof net.Socket && !socket.destroyed) socket.end('HTTP/1.1 400 Bad Request\r\n\r\n');
1265
+ });
1266
+
1267
+ const port = await new Promise((resolve, reject) => {
1268
+ server.once('error', reject);
1269
+ server.listen(0, '0.0.0.0', () => {
1270
+ const address = server.address();
1271
+ resolve(typeof address === 'object' && address ? address.port : 0);
1272
+ });
1273
+ });
1274
+
1275
+ return {
1276
+ port,
1277
+ calls,
1278
+ stop: () =>
1279
+ new Promise((resolve) => {
1280
+ server.closeAllConnections?.();
1281
+ server.close(() => resolve(calls));
1282
+ }),
1283
+ };
1284
+ }
1285
+
1286
+ /**
1287
+ * The address the emulator reaches this machine on. Not localhost — inside the emulator that
1288
+ * is the emulator.
1289
+ */
1290
+ export const HOST_FROM_EMULATOR = '10.0.2.2';
1291
+
1292
+ // ---------------------------------------------------------------------------
1293
+ // What went out — files, permissions, intents
1294
+ // ---------------------------------------------------------------------------
1295
+
1296
+ /**
1297
+ * Every file the app has written, with its size.
1298
+ *
1299
+ * Contents are not read. A file's name and rough size is what changes when behaviour
1300
+ * changes; its contents are usually a timestamp away from differing on every run, and
1301
+ * reading them all off a device is slow enough to matter.
1302
+ *
1303
+ * Two ways in, and which one worked is reported, because they see different amounts. With
1304
+ * root, everything under the app's folder. Without root, `run-as` sees the same folder but
1305
+ * only for an app built as debuggable — and a release APK on a Play Store image can be seen
1306
+ * neither way, which is missing coverage and is said so.
1307
+ *
1308
+ * @param {Device} device
1309
+ * @param {string} pkg
1310
+ * @returns {Promise<{ok: boolean, how: string, why: string, files: {path: string, bytes: string}[]}>}
1311
+ */
1312
+ export async function filesWritten(device, pkg) {
1313
+ const find = `find /data/data/${pkg} -type f -not -path '*/cache/*' -exec stat -c '%s %n' {} + 2>/dev/null | head -400`;
1314
+
1315
+ /** @param {string} out */
1316
+ const parse = (out) =>
1317
+ out
1318
+ .split('\n')
1319
+ .map((line) => tidy(line))
1320
+ .filter((line) => line !== '')
1321
+ .map((line) => {
1322
+ const at = line.indexOf(' ');
1323
+ const bytes = Number(line.slice(0, at));
1324
+ return {
1325
+ path: line.slice(at + 1).replace(`/data/data/${pkg}/`, ''),
1326
+ // Bucketed, because a database file that grew by 12 bytes is not news and would
1327
+ // otherwise differ on every single run.
1328
+ bytes: bytes < 1024 ? 'under a kilobyte' : bytes < 102400 ? 'kilobytes' : bytes < 10485760 ? 'megabytes' : 'tens of megabytes or more',
1329
+ };
1330
+ })
1331
+ .sort((a, b) => a.path.localeCompare(b.path));
1332
+
1333
+ if (await device.root()) {
1334
+ const asked = await device.shell(find, { timeoutMs: 60000 });
1335
+ if (asked.ok) return { ok: true, how: 'as root', why: 'every file the app has written was listed', files: parse(asked.out) };
1336
+ }
1337
+ const viaRunAs = await device.shell(`run-as ${pkg} sh -c "${find.replace(/"/g, '\\"')}"`, { timeoutMs: 60000 });
1338
+ if (viaRunAs.ok && !/not debuggable|unknown package|Could not/i.test(viaRunAs.out + viaRunAs.err)) {
1339
+ return { ok: true, how: 'through the app itself', why: 'the files were listed by asking the app, which only works because it is a debuggable build', files: parse(viaRunAs.out) };
1340
+ }
1341
+ return {
1342
+ ok: false,
1343
+ how: 'not at all',
1344
+ why: 'the files this app writes cannot be seen: this device refuses root and the app is not a debuggable build. Anything it saves is therefore unchecked, not unchanged.',
1345
+ files: [],
1346
+ };
1347
+ }
1348
+
1349
+ /**
1350
+ * Which permissions the app has actually been granted, as opposed to which it asks for.
1351
+ *
1352
+ * The APK says what it wants; this says what it has. An app that quietly started being
1353
+ * granted something is a real change and it is invisible in every other channel.
1354
+ *
1355
+ * @param {Device} device
1356
+ * @param {string} pkg
1357
+ * @returns {Promise<{granted: string[], asked: string[]}>}
1358
+ */
1359
+ export async function permissionsHeld(device, pkg) {
1360
+ const dump = await device.shell(`dumpsys package ${pkg}`, { timeoutMs: 60000 });
1361
+ /** @type {Set<string>} */
1362
+ const granted = new Set();
1363
+ /** @type {Set<string>} */
1364
+ const asked = new Set();
1365
+ let section = '';
1366
+ for (const raw of dump.out.split('\n')) {
1367
+ const line = raw.trim();
1368
+ if (/^requested permissions:/i.test(line)) section = 'asked';
1369
+ else if (/^(install|runtime) permissions:/i.test(line)) section = 'granted';
1370
+ else if (/^[A-Za-z ]+:$/.test(line)) section = '';
1371
+ else if (line.startsWith('android.permission') || line.startsWith('com.')) {
1372
+ const name = line.split(':')[0].trim();
1373
+ if (section === 'asked') asked.add(name);
1374
+ if (section === 'granted' && /granted=true/.test(line)) granted.add(name);
1375
+ if (section === 'granted' && !line.includes('granted=')) granted.add(name);
1376
+ }
1377
+ }
1378
+ return { granted: [...granted].sort(), asked: [...asked].sort() };
1379
+ }
1380
+
1381
+ /**
1382
+ * What logcat said, cut down to this app and sorted into what it means.
1383
+ *
1384
+ * `--pid` is used where a pid is known, because a package's own tag is not enough: a crash
1385
+ * is printed by the runtime under its own tag and would be missed. Timestamps, thread ids
1386
+ * and pids are stripped from the text kept, since all three differ on every run and none of
1387
+ * them is ever the finding.
1388
+ *
1389
+ * @param {Device} device
1390
+ * @param {{pkg: string, pid?: number, sinceMs?: number}} what
1391
+ * @returns {Promise<{crashes: string[], anrs: string[], errors: string[], lines: string[], raw: string}>}
1392
+ */
1393
+ export async function complaints(device, what) {
1394
+ const filter = what.pid ? `--pid=${what.pid}` : '';
1395
+ const asked = await device.shell(`logcat -d -v brief ${filter}`, { timeoutMs: 60000 });
1396
+ /** @type {string[]} */
1397
+ const crashes = [];
1398
+ /** @type {string[]} */
1399
+ const anrs = [];
1400
+ /** @type {string[]} */
1401
+ const errors = [];
1402
+ /** @type {string[]} */
1403
+ const lines = [];
1404
+
1405
+ for (const raw of asked.out.split('\n')) {
1406
+ const line = tidy(raw).replace(/\(\s*\d+\s*\)/g, '').replace(/\b\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d+\b/g, '');
1407
+ if (line === '' || line.startsWith('---------')) continue;
1408
+ if (!what.pid && !line.includes(what.pkg) && !/AndroidRuntime|ANR in|FATAL/.test(line)) continue;
1409
+ lines.push(line);
1410
+ if (/FATAL EXCEPTION|AndroidRuntime: .*Exception|signal \d+ \(SIG/.test(line)) crashes.push(line);
1411
+ else if (/ANR in|Application Not Responding/.test(line)) anrs.push(line);
1412
+ else if (/^E\//.test(line)) errors.push(line);
1413
+ }
1414
+ return { crashes, anrs, errors, lines, raw: asked.out };
1415
+ }
1416
+
1417
+ /**
1418
+ * Every other app or screen this app asked Android to open.
1419
+ *
1420
+ * Read out of the activity manager's own log lines rather than by hooking anything, so it
1421
+ * works on any device and on a release build. It catches a share sheet, a browser, a dialler
1422
+ * — the things an app does that leave it entirely, which nothing on screen would ever show.
1423
+ *
1424
+ * @param {Device} device
1425
+ * @param {string} pkg
1426
+ * @returns {Promise<string[]>}
1427
+ */
1428
+ export async function intentsFired(device, pkg) {
1429
+ const asked = await device.shell('logcat -d -b events -v brief', { timeoutMs: 60000 });
1430
+ /** @type {Set<string>} */
1431
+ const out = new Set();
1432
+ for (const raw of asked.out.split('\n')) {
1433
+ if (!raw.includes(pkg)) continue;
1434
+ // Android 12 renamed these event tags from `am_` to `wm_` and left the old names in the
1435
+ // wild on older devices, so both are read. The component is the fourth field.
1436
+ const started = /\b(?:am|wm)_(?:create_activity|activity_launching|new_intent)\s*(?:\(\s*\d+\s*\))?\s*:\s*\[[^,]*,[^,]*,[^,]*,([^,\]]+)/.exec(raw);
1437
+ if (started) out.add(tidy(started[1]));
1438
+ const explicit = /cmp=([\w.]+\/[\w.$]*)/.exec(raw);
1439
+ if (explicit) out.add(tidy(explicit[1]));
1440
+ }
1441
+ return [...out].sort();
1442
+ }
1443
+
1444
+ // ---------------------------------------------------------------------------
1445
+ // Putting the device back
1446
+ // ---------------------------------------------------------------------------
1447
+
1448
+ /**
1449
+ * Save the whole machine, so it can be put back exactly.
1450
+ *
1451
+ * @param {Device} device
1452
+ * @param {string} name
1453
+ * @returns {Promise<{ok: boolean, ms: number, why: string}>}
1454
+ */
1455
+ export async function snapshotSave(device, name) {
1456
+ const started = Date.now();
1457
+ const asked = await device.cmd(['emu', 'avd', 'snapshot', 'save', name], { timeoutMs: 300000 });
1458
+ const ok = /\bOK\b/.test(asked.out) && !/KO/.test(asked.out);
1459
+ return {
1460
+ ok,
1461
+ ms: Date.now() - started,
1462
+ why: ok ? `the whole device was saved as "${name}"` : `the device would not save a snapshot: ${tidy(asked.out || asked.err) || 'no reason given'}`,
1463
+ };
1464
+ }
1465
+
1466
+ /**
1467
+ * Put the whole machine back.
1468
+ *
1469
+ * This is the strong form of resetting between two builds, and it is far stronger than
1470
+ * uninstalling: it puts back the settings, the accounts, the clock, the caches and anything
1471
+ * the app left anywhere on the device, not only inside its own folder. Where it works, both
1472
+ * builds genuinely start from the same machine.
1473
+ *
1474
+ * @param {Device} device
1475
+ * @param {string} name
1476
+ * @returns {Promise<{ok: boolean, ms: number, why: string}>}
1477
+ */
1478
+ export async function snapshotLoad(device, name) {
1479
+ const started = Date.now();
1480
+ const asked = await device.cmd(['emu', 'avd', 'snapshot', 'load', name], { timeoutMs: 300000 });
1481
+ const ok = /\bOK\b/.test(asked.out) && !/KO/.test(asked.out);
1482
+ if (ok) {
1483
+ await device.cmd(['wait-for-device'], { timeoutMs: 120000 });
1484
+ // adbd is restarted by the restore, so root has to be asked for again.
1485
+ device.rooted = null;
1486
+ }
1487
+ return {
1488
+ ok,
1489
+ ms: Date.now() - started,
1490
+ why: ok ? `the whole device was put back to "${name}"` : `the device would not restore the snapshot: ${tidy(asked.out || asked.err) || 'no reason given'}`,
1491
+ };
1492
+ }
1493
+
1494
+ /**
1495
+ * The weaker reset: take the app off and put everything it owned with it.
1496
+ *
1497
+ * Used when snapshots are not available. It is weaker in a way worth naming: anything the
1498
+ * app changed OUTSIDE its own folder — a permission it was granted, a setting it altered, a
1499
+ * file it put in shared storage, an account it added — survives this and carries from one
1500
+ * build's run into the other's.
1501
+ *
1502
+ * @param {Device} device
1503
+ * @param {string} pkg
1504
+ * @returns {Promise<{ok: boolean, why: string}>}
1505
+ */
1506
+ export async function removeApp(device, pkg) {
1507
+ await device.shell(`am force-stop ${pkg}`, { timeoutMs: 30000 });
1508
+ const gone = await device.cmd(['uninstall', pkg], { timeoutMs: 120000 });
1509
+ const wasThere = /Success/i.test(gone.out);
1510
+ return {
1511
+ ok: true,
1512
+ why: wasThere
1513
+ ? 'the app and everything inside its own folder were removed'
1514
+ : 'the app was not installed, so there was nothing to remove',
1515
+ };
1516
+ }
1517
+
1518
+ /**
1519
+ * Put an APK on the device.
1520
+ *
1521
+ * `-g` grants everything the app asks for up front. That is deliberate: a permission prompt
1522
+ * appearing halfway through a walkthrough covers the screen, and whether it appears at all
1523
+ * depends on what the device remembers from last time — which is the definition of a
1524
+ * difference caused by the harness rather than by the change.
1525
+ *
1526
+ * @param {Device} device
1527
+ * @param {string} apkPath
1528
+ * @returns {Promise<{ok: boolean, ms: number, why: string}>}
1529
+ */
1530
+ export async function installApk(device, apkPath) {
1531
+ const started = Date.now();
1532
+ const asked = await device.cmd(['install', '-r', '-g', '-t', apkPath], { timeoutMs: 300000 });
1533
+ const ok = /Success/i.test(asked.out);
1534
+ return {
1535
+ ok,
1536
+ ms: Date.now() - started,
1537
+ why: ok
1538
+ ? 'the app was installed with every permission it asks for already granted, so no prompt can interrupt a walkthrough'
1539
+ : `the app would not install: ${tidy(asked.out || asked.err) || 'no reason given'}`,
1540
+ };
1541
+ }
1542
+
1543
+ /**
1544
+ * The process id of a running app, or null.
1545
+ * @param {Device} device
1546
+ * @param {string} pkg
1547
+ * @returns {Promise<number|null>}
1548
+ */
1549
+ export async function pidOf(device, pkg) {
1550
+ const asked = await device.shell(`pidof ${pkg}`, { timeoutMs: 20000 });
1551
+ const pid = Number(tidy(asked.out).split(/\s+/)[0]);
1552
+ return Number.isInteger(pid) && pid > 0 ? pid : null;
1553
+ }
1554
+
1555
+ /**
1556
+ * Take a picture. Evidence only — never the accusation.
1557
+ * @param {Device} device
1558
+ * @param {string} to
1559
+ * @returns {Promise<{ok: boolean, bytes: number, path: string}>}
1560
+ */
1561
+ export async function screenshot(device, to) {
1562
+ const png = await device.bytes('screencap -p');
1563
+ if (png.length < 8 || png[0] !== 0x89) return { ok: false, bytes: png.length, path: to };
1564
+ await fsp.mkdir(path.dirname(to), { recursive: true });
1565
+ await fsp.writeFile(to, png);
1566
+ return { ok: true, bytes: png.length, path: to };
1567
+ }
1568
+
1569
+ // ---------------------------------------------------------------------------
1570
+ // Starting an emulator of our own
1571
+ // ---------------------------------------------------------------------------
1572
+
1573
+ /**
1574
+ * Ports adb will talk to an emulator on. Even numbers only, 5554 upwards, and adb ignores
1575
+ * anything outside this range — a fact that is not written down anywhere obvious and costs
1576
+ * an afternoon when an emulator starts perfectly and then cannot be seen.
1577
+ */
1578
+ const EMULATOR_PORTS = Array.from({ length: 16 }, (_, i) => 5554 + i * 2);
1579
+
1580
+ /**
1581
+ * Which virtual device an already-running emulator is.
1582
+ *
1583
+ * The serial says nothing about it — `emulator-5554` is a port, not a name — so the emulator
1584
+ * has to be asked. This is what stops the tool starting a second copy of a virtual device
1585
+ * somebody is already using, which does not work and does not fail cleanly either: the
1586
+ * second copy finds the disk locked and sits there never finishing its boot, and all the
1587
+ * caller sees is a timeout with no reason attached.
1588
+ *
1589
+ * @param {string} adb
1590
+ * @param {string} serial
1591
+ * @param {AbortSignal} [signal]
1592
+ * @returns {Promise<string|null>}
1593
+ */
1594
+ export async function avdBehind(adb, serial, signal) {
1595
+ const asked = await run(adb, ['-s', serial, 'emu', 'avd', 'name'], { timeoutMs: 20000, signal });
1596
+ const name = asked.out.split('\n').map((l) => l.trim()).find((l) => l !== '' && l !== 'OK');
1597
+ return name ?? null;
1598
+ }
1599
+
1600
+ /**
1601
+ * Start an emulator this tool owns.
1602
+ *
1603
+ * The rule this obeys is the one that matters most on somebody else's machine: never touch
1604
+ * what they are using. A device that is already running is used as it is and never shut down
1605
+ * afterwards; only an emulator started here is ever stopped, and `owned` is how the caller
1606
+ * knows which it has. A virtual device that is ALREADY running is handed straight back for
1607
+ * the same reason — starting a second copy of one is not something to work around, because
1608
+ * the two copies would share one disk and one saved state.
1609
+ *
1610
+ * @param {object} spec
1611
+ * @param {string} spec.emulator Path to the emulator binary.
1612
+ * @param {string} spec.adb
1613
+ * @param {string} spec.avd
1614
+ * @param {AbortSignal} [spec.signal]
1615
+ * @param {(m: string) => void} [spec.log]
1616
+ * @param {boolean} [spec.headless] Default true. A window on somebody's screen is a
1617
+ * surprise, and the design says announce any visible
1618
+ * automation rather than spring it.
1619
+ * @returns {Promise<{ok: boolean, serial: string, owned: boolean, why: string, stop: () => Promise<void>}>}
1620
+ */
1621
+ export async function startEmulator(spec) {
1622
+ const running = await listDevices(spec.adb, spec.signal);
1623
+ for (const device of running) {
1624
+ if (!device.emulator || device.state !== 'device') continue;
1625
+ if ((await avdBehind(spec.adb, device.serial, spec.signal)) === spec.avd) {
1626
+ return {
1627
+ ok: true,
1628
+ serial: device.serial,
1629
+ owned: false,
1630
+ why: `${spec.avd} is already running as ${device.serial}, so it was used as it is and will be left running afterwards`,
1631
+ stop: async () => {},
1632
+ };
1633
+ }
1634
+ }
1635
+
1636
+ const taken = new Set(running.map((d) => d.serial));
1637
+ const port = EMULATOR_PORTS.find((p) => !taken.has(`emulator-${p}`));
1638
+ if (port === undefined) {
1639
+ return { ok: false, serial: '', owned: false, why: 'every port an emulator can use is already in use on this machine.', stop: async () => {} };
1640
+ }
1641
+ const serial = `emulator-${port}`;
1642
+
1643
+ const args = [
1644
+ '-avd', spec.avd,
1645
+ '-port', String(port),
1646
+ '-no-audio',
1647
+ '-no-boot-anim',
1648
+ // Never write back over the snapshot the machine's owner has: this tool's runs must
1649
+ // leave their AVD exactly as they found it.
1650
+ '-no-snapshot-save',
1651
+ '-gpu', 'swiftshader_indirect',
1652
+ ];
1653
+ if (spec.headless !== false) args.push('-no-window');
1654
+
1655
+ const child = spawn(spec.emulator, args, { detached: false, stdio: ['ignore', 'pipe', 'pipe'] });
1656
+ // Keep the last of whatever it said. When an emulator will not come up, the reason is in
1657
+ // here and nowhere else, and a bare "it did not boot in six minutes" sends somebody
1658
+ // looking in every wrong place first.
1659
+ /** @type {string[]} */
1660
+ const said = [];
1661
+ /** @param {Buffer} chunk */
1662
+ const remember = (chunk) => {
1663
+ for (const line of String(chunk).split('\n')) {
1664
+ const clean = line.trim();
1665
+ if (clean !== '' && !/^INFO/.test(clean)) said.push(clean);
1666
+ }
1667
+ while (said.length > 12) said.shift();
1668
+ };
1669
+ child.stdout?.on('data', remember);
1670
+ child.stderr?.on('data', remember);
1671
+ spec.log?.(`starting the ${spec.avd} emulator as ${serial}`);
1672
+
1673
+ // An emulator that dies on the spot — no disk, a locked virtual device, a bad image —
1674
+ // must not cost six minutes of waiting for a boot that is never coming.
1675
+ /** @type {Promise<{ready: boolean, ms: number, why: string}>} */
1676
+ const died = new Promise((resolve) => {
1677
+ child.once('exit', (code) => {
1678
+ resolve({ ready: false, ms: 0, why: `the emulator stopped on its own${code === null ? '' : ` (exit code ${code})`} instead of finishing its boot` });
1679
+ });
1680
+ });
1681
+
1682
+ const device = new Device(spec.adb, serial, { signal: spec.signal, log: spec.log });
1683
+ const ready = await Promise.race([device.waitUntilReady(360000), died]);
1684
+
1685
+ const stop = async () => {
1686
+ try {
1687
+ await run(spec.adb, ['-s', serial, 'emu', 'kill'], { timeoutMs: 30000 });
1688
+ } catch {
1689
+ // Falling through to the process is fine; the console may already be gone.
1690
+ }
1691
+ if (!child.killed) child.kill('SIGTERM');
1692
+ };
1693
+
1694
+ if (!ready.ready) {
1695
+ await stop();
1696
+ return {
1697
+ ok: false,
1698
+ serial,
1699
+ owned: true,
1700
+ why: `${ready.why}${said.length > 0 ? `. The emulator said: ${said.join(' / ')}` : ''}`,
1701
+ stop: async () => {},
1702
+ };
1703
+ }
1704
+ return { ok: true, serial, owned: true, why: `${spec.avd} came up as ${serial} in ${Math.round(ready.ms / 1000)} seconds`, stop };
1705
+ }