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,1117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Android apps, on an emulator.
|
|
3
|
+
*
|
|
4
|
+
* A phone is the hardest surface in this repository to compare honestly, and it is worth
|
|
5
|
+
* saying why before saying what this does. A desktop app can be booted twice on two ports. A
|
|
6
|
+
* website can be opened in two throwaway profiles. A phone has ONE screen, ONE package name,
|
|
7
|
+
* ONE set of granted permissions and ONE keyboard, and two builds of the same app cannot
|
|
8
|
+
* both be installed. So there is no version of this where the two builds run side by side.
|
|
9
|
+
* Everything below is arranged around that: one device, one build at a time, the whole
|
|
10
|
+
* machine put back in between.
|
|
11
|
+
*
|
|
12
|
+
* What is read, and in which channel:
|
|
13
|
+
*
|
|
14
|
+
* CONTRACT Everything the APK declares, read out of the file without installing it and
|
|
15
|
+
* without a Java runtime: the package, the version, every permission it asks
|
|
16
|
+
* for, every activity, service, broadcast receiver and content provider, and
|
|
17
|
+
* which of them any other app on the phone can reach. This is Android's door
|
|
18
|
+
* list. It is free, it is exact, and it sees doors no walkthrough opens.
|
|
19
|
+
* MEANING The accessibility tree, through UiAutomator - what each control IS and DOES,
|
|
20
|
+
* its role, the name a screen reader would read, whether it is on, off, ticked
|
|
21
|
+
* or disabled. Addressed by identity, never by position on screen.
|
|
22
|
+
* EFFECTS Every call the app made, watched at a proxy this tool runs and STOPPED there;
|
|
23
|
+
* every file it wrote; every permission it was actually granted; every screen
|
|
24
|
+
* or other app it asked Android to open.
|
|
25
|
+
* COMPLAINTS Crashes, ANRs and errors out of logcat, cut down to this app's own process.
|
|
26
|
+
* RESULTS The text the app rendered as data, kept apart from the controls, because a
|
|
27
|
+
* changed sentence and a vanished button are not the same kind of news.
|
|
28
|
+
* COUNTERS How many controls, how many calls, and a rough time bucket. Never milliseconds.
|
|
29
|
+
* PIXELS One picture per checkpoint, kept as evidence for a finding another channel
|
|
30
|
+
* already made. Never the accusation.
|
|
31
|
+
*
|
|
32
|
+
* WHAT IT CANNOT SEE, and these are not small.
|
|
33
|
+
*
|
|
34
|
+
* - Anything inside TLS. The proxy sees that the app reached for a host and a port and
|
|
35
|
+
* stops it there. The request itself is never opened, and no certificate is installed to
|
|
36
|
+
* open it. That IS the call boundary the design draws, and it is drawn here on purpose.
|
|
37
|
+
* - Which process made a call. A device-wide proxy sees sockets, not programs. Calls to
|
|
38
|
+
* Google's own infrastructure are filed separately from the app's; a third-party library
|
|
39
|
+
* inside the app cannot be told apart from the app itself.
|
|
40
|
+
* - The files an app writes, when the device refuses root AND the app is a release build.
|
|
41
|
+
* A Play Store system image always refuses root. That combination is reported as missing
|
|
42
|
+
* coverage on every run rather than passing quietly.
|
|
43
|
+
* - A real handset. Two builds cannot be run on a phone somebody is holding, and this
|
|
44
|
+
* adapter will not pretend otherwise: pointed at a real device it walks it against the
|
|
45
|
+
* stored record and says, on every run, that this is the weaker of the two comparisons.
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
import fs from 'node:fs';
|
|
49
|
+
import fsp from 'node:fs/promises';
|
|
50
|
+
import path from 'node:path';
|
|
51
|
+
|
|
52
|
+
import {
|
|
53
|
+
countBucket, defineAdapter, joinPath, notCovered, observation, sizeBucket, timeBucket,
|
|
54
|
+
} from './contract.js';
|
|
55
|
+
import {
|
|
56
|
+
Device, HOST_FROM_EMULATOR, complaints, filesWritten, findAdb, findEmulator,
|
|
57
|
+
findNodes, holdStill, installApk, intentsFired, isDeviceHost, listAvds, listDevices,
|
|
58
|
+
pause, permissionsHeld, pidOf, pressNode, readApk, readTree, removeApp, screenshot,
|
|
59
|
+
settleScreen, snapshotLoad, snapshotSave, startEmulator, typeText, watchTheWire,
|
|
60
|
+
} from './android-driver.js';
|
|
61
|
+
|
|
62
|
+
/** @typedef {import('./contract.js').Journey} Journey */
|
|
63
|
+
/** @typedef {import('./contract.js').Observation} Observation */
|
|
64
|
+
/** @typedef {import('./contract.js').Missing} Missing */
|
|
65
|
+
/** @typedef {import('./contract.js').Build} Build */
|
|
66
|
+
/** @typedef {import('./contract.js').RunContext} RunContext */
|
|
67
|
+
/** @typedef {import('./android-driver.js').ApkFacts} ApkFacts */
|
|
68
|
+
/** @typedef {import('./android-driver.js').Node} Node */
|
|
69
|
+
|
|
70
|
+
/** The snapshot this tool takes of a clean device. Its own name, so nobody else's is touched. */
|
|
71
|
+
const CLEAN_SNAPSHOT = 'staysfixed-clean';
|
|
72
|
+
|
|
73
|
+
/** The journey that needs no device at all. */
|
|
74
|
+
const DECLARED = 'what the app declares';
|
|
75
|
+
|
|
76
|
+
// ---------------------------------------------------------------------------
|
|
77
|
+
// Finding the APK
|
|
78
|
+
// ---------------------------------------------------------------------------
|
|
79
|
+
|
|
80
|
+
/** Where Gradle puts things, best first. */
|
|
81
|
+
const APK_PLACES = [
|
|
82
|
+
'app/build/outputs/apk/release',
|
|
83
|
+
'app/build/outputs/apk/debug',
|
|
84
|
+
'android/app/build/outputs/apk/release',
|
|
85
|
+
'android/app/build/outputs/apk/debug',
|
|
86
|
+
'build/app/outputs/flutter-apk',
|
|
87
|
+
'build/outputs/apk/release',
|
|
88
|
+
'dist',
|
|
89
|
+
'build',
|
|
90
|
+
'.',
|
|
91
|
+
];
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Find the APK for one build.
|
|
95
|
+
*
|
|
96
|
+
* A named path in the settings wins outright. Otherwise the usual output folders are looked
|
|
97
|
+
* in, newest first — and an unsigned APK is passed over, because it cannot be installed and
|
|
98
|
+
* reporting "the app would not install" when a signed one is sitting beside it would send
|
|
99
|
+
* somebody hunting the wrong thing.
|
|
100
|
+
*
|
|
101
|
+
* @param {string} root
|
|
102
|
+
* @param {Record<string, any>} config
|
|
103
|
+
* @returns {Promise<{path: string|null, why: string, looked: string[]}>}
|
|
104
|
+
*/
|
|
105
|
+
export async function findApk(root, config) {
|
|
106
|
+
if (config.apk) {
|
|
107
|
+
const named = path.isAbsolute(config.apk) ? config.apk : path.join(root, config.apk);
|
|
108
|
+
if (fs.existsSync(named)) return { path: named, why: `the APK named in the settings`, looked: [named] };
|
|
109
|
+
return { path: null, why: `the settings name an APK at ${config.apk}, and there is no file there`, looked: [named] };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** @type {string[]} */
|
|
113
|
+
const looked = [];
|
|
114
|
+
/** @type {{file: string, at: number}[]} */
|
|
115
|
+
const found = [];
|
|
116
|
+
for (const place of APK_PLACES) {
|
|
117
|
+
const dir = path.join(root, place);
|
|
118
|
+
looked.push(place);
|
|
119
|
+
/** @type {string[]} */
|
|
120
|
+
let entries;
|
|
121
|
+
try {
|
|
122
|
+
entries = await fsp.readdir(dir);
|
|
123
|
+
} catch {
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
for (const entry of entries) {
|
|
127
|
+
if (!entry.endsWith('.apk')) continue;
|
|
128
|
+
if (/unsigned/i.test(entry)) continue;
|
|
129
|
+
try {
|
|
130
|
+
found.push({ file: path.join(dir, entry), at: (await fsp.stat(path.join(dir, entry))).mtimeMs });
|
|
131
|
+
} catch {
|
|
132
|
+
// A file that vanished between listing and asking about it is not worth reporting.
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
if (found.length > 0) break;
|
|
136
|
+
}
|
|
137
|
+
found.sort((a, b) => b.at - a.at);
|
|
138
|
+
if (found.length > 0) return { path: found[0].file, why: `found in ${path.relative(root, path.dirname(found[0].file))}`, looked };
|
|
139
|
+
return { path: null, why: 'no APK was found anywhere this looks', looked };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ---------------------------------------------------------------------------
|
|
143
|
+
// The contract — what the APK says, before anything runs
|
|
144
|
+
// ---------------------------------------------------------------------------
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Turn what an APK declares into observations.
|
|
148
|
+
*
|
|
149
|
+
* Kept as its own function because it needs no device, no emulator and no Java: on a machine
|
|
150
|
+
* with nothing installed at all this still runs and still catches a permission that appeared,
|
|
151
|
+
* a component that quietly became reachable by other apps, or a version that did not move.
|
|
152
|
+
*
|
|
153
|
+
* @param {ApkFacts} apk
|
|
154
|
+
* @param {string} [file]
|
|
155
|
+
* @returns {Observation[]}
|
|
156
|
+
*/
|
|
157
|
+
export function declaredObservations(apk, file) {
|
|
158
|
+
/** @type {Observation[]} */
|
|
159
|
+
const out = [];
|
|
160
|
+
const where = file ? { file } : undefined;
|
|
161
|
+
|
|
162
|
+
out.push(observation({ channel: 'contract', path: ['manifest', 'package'], value: apk.pkg, says: `the app calls itself ${apk.pkg}`, where }));
|
|
163
|
+
out.push(observation({ channel: 'contract', path: ['manifest', 'version'], value: apk.versionName ?? '(none)', says: `the version people see is ${apk.versionName ?? 'not set'}`, where }));
|
|
164
|
+
out.push(observation({ channel: 'contract', path: ['manifest', 'build number'], value: apk.versionCode ?? 0, says: `the build number the store sees is ${apk.versionCode ?? 'not set'}`, where }));
|
|
165
|
+
out.push(observation({ channel: 'contract', path: ['manifest', 'oldest android'], value: apk.minSdk ?? 0, says: `it will install on Android API ${apk.minSdk ?? 'unknown'} and newer`, where }));
|
|
166
|
+
out.push(observation({ channel: 'contract', path: ['manifest', 'built for android'], value: apk.targetSdk ?? 0, says: `it is built against API ${apk.targetSdk ?? 'unknown'}, which decides which of Android's rules apply to it`, where }));
|
|
167
|
+
out.push(observation({ channel: 'contract', path: ['manifest', 'debuggable'], value: apk.debuggable, says: apk.debuggable ? 'this build can be inspected and debugged — never true of something shipped to people' : 'this build cannot be debugged, which is what a shipped build should say', where }));
|
|
168
|
+
out.push(observation({ channel: 'contract', path: ['manifest', 'plain http allowed'], value: apk.cleartext, says: apk.cleartext ? 'the app is allowed to talk over unencrypted HTTP' : 'the app is only allowed to talk over encrypted connections', where }));
|
|
169
|
+
|
|
170
|
+
for (const permission of apk.permissions) {
|
|
171
|
+
out.push(observation({
|
|
172
|
+
channel: 'contract',
|
|
173
|
+
path: ['manifest', 'permission', permission],
|
|
174
|
+
value: 'asked for',
|
|
175
|
+
says: `the app asks for ${permission.replace(/^android\.permission\./, '')}`,
|
|
176
|
+
where,
|
|
177
|
+
}));
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
for (const component of apk.components) {
|
|
181
|
+
out.push(observation({
|
|
182
|
+
channel: 'contract',
|
|
183
|
+
path: ['manifest', component.kind, component.name, 'open to other apps'],
|
|
184
|
+
value: component.exported,
|
|
185
|
+
says: component.exported
|
|
186
|
+
? `any other app on the phone can reach this ${component.kind}${component.permission ? `, if it holds ${component.permission}` : ', with no permission needed'}`
|
|
187
|
+
: `this ${component.kind} can only be reached by the app itself`,
|
|
188
|
+
where,
|
|
189
|
+
}));
|
|
190
|
+
if (component.actions.length > 0) {
|
|
191
|
+
out.push(observation({
|
|
192
|
+
channel: 'contract',
|
|
193
|
+
path: ['manifest', component.kind, component.name, 'answers'],
|
|
194
|
+
value: component.actions.slice().sort(),
|
|
195
|
+
says: `this ${component.kind} answers ${component.actions.length} kind${component.actions.length === 1 ? '' : 's'} of request from elsewhere on the phone`,
|
|
196
|
+
where,
|
|
197
|
+
}));
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
out.push(observation({
|
|
202
|
+
channel: 'counters',
|
|
203
|
+
path: ['count', 'doors'],
|
|
204
|
+
value: countBucket(apk.components.length),
|
|
205
|
+
says: `the app declares ${apk.components.length} component${apk.components.length === 1 ? '' : 's'} in total`,
|
|
206
|
+
where,
|
|
207
|
+
}));
|
|
208
|
+
return out;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// ---------------------------------------------------------------------------
|
|
212
|
+
// Journeys
|
|
213
|
+
// ---------------------------------------------------------------------------
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* What there is to walk.
|
|
217
|
+
*
|
|
218
|
+
* Three sources, in the order the design ranks them. The settings first, because somebody
|
|
219
|
+
* wrote those on purpose. Then one journey per screen the APK says other apps can open,
|
|
220
|
+
* read straight out of the manifest — free, exact, and it finds the screens nobody wrote a
|
|
221
|
+
* test for. And always the one that needs no device at all.
|
|
222
|
+
*
|
|
223
|
+
* @param {object} input
|
|
224
|
+
* @param {Record<string, any>} input.config
|
|
225
|
+
* @param {ApkFacts|null} input.apk
|
|
226
|
+
* @param {string} [input.from]
|
|
227
|
+
* @returns {Journey[]}
|
|
228
|
+
*/
|
|
229
|
+
export function journeysFrom(input) {
|
|
230
|
+
const config = input.config ?? {};
|
|
231
|
+
/** @type {Journey[]} */
|
|
232
|
+
const journeys = [];
|
|
233
|
+
|
|
234
|
+
journeys.push({
|
|
235
|
+
name: DECLARED,
|
|
236
|
+
describe: 'read everything the app file itself says: its version, the permissions it asks for, and every screen and background piece other apps can reach',
|
|
237
|
+
source: 'code',
|
|
238
|
+
surface: 'android',
|
|
239
|
+
from: input.from ?? 'the APK',
|
|
240
|
+
channels: ['contract', 'counters'],
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
for (const named of [...(config.journeys ?? []), ...(config.screens ?? [])]) {
|
|
244
|
+
if (!named || typeof named !== 'object') continue;
|
|
245
|
+
const name = String(named.name ?? 'a journey');
|
|
246
|
+
/** @type {any[]} */
|
|
247
|
+
const steps = [];
|
|
248
|
+
if (named.activity) steps.push({ act: 'open', activity: String(named.activity), note: `open ${named.activity}` });
|
|
249
|
+
else if (!Array.isArray(named.steps) || !named.steps.some((/** @type {any} */ s) => s?.act === 'open')) {
|
|
250
|
+
steps.push({ act: 'open', note: 'open the app' });
|
|
251
|
+
}
|
|
252
|
+
for (const step of named.steps ?? []) steps.push({ act: String(step.act ?? 'read'), ...step });
|
|
253
|
+
journeys.push({
|
|
254
|
+
name,
|
|
255
|
+
describe: String(named.describe ?? named.why ?? `walk ${name}`),
|
|
256
|
+
source: 'code',
|
|
257
|
+
surface: 'android',
|
|
258
|
+
from: 'the project settings',
|
|
259
|
+
channels: ['meaning', 'effects', 'complaints', 'results', 'counters', 'pixels'],
|
|
260
|
+
steps,
|
|
261
|
+
irreversible: named.irreversible === true,
|
|
262
|
+
timeoutMs: named.timeoutMs,
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const named = new Set(journeys.map((j) => j.name));
|
|
267
|
+
for (const component of input.apk?.components ?? []) {
|
|
268
|
+
if (component.kind !== 'activity' || !component.exported) continue;
|
|
269
|
+
const short = component.name.split('.').pop() ?? component.name;
|
|
270
|
+
const name = `open ${short}`;
|
|
271
|
+
if (named.has(name)) continue;
|
|
272
|
+
named.add(name);
|
|
273
|
+
journeys.push({
|
|
274
|
+
name,
|
|
275
|
+
describe: `open the ${short} screen straight from outside the app, the way another app on the phone could, and read what it says`,
|
|
276
|
+
source: 'code',
|
|
277
|
+
surface: 'android',
|
|
278
|
+
from: 'the APK manifest',
|
|
279
|
+
channels: ['meaning', 'effects', 'complaints', 'results', 'counters', 'pixels'],
|
|
280
|
+
steps: [{ act: 'open', activity: component.name, note: `open ${short}` }, { act: 'read', note: 'read what the screen says' }],
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
return journeys;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// ---------------------------------------------------------------------------
|
|
288
|
+
// Turning one walked screen into observations
|
|
289
|
+
// ---------------------------------------------------------------------------
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Whether a control is something a person operates, as opposed to something they read.
|
|
293
|
+
*
|
|
294
|
+
* The line matters because the two go into different channels. A button, a tick box or a
|
|
295
|
+
* text field is MEANING: what the screen says you can do. A label or a paragraph is
|
|
296
|
+
* RESULTS: what the app is telling you. Mixing them makes a reworded sentence sort next to
|
|
297
|
+
* a button that stopped working, and the whole point of ranking is that it does not.
|
|
298
|
+
*
|
|
299
|
+
* @param {Node} node
|
|
300
|
+
* @returns {boolean}
|
|
301
|
+
*/
|
|
302
|
+
export function isControl(node) {
|
|
303
|
+
return node.clickable || node.checkable || node.scrollable || node.kind === 'EditText' || node.kind === 'Switch' || node.kind === 'SeekBar';
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* One screen, as observations.
|
|
308
|
+
*
|
|
309
|
+
* Each control produces its own address with its properties hanging off it, so a button that
|
|
310
|
+
* went disabled is one difference at one address and not a whole screen redrawn. Everything
|
|
311
|
+
* that is only ever read is filed under results with its words as the value.
|
|
312
|
+
*
|
|
313
|
+
* @param {object} input
|
|
314
|
+
* @param {Node[]} input.nodes
|
|
315
|
+
* @param {string} input.journey
|
|
316
|
+
* @param {string} [input.step]
|
|
317
|
+
* @param {boolean} input.settled
|
|
318
|
+
* @returns {Observation[]}
|
|
319
|
+
*/
|
|
320
|
+
export function screenObservations(input) {
|
|
321
|
+
/** @type {Observation[]} */
|
|
322
|
+
const out = [];
|
|
323
|
+
const at = input.step ? [input.journey, input.step] : [input.journey];
|
|
324
|
+
|
|
325
|
+
let controls = 0;
|
|
326
|
+
let readable = 0;
|
|
327
|
+
for (const node of input.nodes) {
|
|
328
|
+
const address = joinPath('screen', ...at, node.address);
|
|
329
|
+
if (isControl(node)) {
|
|
330
|
+
controls += 1;
|
|
331
|
+
out.push(observation({
|
|
332
|
+
channel: 'meaning',
|
|
333
|
+
path: `${address}.is`,
|
|
334
|
+
value: `${node.kind}${node.name ? ` called "${node.name}"` : ''}`,
|
|
335
|
+
says: `there is a ${node.kind.toLowerCase()}${node.name ? ` called "${node.name}"` : ' with no name a screen reader could read'} on this screen`,
|
|
336
|
+
journey: input.journey,
|
|
337
|
+
surface: 'android',
|
|
338
|
+
}));
|
|
339
|
+
out.push(observation({
|
|
340
|
+
channel: 'meaning',
|
|
341
|
+
path: `${address}.you can use it`,
|
|
342
|
+
value: node.enabled,
|
|
343
|
+
says: node.enabled ? `"${node.name || node.kind}" can be used` : `"${node.name || node.kind}" is there but greyed out`,
|
|
344
|
+
journey: input.journey,
|
|
345
|
+
surface: 'android',
|
|
346
|
+
}));
|
|
347
|
+
if (node.checkable) {
|
|
348
|
+
out.push(observation({
|
|
349
|
+
channel: 'meaning',
|
|
350
|
+
path: `${address}.ticked`,
|
|
351
|
+
value: node.checked,
|
|
352
|
+
says: `"${node.name || node.kind}" is ${node.checked ? 'ticked' : 'not ticked'}`,
|
|
353
|
+
journey: input.journey,
|
|
354
|
+
surface: 'android',
|
|
355
|
+
}));
|
|
356
|
+
}
|
|
357
|
+
if (node.kind === 'EditText') {
|
|
358
|
+
out.push(observation({
|
|
359
|
+
channel: 'meaning',
|
|
360
|
+
path: `${address}.hidden text`,
|
|
361
|
+
value: node.password,
|
|
362
|
+
says: node.password ? 'what is typed here is hidden, the way a password should be' : 'what is typed here is shown on screen',
|
|
363
|
+
journey: input.journey,
|
|
364
|
+
surface: 'android',
|
|
365
|
+
}));
|
|
366
|
+
}
|
|
367
|
+
} else if (node.text !== '') {
|
|
368
|
+
readable += 1;
|
|
369
|
+
out.push(observation({
|
|
370
|
+
channel: 'results',
|
|
371
|
+
path: `${address}.says`,
|
|
372
|
+
value: node.text,
|
|
373
|
+
says: `the screen shows "${node.text.length > 80 ? `${node.text.slice(0, 77)}...` : node.text}"`,
|
|
374
|
+
journey: input.journey,
|
|
375
|
+
surface: 'android',
|
|
376
|
+
}));
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
out.push(observation({
|
|
381
|
+
channel: 'counters',
|
|
382
|
+
path: joinPath('count', ...at, 'controls'),
|
|
383
|
+
value: countBucket(controls),
|
|
384
|
+
says: `${controls} thing${controls === 1 ? '' : 's'} on this screen can be operated`,
|
|
385
|
+
journey: input.journey,
|
|
386
|
+
surface: 'android',
|
|
387
|
+
}));
|
|
388
|
+
out.push(observation({
|
|
389
|
+
channel: 'counters',
|
|
390
|
+
path: joinPath('count', ...at, 'things to read'),
|
|
391
|
+
value: countBucket(readable),
|
|
392
|
+
says: `${readable} piece${readable === 1 ? '' : 's'} of text on this screen are there to be read`,
|
|
393
|
+
journey: input.journey,
|
|
394
|
+
surface: 'android',
|
|
395
|
+
}));
|
|
396
|
+
if (!input.settled) {
|
|
397
|
+
out.push(notCovered({
|
|
398
|
+
channel: 'meaning',
|
|
399
|
+
path: joinPath('screen', ...at, 'held still'),
|
|
400
|
+
reason: 'timed out',
|
|
401
|
+
says: 'this screen was still moving when it was read, so what was read may be halfway through something and anything odd here should be blamed on that first',
|
|
402
|
+
}));
|
|
403
|
+
}
|
|
404
|
+
return out;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// ---------------------------------------------------------------------------
|
|
408
|
+
// What one build is holding open while it is walked
|
|
409
|
+
// ---------------------------------------------------------------------------
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* @typedef {object} Session
|
|
413
|
+
* @property {Device} device
|
|
414
|
+
* @property {ApkFacts} apk
|
|
415
|
+
* @property {string} apkPath
|
|
416
|
+
* @property {boolean} ownsDevice True only when this tool started the emulator.
|
|
417
|
+
* @property {() => Promise<void>} stopDevice
|
|
418
|
+
* @property {'snapshot'|'uninstall'} reset
|
|
419
|
+
* @property {Record<string, any>} config
|
|
420
|
+
* @property {string[]} caveats Everything about this device that weakens the check.
|
|
421
|
+
*/
|
|
422
|
+
|
|
423
|
+
/** Keyed by build id, emptied on teardown. Never shared between two builds. */
|
|
424
|
+
const open = new Map();
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* Read the size out of a PNG without decoding it.
|
|
428
|
+
*
|
|
429
|
+
* The pixel channel is evidence, never the accusation, so nothing here needs to look at the
|
|
430
|
+
* picture. What is worth comparing is the shape of it and roughly how much is in it: a
|
|
431
|
+
* screen that went blank weighs a fraction of one that did not.
|
|
432
|
+
*
|
|
433
|
+
* @param {Buffer} png
|
|
434
|
+
* @returns {{width: number, height: number} | null}
|
|
435
|
+
*/
|
|
436
|
+
export function pngSize(png) {
|
|
437
|
+
if (png.length < 24 || png.readUInt32BE(12) !== 0x49484452) return null;
|
|
438
|
+
return { width: png.readUInt32BE(16), height: png.readUInt32BE(20) };
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* Choose how the device gets put back between builds, and say what that costs.
|
|
443
|
+
*
|
|
444
|
+
* @param {Session} session
|
|
445
|
+
* @returns {Promise<{ok: boolean, why: string}>}
|
|
446
|
+
*/
|
|
447
|
+
async function resetDevice(session) {
|
|
448
|
+
if (session.reset === 'snapshot') {
|
|
449
|
+
const back = await snapshotLoad(session.device, CLEAN_SNAPSHOT);
|
|
450
|
+
if (back.ok) return { ok: true, why: `the whole device was put back to how it was before anything was installed, in ${(back.ms / 1000).toFixed(1)} seconds` };
|
|
451
|
+
// Falling back rather than failing: a weaker reset that says so beats no run at all.
|
|
452
|
+
session.reset = 'uninstall';
|
|
453
|
+
session.caveats.push(`the device would not restore its snapshot (${back.why}), so between builds only the app itself is removed — anything it changed outside its own folder carries over`);
|
|
454
|
+
}
|
|
455
|
+
const gone = await removeApp(session.device, session.apk.pkg);
|
|
456
|
+
return { ok: gone.ok, why: gone.why };
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// ---------------------------------------------------------------------------
|
|
460
|
+
// The adapter
|
|
461
|
+
// ---------------------------------------------------------------------------
|
|
462
|
+
|
|
463
|
+
export const androidAdapter = defineAdapter({
|
|
464
|
+
name: 'android',
|
|
465
|
+
title: 'Android apps, on an emulator',
|
|
466
|
+
describe:
|
|
467
|
+
'Reads everything an APK declares — its version, every permission it asks for, and every screen, service, receiver and provider other apps on the phone can reach — straight out of the file, with nothing installed and no Java needed. Then, where there is an emulator, it puts the device back to a known state, installs one build at a time with the clock stopped and every animation switched off, walks each journey, and writes down what the screen MEANS through the accessibility layer, every call the app tried to make, every file it wrote, every permission it was really granted, and everything it crashed or complained about. Controls are found by what they are, never by where they are on screen. Nothing is allowed off the machine: every outbound call is written down at a proxy and stopped there, so what is inside an encrypted request is never seen and is reported as unchecked rather than passed. It cannot tell which program made a call, it cannot see the files a release build writes on a device that refuses root, and on a real handset it cannot run two builds at all.',
|
|
468
|
+
channels: ['meaning', 'effects', 'complaints', 'results', 'contract', 'counters', 'pixels'],
|
|
469
|
+
|
|
470
|
+
/** @param {import('./contract.js').AdapterProject} project */
|
|
471
|
+
async detect(project) {
|
|
472
|
+
const config = project.config ?? {};
|
|
473
|
+
/** @type {Missing[]} */
|
|
474
|
+
const missing = [];
|
|
475
|
+
/** @type {string[]} */
|
|
476
|
+
const notes = [];
|
|
477
|
+
|
|
478
|
+
const apkFound = await findApk(project.root, config);
|
|
479
|
+
const apk = apkFound.path ? await readApk(apkFound.path) : null;
|
|
480
|
+
const looksAndroid = Boolean(apk?.ok)
|
|
481
|
+
|| fs.existsSync(path.join(project.root, 'android', 'app', 'build.gradle'))
|
|
482
|
+
|| fs.existsSync(path.join(project.root, 'app', 'build.gradle'))
|
|
483
|
+
|| fs.existsSync(path.join(project.root, 'android', 'app', 'build.gradle.kts'))
|
|
484
|
+
|| fs.existsSync(path.join(project.root, 'AndroidManifest.xml'));
|
|
485
|
+
|
|
486
|
+
if (!apkFound.path) {
|
|
487
|
+
missing.push({
|
|
488
|
+
what: 'a built APK',
|
|
489
|
+
unlocks: 'everything — without the app file there is nothing to read and nothing to install',
|
|
490
|
+
howToGet: looksAndroid
|
|
491
|
+
? 'Build the app first (in most projects that is ./gradlew assembleRelease), or put {"apk": "path/to/your.apk"} under "android" in the settings.'
|
|
492
|
+
: 'Put {"apk": "path/to/your.apk"} under "android" in the settings.',
|
|
493
|
+
blocking: true,
|
|
494
|
+
});
|
|
495
|
+
} else if (apk && !apk.ok) {
|
|
496
|
+
missing.push({ what: 'a readable APK', unlocks: 'reading what the app declares', howToGet: apk.why, blocking: true });
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
const adb = findAdb();
|
|
500
|
+
const emulator = findEmulator();
|
|
501
|
+
const avds = listAvds();
|
|
502
|
+
const devices = adb ? await listDevices(adb) : [];
|
|
503
|
+
const usable = devices.filter((d) => d.state === 'device');
|
|
504
|
+
|
|
505
|
+
if (!adb) {
|
|
506
|
+
missing.push({
|
|
507
|
+
what: 'adb, the program that talks to an Android device',
|
|
508
|
+
unlocks: 'installing the app and walking it. Everything the APK declares can still be checked without it',
|
|
509
|
+
// A person has to accept a licence, so this is the third of the four states: only a
|
|
510
|
+
// person can do it, and what they get for it is said in words they can act on.
|
|
511
|
+
howToGet: 'Install the Android command line tools. On a Mac: brew install --cask android-commandlinetools, then run sdkmanager "platform-tools" "emulator". Somebody has to accept Google\'s licence once, and after that nothing here needs a person again.',
|
|
512
|
+
});
|
|
513
|
+
}
|
|
514
|
+
if (adb && !emulator) {
|
|
515
|
+
missing.push({
|
|
516
|
+
what: 'the Android emulator',
|
|
517
|
+
unlocks: 'running the app without a phone plugged in, which is the only way two builds can be compared',
|
|
518
|
+
howToGet: `${path.dirname(path.dirname(adb))}/cmdline-tools/latest/bin/sdkmanager --install emulator`,
|
|
519
|
+
});
|
|
520
|
+
}
|
|
521
|
+
if (emulator && avds.length === 0) {
|
|
522
|
+
missing.push({
|
|
523
|
+
what: 'a virtual device for the emulator to run',
|
|
524
|
+
unlocks: 'having somewhere to install the app',
|
|
525
|
+
howToGet: 'sdkmanager --install "system-images;android-33;google_apis;arm64-v8a" then avdmanager create avd -n staysfixed -k "system-images;android-33;google_apis;arm64-v8a". Pick a plain Google APIs image, NOT a Play Store one: a Play Store device refuses root forever, and without root the files an app writes cannot be seen.',
|
|
526
|
+
});
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
const chosen = config.avd ? avds.find((a) => a.name === config.avd) : avds.find((a) => !a.playStore) ?? avds[0];
|
|
530
|
+
if (chosen?.playStore) {
|
|
531
|
+
missing.push({
|
|
532
|
+
what: 'a virtual device built from a plain Google APIs image rather than a Play Store one',
|
|
533
|
+
unlocks: 'seeing the files the app writes, and stopping the clock. A Play Store device refuses root permanently, and both of those need it',
|
|
534
|
+
howToGet: 'avdmanager create avd -n staysfixed -k "system-images;android-33;google_apis;arm64-v8a"',
|
|
535
|
+
});
|
|
536
|
+
}
|
|
537
|
+
if (usable.some((d) => !d.emulator)) {
|
|
538
|
+
notes.push('There is a real phone plugged in. It can be walked, but two builds cannot be compared on it — a phone somebody is holding cannot be put back to a known state — so it is only ever compared against the record from last time, which is the weaker of the two.');
|
|
539
|
+
}
|
|
540
|
+
if (!config.journeys && !config.screens) {
|
|
541
|
+
missing.push({
|
|
542
|
+
what: 'a list of the things somebody actually does in this app',
|
|
543
|
+
unlocks: 'checking what the app DOES rather than only what each screen looks like when it opens. Every screen other apps can reach is opened without this; nothing is typed, pressed or saved',
|
|
544
|
+
howToGet: 'Put {"journeys": [{"name": "save a note", "steps": [{"act": "type", "into": "note text", "text": "hello"}, {"act": "press", "name": "save"}, {"act": "read"}]}]} under "android" in the settings.',
|
|
545
|
+
});
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
const canWalk = Boolean(adb) && (usable.length > 0 || (Boolean(emulator) && avds.length > 0));
|
|
549
|
+
const doors = apk?.components.length ?? 0;
|
|
550
|
+
return {
|
|
551
|
+
applies: looksAndroid,
|
|
552
|
+
confidence: apk?.ok && canWalk ? 1 : apk?.ok ? 0.7 : looksAndroid ? 0.4 : 0,
|
|
553
|
+
why: !looksAndroid
|
|
554
|
+
? 'Nothing here looks like an Android app: no APK, and no Gradle build for one.'
|
|
555
|
+
: `${apk?.ok ? `${apk.why} ` : `${apkFound.why}. `}${
|
|
556
|
+
canWalk
|
|
557
|
+
? `${usable.length > 0 ? `${usable.length} device${usable.length === 1 ? ' is' : 's are'} connected right now` : `no device is running, so one will be started from the ${chosen?.name} virtual device`}, so all ${doors} of those doors can also be opened and walked.`
|
|
558
|
+
: 'There is no way to run it on this machine, so what the app declares can be checked but nothing can be opened.'
|
|
559
|
+
}`,
|
|
560
|
+
missing,
|
|
561
|
+
notes: [
|
|
562
|
+
...notes,
|
|
563
|
+
'What is compared is what the screen MEANS — the roles, names and states a screen reader would read — and every control is found by what it is, never by where it sits. Moving something, restyling it, or wrapping it in another layout reports nothing.',
|
|
564
|
+
'The two builds are never installed at once. Android allows one app of a given name on a device, so each build is installed, walked and removed, and the whole device is put back in between.',
|
|
565
|
+
'Nothing is allowed off this machine. Every call the app makes is written down at a proxy and stopped there — which also means what is inside an encrypted request is never seen, and is reported as unchecked rather than as fine.',
|
|
566
|
+
],
|
|
567
|
+
};
|
|
568
|
+
},
|
|
569
|
+
|
|
570
|
+
/** @param {import('./contract.js').AdapterProject} project */
|
|
571
|
+
async journeys(project) {
|
|
572
|
+
const config = project.config ?? {};
|
|
573
|
+
const found = await findApk(project.root, config);
|
|
574
|
+
const apk = found.path ? await readApk(found.path) : null;
|
|
575
|
+
return journeysFrom({ config, apk: apk?.ok ? apk : null, from: found.path ?? undefined });
|
|
576
|
+
},
|
|
577
|
+
|
|
578
|
+
/**
|
|
579
|
+
* Get one build onto a device.
|
|
580
|
+
*
|
|
581
|
+
* The order matters and each step is here for a reason somebody paid for once. The APK is
|
|
582
|
+
* read before anything is started, so a build that cannot be read costs no emulator boot.
|
|
583
|
+
* The device is held still before the app is installed, so the very first launch already
|
|
584
|
+
* has animations off. A clean snapshot is taken BEFORE the app goes on, so putting the
|
|
585
|
+
* device back really does mean back. And the app is launched once and thrown away, because
|
|
586
|
+
* the first launch after an install is genuinely different from every later one — it
|
|
587
|
+
* builds caches, it compiles, and comparing a first launch against a later one reports a
|
|
588
|
+
* difference that has nothing to do with anybody's change.
|
|
589
|
+
*
|
|
590
|
+
* @param {Build} build
|
|
591
|
+
* @param {RunContext} ctx
|
|
592
|
+
*/
|
|
593
|
+
async prepare(build, ctx) {
|
|
594
|
+
const config = ctx.config ?? {};
|
|
595
|
+
const base = path.join(ctx.scratchDir, `android-${build.id.slice(0, 12).replace(/[^A-Za-z0-9_-]/g, '-')}`);
|
|
596
|
+
await fsp.mkdir(base, { recursive: true });
|
|
597
|
+
|
|
598
|
+
/** @param {string} why */
|
|
599
|
+
const notReady = (why) => ({
|
|
600
|
+
build, root: base, ready: false, why,
|
|
601
|
+
dispose: async () => { await fsp.rm(base, { recursive: true, force: true }); },
|
|
602
|
+
});
|
|
603
|
+
|
|
604
|
+
const found = await findApk(build.artifact ? path.dirname(build.artifact) : build.root, { ...config, apk: build.artifact ?? config.apk });
|
|
605
|
+
if (!found.path) return notReady(`There is no APK for ${build.label}: ${found.why}.`);
|
|
606
|
+
const apk = await readApk(found.path);
|
|
607
|
+
if (!apk.ok) return notReady(`The APK for ${build.label} could not be read: ${apk.why}`);
|
|
608
|
+
|
|
609
|
+
const adb = findAdb();
|
|
610
|
+
if (!adb) {
|
|
611
|
+
// Not a failure. Everything the APK declares was still read, and saying so is the
|
|
612
|
+
// difference between "we could not check" and "there is nothing to check".
|
|
613
|
+
return {
|
|
614
|
+
build, root: base, ready: true,
|
|
615
|
+
why: `adb is not on this machine, so ${build.label} cannot be installed or opened. What it declares — its version, its permissions and all ${apk.components.length} of its components — is still read straight out of the file, and every journey that needs a device is reported as unchecked rather than passed.`,
|
|
616
|
+
facts: { apk: found.path, pkg: apk.pkg, deviceless: true },
|
|
617
|
+
dispose: async () => { await fsp.rm(base, { recursive: true, force: true }); },
|
|
618
|
+
};
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
/** @type {string[]} */
|
|
622
|
+
const caveats = [];
|
|
623
|
+
let serial = config.serial ?? null;
|
|
624
|
+
let ownsDevice = false;
|
|
625
|
+
let stopDevice = async () => {};
|
|
626
|
+
|
|
627
|
+
if (!serial) {
|
|
628
|
+
const already = (await listDevices(adb, ctx.signal)).filter((d) => d.state === 'device');
|
|
629
|
+
const emulatorFirst = already.find((d) => d.emulator) ?? already[0];
|
|
630
|
+
if (emulatorFirst) {
|
|
631
|
+
serial = emulatorFirst.serial;
|
|
632
|
+
// Somebody else's device. Used as found, never shut down, and never wiped.
|
|
633
|
+
caveats.push(`this ran on ${serial}, which was already running and was left running afterwards`);
|
|
634
|
+
if (!emulatorFirst.emulator) {
|
|
635
|
+
caveats.push('this is a real phone, not an emulator: it cannot be put back to a known state, so this build can only be compared against the record from last time, never against another build run minutes earlier. That is the weaker of the two comparisons and it applies to every finding below.');
|
|
636
|
+
}
|
|
637
|
+
} else {
|
|
638
|
+
const emulator = findEmulator();
|
|
639
|
+
const avds = listAvds();
|
|
640
|
+
const wanted = config.avd ? avds.find((a) => a.name === config.avd) : avds.find((a) => !a.playStore) ?? avds[0];
|
|
641
|
+
if (!emulator || !wanted) {
|
|
642
|
+
return notReady(`There is no device connected and no emulator to start${config.avd ? ` called ${config.avd}` : ''}, so ${build.label} cannot be opened.`);
|
|
643
|
+
}
|
|
644
|
+
ctx.log?.(`starting the ${wanted.name} emulator`);
|
|
645
|
+
const started = await startEmulator({ emulator, adb, avd: wanted.name, signal: ctx.signal, log: ctx.log, headless: config.headless !== false });
|
|
646
|
+
if (!started.ok) return notReady(`The emulator would not start: ${started.why}`);
|
|
647
|
+
serial = started.serial;
|
|
648
|
+
ownsDevice = true;
|
|
649
|
+
stopDevice = started.stop;
|
|
650
|
+
if (wanted.playStore) caveats.push(`${wanted.name} is a Play Store device, which refuses root: the clock cannot be stopped and the files this app writes cannot be seen`);
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
const device = new Device(adb, /** @type {string} */ (serial), { signal: ctx.signal, log: ctx.log });
|
|
655
|
+
const ready = await device.waitUntilReady(240000);
|
|
656
|
+
if (!ready.ready) {
|
|
657
|
+
await stopDevice();
|
|
658
|
+
return notReady(ready.why);
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
const still = await holdStill(device, { clock: ctx.clock, timezone: config.timezone ?? 'UTC', locale: config.locale });
|
|
662
|
+
caveats.push(...still.couldNot);
|
|
663
|
+
|
|
664
|
+
/** @type {'snapshot'|'uninstall'} */
|
|
665
|
+
let reset = 'uninstall';
|
|
666
|
+
await removeApp(device, apk.pkg);
|
|
667
|
+
if (config.reset !== 'uninstall' && (await listDevices(adb, ctx.signal)).some((d) => d.serial === serial && d.emulator)) {
|
|
668
|
+
const saved = await snapshotSave(device, CLEAN_SNAPSHOT);
|
|
669
|
+
if (saved.ok) reset = 'snapshot';
|
|
670
|
+
else caveats.push(`the device would not save a snapshot (${saved.why}), so between builds only the app is removed and anything it changed elsewhere on the device carries over`);
|
|
671
|
+
} else if (config.reset !== 'uninstall') {
|
|
672
|
+
caveats.push('this is not an emulator, so the device cannot be snapshotted: between builds only the app is removed, and anything it changed elsewhere carries over');
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
const installed = await installApk(device, found.path);
|
|
676
|
+
if (!installed.ok) {
|
|
677
|
+
if (ownsDevice) await stopDevice();
|
|
678
|
+
return notReady(installed.why);
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
// Burn the first launch. It is measurably different from every later one and comparing
|
|
682
|
+
// one against the other is a difference nobody caused.
|
|
683
|
+
const warmStart = Date.now();
|
|
684
|
+
if (apk.launchActivity) {
|
|
685
|
+
await device.shell(`am start -W -n ${apk.pkg}/${apk.launchActivity}`, { timeoutMs: 120000 });
|
|
686
|
+
await settleScreen(device, { tries: 8 });
|
|
687
|
+
await device.shell(`am force-stop ${apk.pkg}`, { timeoutMs: 30000 });
|
|
688
|
+
}
|
|
689
|
+
const warmMs = Date.now() - warmStart;
|
|
690
|
+
|
|
691
|
+
/** @type {Session} */
|
|
692
|
+
const session = { device, apk, apkPath: found.path, ownsDevice, stopDevice, reset, config, caveats };
|
|
693
|
+
open.set(build.id, session);
|
|
694
|
+
|
|
695
|
+
return {
|
|
696
|
+
build,
|
|
697
|
+
root: base,
|
|
698
|
+
ready: true,
|
|
699
|
+
why: `${build.label} is installed on ${serial} (${apk.pkg} ${apk.versionName ?? ''} build ${apk.versionCode ?? '?'}). The clock, the time zone, the text size and every animation are pinned, the first launch has been used up and thrown away, and between builds the device is put back ${reset === 'snapshot' ? 'completely, from a snapshot taken before anything was installed' : 'by removing the app — which does not undo anything it changed elsewhere on the device'}.${caveats.length > 0 ? ` Worth knowing: ${caveats.join('; ')}.` : ''}`,
|
|
700
|
+
facts: {
|
|
701
|
+
serial: /** @type {string} */ (serial),
|
|
702
|
+
pkg: apk.pkg,
|
|
703
|
+
apk: found.path,
|
|
704
|
+
reset,
|
|
705
|
+
rooted: device.rooted === true,
|
|
706
|
+
firstLaunchMs: warmMs,
|
|
707
|
+
ownsDevice,
|
|
708
|
+
},
|
|
709
|
+
dispose: async () => {
|
|
710
|
+
open.delete(build.id);
|
|
711
|
+
try {
|
|
712
|
+
await resetDevice(session);
|
|
713
|
+
} catch {
|
|
714
|
+
// Putting the device back is best effort; failing here must not hide a finding.
|
|
715
|
+
}
|
|
716
|
+
// Only ever stop an emulator this tool started.
|
|
717
|
+
if (ownsDevice) await stopDevice();
|
|
718
|
+
await fsp.rm(base, { recursive: true, force: true });
|
|
719
|
+
},
|
|
720
|
+
};
|
|
721
|
+
},
|
|
722
|
+
|
|
723
|
+
/**
|
|
724
|
+
* Walk one journey against one prepared build.
|
|
725
|
+
*
|
|
726
|
+
* @param {Journey} journey
|
|
727
|
+
* @param {import('./contract.js').PreparedBuild} prepared
|
|
728
|
+
* @param {RunContext} ctx
|
|
729
|
+
* @returns {Promise<Observation[]>}
|
|
730
|
+
*/
|
|
731
|
+
async run(journey, prepared, ctx) {
|
|
732
|
+
/** @type {Observation[]} */
|
|
733
|
+
const out = [];
|
|
734
|
+
const session = open.get(prepared.build.id);
|
|
735
|
+
|
|
736
|
+
// The one journey that needs nothing but the file.
|
|
737
|
+
if (journey.name === DECLARED) {
|
|
738
|
+
const apkPath = String(prepared.facts?.apk ?? session?.apkPath ?? '');
|
|
739
|
+
const apk = session?.apk ?? (apkPath ? await readApk(apkPath) : null);
|
|
740
|
+
if (!apk?.ok) {
|
|
741
|
+
return [notCovered({ channel: 'contract', path: ['manifest', 'read'], reason: 'missing tool', says: 'the app file could not be read, so nothing it declares was checked' })];
|
|
742
|
+
}
|
|
743
|
+
return declaredObservations(apk, apkPath);
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
if (!session) {
|
|
747
|
+
return [notCovered({
|
|
748
|
+
channel: 'meaning',
|
|
749
|
+
path: joinPath('screen', journey.name, 'walked'),
|
|
750
|
+
reason: 'missing tool',
|
|
751
|
+
says: `"${journey.name}" was not walked: ${prepared.why}`,
|
|
752
|
+
})];
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
const { device, apk } = session;
|
|
756
|
+
const started = Date.now();
|
|
757
|
+
|
|
758
|
+
if (journey.irreversible && ctx.allowIrreversible !== true) {
|
|
759
|
+
return [notCovered({
|
|
760
|
+
channel: 'effects',
|
|
761
|
+
path: joinPath('screen', journey.name, 'walked'),
|
|
762
|
+
reason: 'irreversible',
|
|
763
|
+
says: `"${journey.name}" was not walked because doing it for real would spend money, send a message or destroy data. It is unchecked, not fine.`,
|
|
764
|
+
})];
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
// Every run starts from the same place: the app stopped, its own data cleared, logs
|
|
768
|
+
// emptied. Without this the second journey inherits whatever the first one left behind,
|
|
769
|
+
// and the order journeys happen to run in becomes part of the answer.
|
|
770
|
+
await device.shell(`am force-stop ${apk.pkg}`, { timeoutMs: 30000 });
|
|
771
|
+
await device.shell(`pm clear ${apk.pkg}`, { timeoutMs: 60000 });
|
|
772
|
+
await device.shell('logcat -c; logcat -c -b events', { timeoutMs: 30000 });
|
|
773
|
+
|
|
774
|
+
const wire = await watchTheWire({ allowTo: session.config.allowTo ?? [], log: ctx.log });
|
|
775
|
+
await device.shell(`settings put global http_proxy ${HOST_FROM_EMULATOR}:${wire.port}`, { timeoutMs: 30000 });
|
|
776
|
+
|
|
777
|
+
/** @type {string[]} */
|
|
778
|
+
const trouble = [];
|
|
779
|
+
let checkpoints = 0;
|
|
780
|
+
|
|
781
|
+
/**
|
|
782
|
+
* Read the screen and write down everything on it.
|
|
783
|
+
* @param {string} label
|
|
784
|
+
*/
|
|
785
|
+
const readScreen = async (label) => {
|
|
786
|
+
checkpoints += 1;
|
|
787
|
+
const settled = await settleScreen(device, { tries: session.config.settleTries ?? 6 });
|
|
788
|
+
if (!settled.ok) {
|
|
789
|
+
out.push(notCovered({
|
|
790
|
+
channel: 'meaning',
|
|
791
|
+
path: joinPath('screen', journey.name, label, 'read'),
|
|
792
|
+
reason: 'crashed',
|
|
793
|
+
says: `the screen could not be read at "${label}": ${settled.why}`,
|
|
794
|
+
}));
|
|
795
|
+
return [];
|
|
796
|
+
}
|
|
797
|
+
const nodes = readTree(settled.xml, { pkg: apk.pkg });
|
|
798
|
+
out.push(...screenObservations({ nodes, journey: journey.name, step: label, settled: settled.settled }));
|
|
799
|
+
|
|
800
|
+
const shot = await screenshot(device, path.join(ctx.evidenceDir, `${journey.name.replace(/[^\w -]/g, '')}-${label.replace(/[^\w -]/g, '')}.png`));
|
|
801
|
+
if (shot.ok) {
|
|
802
|
+
const size = pngSize(await fsp.readFile(shot.path));
|
|
803
|
+
out.push(observation({
|
|
804
|
+
channel: 'pixels',
|
|
805
|
+
path: joinPath('pixels', journey.name, label),
|
|
806
|
+
// Coarse on purpose. This is evidence for a finding another channel already made,
|
|
807
|
+
// and a value precise enough to differ on its own would turn it into an accusation.
|
|
808
|
+
value: size ? { shape: `${size.width} by ${size.height}`, weight: sizeBucket(shot.bytes) } : { shape: 'unknown', weight: sizeBucket(shot.bytes) },
|
|
809
|
+
says: `a picture of "${label}" was kept as evidence`,
|
|
810
|
+
evidence: shot.path,
|
|
811
|
+
journey: journey.name,
|
|
812
|
+
surface: 'android',
|
|
813
|
+
}));
|
|
814
|
+
}
|
|
815
|
+
return nodes;
|
|
816
|
+
};
|
|
817
|
+
|
|
818
|
+
try {
|
|
819
|
+
/** @type {Node[]} */
|
|
820
|
+
let nodes = [];
|
|
821
|
+
const steps = journey.steps ?? [{ act: 'open' }, { act: 'read' }];
|
|
822
|
+
|
|
823
|
+
for (let i = 0; i < steps.length; i += 1) {
|
|
824
|
+
const step = /** @type {any} */ (steps[i]);
|
|
825
|
+
const label = String(step.note ?? step.act ?? `step ${i + 1}`);
|
|
826
|
+
|
|
827
|
+
if (step.act === 'open') {
|
|
828
|
+
const target = step.activity ? `${apk.pkg}/${String(step.activity).startsWith('.') ? String(step.activity) : String(step.activity)}` : `${apk.pkg}/${apk.launchActivity}`;
|
|
829
|
+
const launch = await device.shell(`am start -W -n ${target}`, { timeoutMs: 120000 });
|
|
830
|
+
const state = (/LaunchState:\s*(\w+)/.exec(launch.out) ?? [])[1] ?? 'unknown';
|
|
831
|
+
out.push(observation({
|
|
832
|
+
channel: 'counters',
|
|
833
|
+
path: joinPath('count', journey.name, label, 'how it started'),
|
|
834
|
+
value: state,
|
|
835
|
+
says: `the screen came up ${state === 'COLD' ? 'from nothing, with the app not already running' : state.toLowerCase()}`,
|
|
836
|
+
journey: journey.name,
|
|
837
|
+
surface: 'android',
|
|
838
|
+
}));
|
|
839
|
+
if (/Error|Exception/i.test(launch.out + launch.err)) trouble.push(`the screen would not open: ${launch.out.trim() || launch.err.trim()}`);
|
|
840
|
+
nodes = await readScreen(label);
|
|
841
|
+
continue;
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
if (step.act === 'read') {
|
|
845
|
+
nodes = await readScreen(label);
|
|
846
|
+
continue;
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
if (step.act === 'press' || step.act === 'tap' || step.act === 'tick') {
|
|
850
|
+
if (nodes.length === 0) nodes = readTree((await settleScreen(device)).xml, { pkg: apk.pkg });
|
|
851
|
+
const want = { id: step.id, name: step.name, text: step.text, kind: step.kind, address: step.address };
|
|
852
|
+
for (const key of Object.keys(want)) if (/** @type {any} */ (want)[key] === undefined) delete /** @type {any} */ (want)[key];
|
|
853
|
+
const hits = findNodes(nodes, want);
|
|
854
|
+
if (hits.length === 0) {
|
|
855
|
+
out.push(notCovered({
|
|
856
|
+
channel: 'meaning',
|
|
857
|
+
path: joinPath('screen', journey.name, label, 'pressed'),
|
|
858
|
+
reason: 'not supported here',
|
|
859
|
+
says: `nothing on this screen answers to ${JSON.stringify(want)}, so the rest of "${journey.name}" was not walked. That is a finding in itself: the control this journey depends on is not there.`,
|
|
860
|
+
}));
|
|
861
|
+
break;
|
|
862
|
+
}
|
|
863
|
+
if (hits.length > 1) {
|
|
864
|
+
// Pressing the first of several would make the walk depend on the order the
|
|
865
|
+
// accessibility layer happened to list them in, which is not something to build on.
|
|
866
|
+
out.push(notCovered({
|
|
867
|
+
channel: 'meaning',
|
|
868
|
+
path: joinPath('screen', journey.name, label, 'pressed'),
|
|
869
|
+
reason: 'refused',
|
|
870
|
+
says: `${hits.length} things on this screen answer to ${JSON.stringify(want)}, so nothing was pressed rather than guessing which one was meant`,
|
|
871
|
+
}));
|
|
872
|
+
break;
|
|
873
|
+
}
|
|
874
|
+
if (!hits[0].enabled) {
|
|
875
|
+
out.push(observation({
|
|
876
|
+
channel: 'meaning',
|
|
877
|
+
path: joinPath('screen', journey.name, label, 'pressed'),
|
|
878
|
+
value: 'could not — it is greyed out',
|
|
879
|
+
says: `"${hits[0].name || hits[0].kind}" is on screen but greyed out, so it could not be pressed`,
|
|
880
|
+
journey: journey.name,
|
|
881
|
+
surface: 'android',
|
|
882
|
+
}));
|
|
883
|
+
break;
|
|
884
|
+
}
|
|
885
|
+
await pressNode(device, hits[0]);
|
|
886
|
+
await pause(step.settleMs ?? 600, ctx.signal);
|
|
887
|
+
nodes = await readScreen(label);
|
|
888
|
+
continue;
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
if (step.act === 'type') {
|
|
892
|
+
if (nodes.length === 0) nodes = readTree((await settleScreen(device)).xml, { pkg: apk.pkg });
|
|
893
|
+
const field = findNodes(nodes, step.into ? { name: String(step.into) } : { kind: 'EditText' })[0]
|
|
894
|
+
?? findNodes(nodes, { id: String(step.into ?? '') })[0];
|
|
895
|
+
if (!field) {
|
|
896
|
+
out.push(notCovered({
|
|
897
|
+
channel: 'meaning',
|
|
898
|
+
path: joinPath('screen', journey.name, label, 'typed'),
|
|
899
|
+
reason: 'not supported here',
|
|
900
|
+
says: `there is nowhere on this screen called "${step.into ?? 'a text field'}" to type into, so the rest of "${journey.name}" was not walked`,
|
|
901
|
+
}));
|
|
902
|
+
break;
|
|
903
|
+
}
|
|
904
|
+
await pressNode(device, field);
|
|
905
|
+
await pause(300, ctx.signal);
|
|
906
|
+
const typed = await typeText(device, String(step.text ?? ''));
|
|
907
|
+
if (!typed.ok) {
|
|
908
|
+
out.push(notCovered({ channel: 'meaning', path: joinPath('screen', journey.name, label, 'typed'), reason: 'not supported here', says: typed.why }));
|
|
909
|
+
break;
|
|
910
|
+
}
|
|
911
|
+
await device.shell('input keyevent KEYCODE_ESCAPE', { timeoutMs: 20000 });
|
|
912
|
+
nodes = await readScreen(label);
|
|
913
|
+
continue;
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
if (step.act === 'back') {
|
|
917
|
+
await device.shell('input keyevent KEYCODE_BACK', { timeoutMs: 20000 });
|
|
918
|
+
await pause(500, ctx.signal);
|
|
919
|
+
nodes = await readScreen(label);
|
|
920
|
+
continue;
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
if (step.act === 'key') {
|
|
924
|
+
await device.shell(`input keyevent ${String(step.key ?? 'KEYCODE_ENTER').replace(/[^A-Z_0-9]/g, '')}`, { timeoutMs: 20000 });
|
|
925
|
+
await pause(400, ctx.signal);
|
|
926
|
+
nodes = await readScreen(label);
|
|
927
|
+
continue;
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
if (step.act === 'wait') {
|
|
931
|
+
await pause(Math.min(Number(step.ms ?? 1000), 30000), ctx.signal);
|
|
932
|
+
continue;
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
out.push(notCovered({
|
|
936
|
+
channel: 'meaning',
|
|
937
|
+
path: joinPath('screen', journey.name, label, 'done'),
|
|
938
|
+
reason: 'not supported here',
|
|
939
|
+
says: `this adapter does not know how to "${step.act}", so that step and everything after it in "${journey.name}" was skipped`,
|
|
940
|
+
}));
|
|
941
|
+
break;
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
// --- what went out ------------------------------------------------------
|
|
945
|
+
const pid = await pidOf(device, apk.pkg);
|
|
946
|
+
const calls = await wire.stop();
|
|
947
|
+
await device.shell('settings put global http_proxy :0', { timeoutMs: 30000 });
|
|
948
|
+
|
|
949
|
+
/** @type {Map<string, number>} */
|
|
950
|
+
const byCall = new Map();
|
|
951
|
+
let fromTheApp = 0;
|
|
952
|
+
for (const call of calls) {
|
|
953
|
+
const key = `${call.method} ${call.host}${call.route}`;
|
|
954
|
+
byCall.set(key, (byCall.get(key) ?? 0) + 1);
|
|
955
|
+
}
|
|
956
|
+
for (const [key, times] of [...byCall.entries()].sort()) {
|
|
957
|
+
const call = calls.find((c) => `${c.method} ${c.host}${c.route}` === key);
|
|
958
|
+
if (!call) continue;
|
|
959
|
+
const mine = !isDeviceHost(call.host);
|
|
960
|
+
if (mine) fromTheApp += 1;
|
|
961
|
+
out.push(observation({
|
|
962
|
+
channel: 'effects',
|
|
963
|
+
path: joinPath('net', journey.name, mine ? 'the app' : 'the phone itself', key),
|
|
964
|
+
value: { asked: countBucket(times), reached: call.allowed ? 'let through' : 'stopped here' },
|
|
965
|
+
says: `${mine ? 'the app' : 'the phone, not the app,'} tried to call ${key}${times > 1 ? ` ${times} times` : ''} — ${call.why}`,
|
|
966
|
+
covered: call.how === 'encrypted' ? false : undefined,
|
|
967
|
+
reason: call.how === 'encrypted' ? 'not supported here' : undefined,
|
|
968
|
+
journey: journey.name,
|
|
969
|
+
surface: 'android',
|
|
970
|
+
}));
|
|
971
|
+
}
|
|
972
|
+
out.push(observation({
|
|
973
|
+
channel: 'counters',
|
|
974
|
+
path: joinPath('count', journey.name, 'calls the app made'),
|
|
975
|
+
value: countBucket(fromTheApp),
|
|
976
|
+
says: `the app reached out ${fromTheApp} time${fromTheApp === 1 ? '' : 's'} while this journey ran`,
|
|
977
|
+
journey: journey.name,
|
|
978
|
+
surface: 'android',
|
|
979
|
+
}));
|
|
980
|
+
|
|
981
|
+
const files = await filesWritten(device, apk.pkg);
|
|
982
|
+
if (!files.ok) {
|
|
983
|
+
out.push(notCovered({ channel: 'effects', path: joinPath('file', journey.name), reason: 'not supported here', says: files.why }));
|
|
984
|
+
} else {
|
|
985
|
+
for (const file of files.files) {
|
|
986
|
+
out.push(observation({
|
|
987
|
+
channel: 'effects',
|
|
988
|
+
path: joinPath('file', journey.name, file.path),
|
|
989
|
+
value: file.bytes,
|
|
990
|
+
says: `the app wrote ${file.path}, ${file.bytes}`,
|
|
991
|
+
journey: journey.name,
|
|
992
|
+
surface: 'android',
|
|
993
|
+
}));
|
|
994
|
+
}
|
|
995
|
+
out.push(observation({
|
|
996
|
+
channel: 'counters',
|
|
997
|
+
path: joinPath('count', journey.name, 'files written'),
|
|
998
|
+
value: countBucket(files.files.length),
|
|
999
|
+
says: `the app left ${files.files.length} file${files.files.length === 1 ? '' : 's'} behind`,
|
|
1000
|
+
journey: journey.name,
|
|
1001
|
+
surface: 'android',
|
|
1002
|
+
}));
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
const held = await permissionsHeld(device, apk.pkg);
|
|
1006
|
+
for (const permission of held.granted) {
|
|
1007
|
+
out.push(observation({
|
|
1008
|
+
channel: 'effects',
|
|
1009
|
+
path: joinPath('perm', journey.name, permission),
|
|
1010
|
+
value: 'granted',
|
|
1011
|
+
says: `the app has been granted ${permission.replace(/^android\.permission\./, '')}`,
|
|
1012
|
+
journey: journey.name,
|
|
1013
|
+
surface: 'android',
|
|
1014
|
+
}));
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
for (const component of await intentsFired(device, apk.pkg)) {
|
|
1018
|
+
out.push(observation({
|
|
1019
|
+
channel: 'effects',
|
|
1020
|
+
path: joinPath('proc', journey.name, 'opened', component),
|
|
1021
|
+
value: 'opened',
|
|
1022
|
+
says: `the app asked Android to open ${component}`,
|
|
1023
|
+
journey: journey.name,
|
|
1024
|
+
surface: 'android',
|
|
1025
|
+
}));
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
// --- what it complained about ------------------------------------------
|
|
1029
|
+
const said = await complaints(device, { pkg: apk.pkg, pid: pid ?? undefined });
|
|
1030
|
+
out.push(observation({
|
|
1031
|
+
channel: 'complaints',
|
|
1032
|
+
path: joinPath('log', journey.name, 'crashed'),
|
|
1033
|
+
value: said.crashes.length > 0,
|
|
1034
|
+
says: said.crashes.length > 0 ? `the app crashed: ${said.crashes[0]}` : 'the app did not crash',
|
|
1035
|
+
journey: journey.name,
|
|
1036
|
+
surface: 'android',
|
|
1037
|
+
}));
|
|
1038
|
+
out.push(observation({
|
|
1039
|
+
channel: 'complaints',
|
|
1040
|
+
path: joinPath('log', journey.name, 'froze'),
|
|
1041
|
+
value: said.anrs.length > 0,
|
|
1042
|
+
says: said.anrs.length > 0 ? `the app stopped responding: ${said.anrs[0]}` : 'the app never stopped responding',
|
|
1043
|
+
journey: journey.name,
|
|
1044
|
+
surface: 'android',
|
|
1045
|
+
}));
|
|
1046
|
+
out.push(observation({
|
|
1047
|
+
channel: 'complaints',
|
|
1048
|
+
path: joinPath('log', journey.name, 'errors'),
|
|
1049
|
+
value: countBucket(said.errors.length),
|
|
1050
|
+
says: said.errors.length > 0 ? `the app logged ${said.errors.length} error${said.errors.length === 1 ? '' : 's'}, the first being: ${said.errors[0]}` : 'the app logged no errors',
|
|
1051
|
+
journey: journey.name,
|
|
1052
|
+
surface: 'android',
|
|
1053
|
+
}));
|
|
1054
|
+
for (const problem of trouble) {
|
|
1055
|
+
out.push(observation({
|
|
1056
|
+
channel: 'complaints',
|
|
1057
|
+
path: joinPath('log', journey.name, 'would not open'),
|
|
1058
|
+
value: problem,
|
|
1059
|
+
says: problem,
|
|
1060
|
+
journey: journey.name,
|
|
1061
|
+
surface: 'android',
|
|
1062
|
+
}));
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
out.push(observation({
|
|
1066
|
+
channel: 'counters',
|
|
1067
|
+
path: joinPath('count', journey.name, 'how long'),
|
|
1068
|
+
value: timeBucket(Date.now() - started),
|
|
1069
|
+
says: `walking "${journey.name}" took ${timeBucket(Date.now() - started)}`,
|
|
1070
|
+
journey: journey.name,
|
|
1071
|
+
surface: 'android',
|
|
1072
|
+
}));
|
|
1073
|
+
out.push(observation({
|
|
1074
|
+
channel: 'counters',
|
|
1075
|
+
path: joinPath('count', journey.name, 'screens read'),
|
|
1076
|
+
value: countBucket(checkpoints),
|
|
1077
|
+
says: `${checkpoints} screen${checkpoints === 1 ? ' was' : 's were'} read along the way`,
|
|
1078
|
+
journey: journey.name,
|
|
1079
|
+
surface: 'android',
|
|
1080
|
+
}));
|
|
1081
|
+
|
|
1082
|
+
for (const caveat of session.caveats) {
|
|
1083
|
+
out.push(notCovered({
|
|
1084
|
+
channel: 'complaints',
|
|
1085
|
+
path: joinPath('log', journey.name, 'worth knowing', caveat.slice(0, 48)),
|
|
1086
|
+
reason: 'not supported here',
|
|
1087
|
+
says: caveat,
|
|
1088
|
+
}));
|
|
1089
|
+
}
|
|
1090
|
+
return out;
|
|
1091
|
+
} finally {
|
|
1092
|
+
await wire.stop().catch(() => {});
|
|
1093
|
+
await device.shell('settings put global http_proxy :0', { timeoutMs: 30000 }).catch(() => {});
|
|
1094
|
+
}
|
|
1095
|
+
},
|
|
1096
|
+
|
|
1097
|
+
/**
|
|
1098
|
+
* Put everything back.
|
|
1099
|
+
*
|
|
1100
|
+
* Only what this tool started is stopped. Somebody's own emulator, and their own phone, are
|
|
1101
|
+
* left exactly as they were found — including the app, if it was already on there.
|
|
1102
|
+
*/
|
|
1103
|
+
async teardown() {
|
|
1104
|
+
for (const [id, session] of [...open.entries()]) {
|
|
1105
|
+
try {
|
|
1106
|
+
await resetDevice(session);
|
|
1107
|
+
await session.device.shell('settings put global http_proxy :0', { timeoutMs: 20000 });
|
|
1108
|
+
if (session.ownsDevice) await session.stopDevice();
|
|
1109
|
+
} catch {
|
|
1110
|
+
// Nothing here is worth throwing over: teardown must never hide a finding.
|
|
1111
|
+
}
|
|
1112
|
+
open.delete(id);
|
|
1113
|
+
}
|
|
1114
|
+
},
|
|
1115
|
+
});
|
|
1116
|
+
|
|
1117
|
+
export default androidAdapter;
|