Skip to content

Commit 2b092ca

Browse files
committed
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
1 parent a7572a5 commit 2b092ca

17 files changed

Lines changed: 577 additions & 108 deletions

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

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -124,11 +124,39 @@ export type OpenApplicationTiming = Readonly<{
124124
openDispatchDurationMs?: number;
125125
launchUrlDurationMs?: number;
126126
postOpenSettleDurationMs?: number;
127-
/**
128-
* What the open learned about the launched app's readability before returning, set by a local
129-
* iOS Simulator and by Android (see each platform owner).
130-
*/
131-
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;
132160
}>;
133161

134162
export type OpenApplicationOutcome = Readonly<{

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,11 @@ 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.
181+
*/
182+
transient?: boolean;
178183
};
179184

180185
/**

‎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.test.ts‎

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import {
3737
type FakeAndroidProcess,
3838
} from './snapshot-helper-session.fixtures.ts';
3939
import { withAndroidAdbProvider, type AndroidAdbProvider } from '../adb-executor.ts';
40+
import { isUnreadableCaptureContentError } from '@agent-device/contracts/android-snapshot-quality';
4041

4142
const VALID_PNG = Buffer.from(
4243
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+b9xkAAAAASUVORK5CYII=',
@@ -537,6 +538,60 @@ test('snapshotAndroid keeps daemon-session helper alive for reuse until session
537538
);
538539
});
539540

541+
test('a borrowed capture releases a helper session it had to start', async () => {
542+
const adbCalls: (readonly string[])[] = [];
543+
const spawnArgs: (readonly string[])[] = [];
544+
const processes: FakeAndroidProcess[] = [];
545+
const provider = createPersistentSnapshotHelperProvider({
546+
calls: adbCalls,
547+
spawnArgs,
548+
processes,
549+
});
550+
551+
await snapshotAndroid(device, {
552+
helperAdb: provider,
553+
helperArtifact,
554+
helperSessionScope: 'borrow',
555+
});
556+
557+
assert.equal(spawnArgs.length, 1);
558+
assert.equal(processes[0]?.exitCode, 0);
559+
assert.equal(
560+
adbCalls.some((args) => args[0] === 'forward' && args[1] === '--remove'),
561+
true,
562+
);
563+
});
564+
565+
test('a borrowed capture leaves a warm daemon-session helper running', async () => {
566+
const adbCalls: (readonly string[])[] = [];
567+
const spawnArgs: (readonly string[])[] = [];
568+
const processes: FakeAndroidProcess[] = [];
569+
const provider = createPersistentSnapshotHelperProvider({
570+
calls: adbCalls,
571+
spawnArgs,
572+
processes,
573+
});
574+
575+
await snapshotAndroid(device, {
576+
helperAdb: provider,
577+
helperArtifact,
578+
helperSessionScope: 'daemon-session',
579+
});
580+
const borrowed = await snapshotAndroid(device, {
581+
helperAdb: provider,
582+
helperArtifact,
583+
helperSessionScope: 'borrow',
584+
});
585+
586+
assert.equal(borrowed.androidSnapshot.helperSessionReused, true);
587+
assert.equal(spawnArgs.length, 1);
588+
assert.equal(processes[0]?.exitCode, null);
589+
assert.equal(
590+
adbCalls.some((args) => args[0] === 'forward' && args[1] === '--remove'),
591+
false,
592+
);
593+
});
594+
540595
test('a daemon-session viewport read warms the session the next snapshot reuses', async () => {
541596
// The gesture viewport and snapshot capture are different helper commands on the same device.
542597
// They may only share the live session if both derive the same session identity, which is why
@@ -607,6 +662,35 @@ test('snapshotAndroid retires content-invalid daemon helper before the next requ
607662
);
608663
});
609664

665+
test('a borrowed capture reports its content verdict without retiring the warm helper', async () => {
666+
const adbCalls: (readonly string[])[] = [];
667+
const spawnArgs: (readonly string[])[] = [];
668+
const processes: FakeAndroidProcess[] = [];
669+
const provider = createPersistentSnapshotHelperProvider({
670+
calls: adbCalls,
671+
spawnArgs,
672+
processes,
673+
sessionXml: (_sessionIndex, snapshotCount) =>
674+
snapshotCount === 1
675+
? '<hierarchy><node text="warm helper" bounds="[0,0][10,10]" /></hierarchy>'
676+
: androidSystemWindowOnlyXml(),
677+
});
678+
await snapshotAndroid(device, {
679+
helperAdb: provider,
680+
helperArtifact,
681+
helperSessionScope: 'daemon-session',
682+
});
683+
684+
await assert.rejects(
685+
snapshotAndroid(device, { helperAdb: provider, helperArtifact, helperSessionScope: 'borrow' }),
686+
(error: unknown) => isUnreadableCaptureContentError(error),
687+
);
688+
689+
assert.equal(processes[0]?.exitCode, null, 'the session helper is still running');
690+
assert.equal(adbCalls.some(isHelperRuntimeReset), false);
691+
assert.equal(spawnArgs.length, 1);
692+
});
693+
610694
test('content-invalid daemon helper retirement force-stops the helper runtime', async () => {
611695
// Retirement after a content failure is a recovery path, not a release: the helper answered with
612696
// output we could not trust, so the next capture must meet a runtime that was reset. A clean quit
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
import { expect, test, vi } from 'vitest';
2+
import type { Interactor, SnapshotOptions } from '@agent-device/contracts/interactor-types';
3+
import type { PlatformRuntimeHost } from '@agent-device/contracts/platform-runtime-operations';
4+
import { AppError, createRequestCanceledError } from '@agent-device/kernel/errors';
5+
import {
6+
ANDROID_LAUNCH_OBSERVATION_WINDOW_MS,
7+
createAndroidLaunchObservationProbe,
8+
} from './launch-observation.ts';
9+
import { androidHelperContentUnavailableError } from './snapshot.ts';
10+
11+
type ProbeFixture = Readonly<{
12+
snapshotOptions: SnapshotOptions[];
13+
sleeps: number[];
14+
/** Ends every pending clock sleep, as elapsed time would. */
15+
elapse: () => void;
16+
observe: (
17+
signal?: AbortSignal,
18+
) => ReturnType<ReturnType<typeof createAndroidLaunchObservationProbe>['awaitObservable']>;
19+
}>;
20+
21+
function createProbe(snapshot: (options: SnapshotOptions) => Promise<unknown>): ProbeFixture {
22+
const snapshotOptions: SnapshotOptions[] = [];
23+
const sleeps: number[] = [];
24+
const pendingSleeps: Array<() => void> = [];
25+
const clock = {
26+
now: () => Date.now(),
27+
sleep: async (ms: number, signal?: AbortSignal) => {
28+
sleeps.push(ms);
29+
await new Promise<void>((resolve, reject) => {
30+
pendingSleeps.push(resolve);
31+
signal?.addEventListener('abort', () => reject(signal.reason), { once: true });
32+
});
33+
},
34+
} as unknown as PlatformRuntimeHost['clock'];
35+
const interactor = {
36+
snapshot: async (options: SnapshotOptions) => {
37+
snapshotOptions.push(options);
38+
return await snapshot(options);
39+
},
40+
} as unknown as Pick<Interactor, 'snapshot'>;
41+
const probe = createAndroidLaunchObservationProbe({ clock });
42+
return {
43+
snapshotOptions,
44+
sleeps,
45+
elapse: () => {
46+
for (const resolve of pendingSleeps.splice(0)) resolve();
47+
},
48+
observe: async (signal = new AbortController().signal) =>
49+
await probe.awaitObservable(interactor, 'com.example.app', signal),
50+
};
51+
}
52+
53+
/** What the capture throws once its re-captures still see an unmounted app. */
54+
function contentVerdict(): AppError {
55+
return androidHelperContentUnavailableError(
56+
{
57+
reason: 'content-poor-app-window',
58+
failureReason: 'Android snapshot helper returned insufficient foreground app content',
59+
diagnostics: {
60+
helperNodeCount: 3,
61+
helperSystemUiNodeCount: 0,
62+
helperWindowRootCount: 1,
63+
helperApplicationWindowRootCount: 1,
64+
helperMeaningfulNodeCount: 0,
65+
helperApplicationMeaningfulNodeCount: 0,
66+
helperNonSystemMeaningfulNodeCount: 0,
67+
helperInputMethodMeaningfulNodeCount: 0,
68+
helperWindowTypes: [1],
69+
},
70+
},
71+
3,
72+
);
73+
}
74+
75+
/** A capture that ends only when its signal aborts, as a stuck helper call does. */
76+
async function captureUntilAborted(options: SnapshotOptions): Promise<never> {
77+
return await new Promise<never>((_resolve, reject) => {
78+
if (options.signal?.aborted) reject(options.signal.reason);
79+
options.signal?.addEventListener('abort', () => reject(options.signal?.reason), {
80+
once: true,
81+
});
82+
});
83+
}
84+
85+
test('a readable launched app is observable through one transient capture', async () => {
86+
const probe = createProbe(async () => ({ nodes: [] }));
87+
88+
await expect(probe.observe()).resolves.toEqual({ observation: 'observable' });
89+
expect(probe.snapshotOptions).toHaveLength(1);
90+
expect(probe.snapshotOptions[0]).toMatchObject({
91+
appBundleId: 'com.example.app',
92+
transient: true,
93+
});
94+
});
95+
96+
test('a content verdict after the capture re-captures is unobservable', async () => {
97+
const probe = createProbe(async () => {
98+
throw contentVerdict();
99+
});
100+
101+
await expect(probe.observe()).resolves.toEqual({ observation: 'unobservable' });
102+
});
103+
104+
test('a system surface covering the launched app is unobservable', async () => {
105+
const probe = createProbe(async () => ({
106+
nodes: [],
107+
androidSnapshot: { backend: 'android-helper', systemSurfaceOnly: true },
108+
}));
109+
110+
await expect(probe.observe()).resolves.toEqual({ observation: 'unobservable' });
111+
});
112+
113+
test('a capture mechanism failure is a failed probe with its typed reason', async () => {
114+
const probe = createProbe(async () => {
115+
throw new AppError('COMMAND_FAILED', 'Android snapshot helper failed: accessibility timeout', {
116+
androidSnapshotHelperFailureReason: 'Android snapshot helper failed: accessibility timeout',
117+
androidCaptureFailureReason: 'accessibility-timeout',
118+
});
119+
});
120+
121+
await expect(probe.observe()).resolves.toEqual({
122+
observation: 'probe-failed',
123+
failure: { code: 'COMMAND_FAILED', reason: 'accessibility-timeout' },
124+
});
125+
});
126+
127+
test('a helper that is not installed at the current version is a failed probe', async () => {
128+
const probe = createProbe(async () => {
129+
throw new AppError('COMMAND_FAILED', 'Android snapshot helper is not installed', {
130+
reason: 'android-snapshot-helper-not-current',
131+
});
132+
});
133+
134+
await expect(probe.observe()).resolves.toEqual({
135+
observation: 'probe-failed',
136+
failure: { code: 'COMMAND_FAILED', reason: 'android-snapshot-helper-not-current' },
137+
});
138+
});
139+
140+
test('a capture that outlasts the fixed window is abandoned as unobservable', async () => {
141+
const probe = createProbe(captureUntilAborted);
142+
143+
const observing = probe.observe();
144+
await vi.waitFor(() => expect(probe.sleeps).toEqual([ANDROID_LAUNCH_OBSERVATION_WINDOW_MS]));
145+
probe.elapse();
146+
147+
await expect(observing).resolves.toEqual({ observation: 'unobservable' });
148+
expect(probe.snapshotOptions[0]?.signal?.aborted).toBe(true);
149+
});
150+
151+
test('a cancelled open rejects with its cancellation, not an observation', async () => {
152+
const controller = new AbortController();
153+
const canceled = createRequestCanceledError();
154+
const probe = createProbe(async (options) => {
155+
controller.abort(canceled);
156+
return await captureUntilAborted(options);
157+
});
158+
159+
await expect(probe.observe(controller.signal)).rejects.toBe(canceled);
160+
});

0 commit comments

Comments
 (0)