Skip to content

Commit 4260aa7

Browse files
authored
fix(android): return from an app open only after the launched app is readable (#2895)
* fix(android): return from an app open only after the launched app is readable am start -W returns when the activity draws its first frame, which can be a splash or an empty root while a React Native app still mounts. The first capture after a cold open --relaunch then saw a content-poor tree and spent its own budget on re-captures. The open now captures the launched app through the interactor snapshot path, whose content verdict and bounded re-capture decide readiness, and reports postOpenObservation. An app that stays unreadable, or a failed capture, still opens as unobservable. Refs #1571 (iOS half: #2838). * fix(android): bound and type the launch observation of an app open The open's launch capture now runs behind an injected launch observation port with a fixed 6 s window of its own, separate from the caller's cancellation. Only a content verdict, a system surface over the app, or the window running out reads as unobservable. Any other capture failure is a typed probe-failed result that the open reports and survives. The capture is transient: it borrows a running helper session and stops only a session it started, installs no helper, and does not retire the helper after a content verdict. A URL open reports no observation, and an app open whose package cannot be read reports app-unidentified. PostOpenObservation is one documented union in the lifecycle contract, shared with the Apple owner. Refs #1571 * fix(android): let the launch settle window end re-captures, never helper work The open's 6 s window reached the snapshot helper as an abort, so a window that closed during a cold helper start or a borrowed capture tore the helper down and left the next read to recover it. The window is now a settle deadline on the transient capture: the content re-capture loop starts no attempt after it, while helper start, capture and teardown keep their own budgets and only the caller's signal cancels them. The transient read replaces the injected port, the borrow session scope and the interactor-side install mapping: the capture itself keeps a session it found, releases one it started, installs no helper and does not retire it after a content verdict. Refs #1571 * fix(android): keep a missing-helper refusal out of capture failure recovery A transient capture on a device without the current helper refused at the install check, and that refusal went through the capture failure handler, which logged an error and force-stopped the helper runtime on every new device's first open. The refusal now reaches the caller directly. The transient-capture tests move to their own file, so snapshot.test.ts stays under the test-file size ratchet. Refs #1571
1 parent 438b43c commit 4260aa7

18 files changed

Lines changed: 841 additions & 89 deletions

‎packages/contracts/src/application-lifecycle-runtime.ts‎

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -124,8 +124,39 @@ export type OpenApplicationTiming = Readonly<{
124124
openDispatchDurationMs?: number;
125125
launchUrlDurationMs?: number;
126126
postOpenSettleDurationMs?: number;
127-
/** What a Simulator open learned about the launched app before returning (see the Apple owner). */
128-
postOpenObservation?: 'observable' | 'unobservable' | 'not-eligible';
127+
/** Unset when the open had no launched app to observe, such as a URL or deep-link target. */
128+
postOpenObservation?: PostOpenObservation;
129+
/** Why the observation could not run; present exactly when it is `probe-failed`. */
130+
postOpenObservationFailure?: PostOpenObservationFailure;
131+
}>;
132+
133+
/**
134+
* What an app open learned about the launched app before it returned. A local iOS Simulator asks
135+
* its host AX bridge; a local Android device captures the app through the snapshot helper. Each
136+
* owner bounds the observation, and the open succeeds whatever the value is.
137+
*
138+
* - `observable`: the launched app's tree was readable.
139+
* - `unobservable`: the app stayed unreadable within the owner's bounded window: a launch transition
140+
* or AX-server state that did not clear, a system surface over the app, a content verdict after
141+
* the capture's own re-captures, or the window ran out.
142+
* - `probe-failed`: the observation could not run (Android: the helper is not installed at the
143+
* current version, or adb or the accessibility service failed). `postOpenObservationFailure`
144+
* carries the typed failure.
145+
* - `app-unidentified`: the open targeted an app, but the owner could not read which package it
146+
* launched, so nothing was observed.
147+
* - `not-eligible`: the device has no observation path.
148+
*/
149+
export type PostOpenObservation =
150+
| 'observable'
151+
| 'unobservable'
152+
| 'probe-failed'
153+
| 'app-unidentified'
154+
| 'not-eligible';
155+
156+
/** The typed failure of a `probe-failed` observation: the error code and its typed reason. */
157+
export type PostOpenObservationFailure = Readonly<{
158+
code: string;
159+
reason?: string;
129160
}>;
130161

131162
export type OpenApplicationOutcome = Readonly<{

‎packages/contracts/src/interactor-types.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,12 @@ export type SnapshotOptions = BaseSnapshotOptions & {
175175
surface?: SessionSurface;
176176
/** Internal capture purpose; action outcomes always require the full tree. */
177177
acquisitionIntent?: 'full' | 'surface-observation';
178+
/**
179+
* A one-off read, such as an open's launch observation. It may use a capture host the session
180+
* already keeps warm, but it never installs one or leaves running one that it started. It starts
181+
* no re-capture after `settleBy` (epoch ms); only `signal` cancels work already started.
182+
*/
183+
transient?: Readonly<{ settleBy: number }>;
178184
};
179185

180186
/**

‎packages/platform-android/src/__tests__/snapshot-helper-install.test.ts‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,31 @@ test('an exec-shaped helper install timeout names the dialog, not a wedged adb s
8686
);
8787
});
8888

89+
test('a current-only helper check refuses a missing helper without installing it', async () => {
90+
const apkPath = await writeHelperApk('snapshot-helper-current-only-');
91+
let installs = 0;
92+
const adbProvider = missingPackageProvider(async () => {
93+
installs += 1;
94+
return { exitCode: 0, stdout: 'Success', stderr: '' };
95+
});
96+
97+
await assert.rejects(
98+
() =>
99+
ensureAndroidSnapshotHelper({
100+
adb: adbProvider.exec,
101+
adbProvider,
102+
artifact: { apkPath, manifest: { ...manifest, sha256: sha256Text('helper-apk') } },
103+
deviceKey: 'android:emulator-5554',
104+
installPolicy: 'current-only',
105+
}),
106+
(error) => {
107+
assert.equal((error as AppError).details?.reason, 'android-snapshot-helper-not-current');
108+
return true;
109+
},
110+
);
111+
assert.equal(installs, 0);
112+
});
113+
89114
function adbInstallTimeout(): AppError {
90115
// An unattended first install on ColorOS: adb blocks on the system install-confirmation dialog
91116
// and the exec layer kills the command, so stdout/stderr stay empty.

‎packages/platform-android/src/__tests__/snapshot-helper-session.fixtures.ts‎

Lines changed: 48 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,10 @@ export type PersistentSnapshotHelperProviderOptions = {
6262
oneShotXml?: string;
6363
/** Make the device-side stop fail the way an unhealthy transport answers. */
6464
runtimeStopFailure?: boolean;
65+
/** How long a started session takes to report ready. */
66+
sessionReadyDelayMs?: number;
67+
/** How long a session takes to answer its `snapshotCount`th capture. */
68+
captureResponseDelayMs?: (snapshotCount: number) => number;
6569
};
6670

6771
export function createPersistentSnapshotHelperProvider(
@@ -104,36 +108,40 @@ export function createPersistentSnapshotHelperProvider(
104108
const body = options.sessionXml
105109
? options.sessionXml(sessionIndex, snapshotCount)
106110
: `<hierarchy><node text="persistent helper snapshot ${snapshotCount}" bounds="[0,0][10,10]" /></hierarchy>`;
107-
socket.end(
108-
sessionResponse({
109-
requestId,
110-
body,
111-
metadata: {
112-
waitForIdleTimeoutMs: '500',
113-
waitForIdleQuietMs: '100',
114-
timeoutMs: '5000',
115-
maxDepth: '128',
116-
maxNodes: '5000',
117-
rootPresent: 'true',
118-
captureMode: 'interactive-windows',
119-
windowCount: '1',
120-
nodeCount: '1',
121-
truncated: 'false',
122-
elapsedMs: '8',
123-
},
124-
}),
125-
);
111+
const answer = () =>
112+
socket.end(
113+
sessionResponse({
114+
requestId,
115+
body,
116+
metadata: {
117+
waitForIdleTimeoutMs: '500',
118+
waitForIdleQuietMs: '100',
119+
timeoutMs: '5000',
120+
maxDepth: '128',
121+
maxNodes: '5000',
122+
rootPresent: 'true',
123+
captureMode: 'interactive-windows',
124+
windowCount: '1',
125+
nodeCount: '1',
126+
truncated: 'false',
127+
elapsedMs: '8',
128+
},
129+
}),
130+
);
131+
setTimeout(answer, options.captureResponseDelayMs?.(snapshotCount) ?? 0);
126132
});
127133
});
128134
server.listen(port, '127.0.0.1', () => {
129-
process.stdout.write(
130-
[
131-
'INSTRUMENTATION_STATUS: agentDeviceProtocol=android-snapshot-helper-v1',
132-
'INSTRUMENTATION_STATUS: sessionReady=true',
133-
'INSTRUMENTATION_STATUS_CODE: 2',
134-
'',
135-
].join('\n'),
136-
);
135+
setTimeout(() => {
136+
process.stdout.write(
137+
[
138+
'INSTRUMENTATION_STATUS: agentDeviceProtocol=android-snapshot-helper-v1',
139+
'INSTRUMENTATION_STATUS: sessionReady=true',
140+
'INSTRUMENTATION_STATUS_CODE: 2',
141+
'',
142+
].join('\n'),
143+
);
144+
}, options.sessionReadyDelayMs ?? 0);
137145
});
138146
process.onKill = () => {
139147
server.close(() => process.emitExit(0, null));
@@ -513,3 +521,16 @@ function readSessionPort(args: readonly string[]): number {
513521
assert.notEqual(index, -1);
514522
return Number(args[index + 1]);
515523
}
524+
525+
/** A capture holding only system-UI chrome: the helper answered, but no app content. */
526+
export function androidSystemWindowOnlyXml(): string {
527+
return [
528+
'<?xml version="1.0" encoding="UTF-8"?>',
529+
'<hierarchy rotation="0">',
530+
' <node window-index="0" window-type="3" window-layer="30" window-active="true" window-focused="true" class="android.widget.FrameLayout" package="com.android.systemui" bounds="[0,0][390,844]" enabled="true" visible-to-user="true">',
531+
' <node content-desc="Back" class="android.widget.ImageButton" package="com.android.systemui" bounds="[0,792][96,844]" clickable="true" enabled="true" focusable="true" visible-to-user="true" />',
532+
' <node content-desc="Home" class="android.widget.ImageButton" package="com.android.systemui" bounds="[147,792][243,844]" clickable="true" enabled="true" focusable="true" visible-to-user="true" />',
533+
' </node>',
534+
'</hierarchy>',
535+
].join('\n');
536+
}

0 commit comments

Comments
 (0)