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.
- package/README.md +534 -402
- package/package.json +8 -3
- package/src/cli/index.js +14 -0
- package/src/v2/adapters/android-driver.js +1705 -0
- package/src/v2/adapters/android.js +1117 -0
- package/src/v2/adapters/contract.js +565 -0
- package/src/v2/adapters/electron.js +1594 -0
- package/src/v2/adapters/http.js +733 -0
- package/src/v2/adapters/ios-driver.js +1551 -0
- package/src/v2/adapters/ios.js +989 -0
- package/src/v2/adapters/isolate.js +739 -0
- package/src/v2/adapters/process.js +920 -0
- package/src/v2/adapters/source.js +1241 -0
- package/src/v2/adapters/web-driver.js +1532 -0
- package/src/v2/adapters/web.js +1009 -0
- package/src/v2/adapters/windows.js +1329 -0
- package/src/v2/browsers.js +1203 -0
- package/src/v2/cause.js +364 -0
- package/src/v2/check.js +1331 -0
- package/src/v2/ci.js +1209 -0
- package/src/v2/cli.js +657 -0
- package/src/v2/cluster.js +372 -0
- package/src/v2/coverage.js +1116 -0
- package/src/v2/detect.js +1199 -0
- package/src/v2/doctor.js +1690 -0
- package/src/v2/escalate.js +679 -0
- package/src/v2/init.js +1394 -0
- package/src/v2/intent.js +659 -0
- package/src/v2/journeys/from-routes.js +498 -0
- package/src/v2/journeys/from-suite.js +988 -0
- package/src/v2/journeys/index.js +651 -0
- package/src/v2/journeys/record.js +516 -0
- package/src/v2/mcp/server.js +374 -0
- package/src/v2/mcp/tools.js +1571 -0
- package/src/v2/normalise.js +783 -0
- package/src/v2/observation.js +877 -0
- package/src/v2/rank.js +672 -0
- package/src/v2/reference.js +1051 -0
- package/src/v2/remote.js +911 -0
- package/src/v2/run.js +964 -0
- package/src/v2/sealed.js +564 -0
- package/src/v2/selfcheck.js +564 -0
- package/src/v2/ship.js +684 -0
- package/src/v2/store.js +703 -0
- package/src/v2/types.js +503 -0
- package/src/v2/waiver.js +511 -0
- 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
|
+
}
|