Skip to content

Commit a7572a5

Browse files
committed
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).
1 parent dc9ab86 commit a7572a5

4 files changed

Lines changed: 163 additions & 19 deletions

File tree

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,10 @@ 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). */
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+
*/
128131
postOpenObservation?: 'observable' | 'unobservable' | 'not-eligible';
129132
}>;
130133

‎packages/platform-android/src/lifecycle.test.ts‎

Lines changed: 131 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
11
import { afterEach, expect, test, vi } from 'vitest';
2-
import type { LocalApplicationInteractorHost } from '@agent-device/contracts/application-lifecycle-runtime';
2+
import type {
3+
LocalApplicationInteractorHost,
4+
OpenApplicationInput,
5+
} from '@agent-device/contracts/application-lifecycle-runtime';
36
import type { PlatformRuntimeHost } from '@agent-device/contracts/platform-runtime-operations';
4-
import type { Interactor } from '@agent-device/contracts/interactor-types';
7+
import type { Interactor, SnapshotOptions } from '@agent-device/contracts/interactor-types';
58
import type { DeviceInfo } from '@agent-device/kernel/device';
9+
import { AppError } from '@agent-device/kernel/errors';
610
import { bindAndroidApplicationLifecycle } from './lifecycle.ts';
711

812
const device: DeviceInfo = {
@@ -18,17 +22,32 @@ afterEach(() => {
1822
vi.restoreAllMocks();
1923
});
2024

21-
test('preserves a runtime launch URL duration after the admitted Android follow-up open', async () => {
22-
const opens: string[] = [];
25+
type LifecycleFixture = Readonly<{
26+
calls: string[];
27+
lifecycle: ReturnType<typeof bindAndroidApplicationLifecycle>;
28+
}>;
29+
30+
function createLifecycle(
31+
params: Readonly<{
32+
snapshot?: (options: SnapshotOptions | undefined) => Promise<unknown>;
33+
openedAppBundleId?: string;
34+
signal?: AbortSignal;
35+
}> = {},
36+
): LifecycleFixture {
37+
const calls: string[] = [];
2338
const localInteractors: LocalApplicationInteractorHost = {
2439
resolve: async () =>
2540
({
2641
open: async (app: string) => {
27-
opens.push(app);
42+
calls.push(`open:${app}`);
2843
},
2944
openDevice: async () => {},
3045
close: async () => {},
3146
setSetting: async () => {},
47+
snapshot: async (options?: SnapshotOptions) => {
48+
calls.push(`snapshot:${options?.appBundleId}`);
49+
return await (params.snapshot?.(options) ?? Promise.resolve({ nodes: [] }));
50+
},
3251
}) as unknown as Interactor,
3352
};
3453
const host = {
@@ -41,7 +60,8 @@ test('preserves a runtime launch URL duration after the admitted Android follow-
4160
},
4261
androidApplications: {
4362
resolveOpenTarget: async () => ({}),
44-
inferOpenedAppBundleId: async () => 'com.example.app',
63+
inferOpenedAppBundleId: async () =>
64+
'openedAppBundleId' in params ? params.openedAppBundleId : 'com.example.app',
4565
resetFramePerfStats: async () => {},
4666
applyRuntimeHints: async () => {},
4767
clearRuntimeHints: async () => {},
@@ -60,21 +80,18 @@ test('preserves a runtime launch URL duration after the admitted Android follow-
6080
| 'localInteractors'
6181
| 'toolchains'
6282
>;
63-
vi.spyOn(Date, 'now')
64-
.mockReturnValueOnce(10)
65-
.mockReturnValueOnce(20)
66-
.mockReturnValueOnce(30)
67-
.mockReturnValueOnce(50);
6883
const lifecycle = bindAndroidApplicationLifecycle({
6984
host,
7085
device,
71-
signal: new AbortController().signal,
86+
signal: params.signal ?? new AbortController().signal,
7287
});
88+
return { calls, lifecycle };
89+
}
7390

74-
const outcome = await lifecycle.openApplication({
91+
function openInput(overrides: Partial<OpenApplicationInput> = {}): OpenApplicationInput {
92+
return {
7593
target: 'com.example.app',
7694
positionals: ['com.example.app'],
77-
runtimeLaunchUrl: 'example://after-open',
7895
appBundleId: 'com.example.app',
7996
surface: 'app',
8097
hasExistingSession: false,
@@ -84,8 +101,106 @@ test('preserves a runtime launch URL duration after the admitted Android follow-
84101
stateDir: '/state',
85102
runtimeHints: {},
86103
execution: {},
87-
});
104+
...overrides,
105+
};
106+
}
107+
108+
/** What the Android capture throws once its bounded re-capture still sees an unmounted app. */
109+
function unreadableLaunchContentError(): AppError {
110+
return new AppError(
111+
'COMMAND_FAILED',
112+
'Android snapshot helper returned insufficient foreground app content',
113+
{
114+
androidSnapshotHelperFailureReason: 'content-poor-app-window',
115+
attempts: 3,
116+
retriable: true,
117+
},
118+
);
119+
}
88120

89-
expect(opens).toEqual(['com.example.app', 'example://after-open']);
121+
test('preserves a runtime launch URL duration after the admitted Android follow-up open', async () => {
122+
const { calls, lifecycle } = createLifecycle();
123+
vi.spyOn(Date, 'now')
124+
.mockReturnValueOnce(10)
125+
.mockReturnValueOnce(20)
126+
.mockReturnValueOnce(30)
127+
.mockReturnValueOnce(50);
128+
129+
const outcome = await lifecycle.openApplication(
130+
openInput({ runtimeLaunchUrl: 'example://after-open' }),
131+
);
132+
133+
expect(calls.filter((call) => call.startsWith('open:'))).toEqual([
134+
'open:com.example.app',
135+
'open:example://after-open',
136+
]);
90137
expect(outcome.timing.launchUrlDurationMs).toBe(20);
91138
});
139+
140+
test('an Android app open returns only after a capture of the launched app is readable', async () => {
141+
let releaseCapture: () => void = () => {};
142+
const captureReleased = new Promise<void>((resolve) => {
143+
releaseCapture = resolve;
144+
});
145+
const { calls, lifecycle } = createLifecycle({
146+
openedAppBundleId: 'com.example.opened',
147+
snapshot: async () => {
148+
await captureReleased;
149+
return { nodes: [] };
150+
},
151+
});
152+
153+
let settled = false;
154+
const opening = lifecycle.openApplication(openInput()).then((outcome) => {
155+
settled = true;
156+
return outcome;
157+
});
158+
await vi.waitFor(() => expect(calls).toContain('snapshot:com.example.opened'));
159+
await Promise.resolve();
160+
expect(settled).toBe(false);
161+
releaseCapture();
162+
const outcome = await opening;
163+
164+
expect(calls).toEqual(['open:com.example.app', 'snapshot:com.example.opened']);
165+
expect(outcome.appBundleId).toBe('com.example.opened');
166+
expect(outcome.timing.postOpenObservation).toBe('observable');
167+
expect(outcome.timing.postOpenSettleDurationMs).toEqual(expect.any(Number));
168+
});
169+
170+
test('an app still unreadable after the capture re-captures opens as unobservable', async () => {
171+
const { calls, lifecycle } = createLifecycle({
172+
snapshot: async () => {
173+
throw unreadableLaunchContentError();
174+
},
175+
});
176+
177+
const outcome = await lifecycle.openApplication(openInput({ relaunch: true }));
178+
179+
expect(calls).toEqual(['open:com.example.app', 'snapshot:com.example.app']);
180+
expect(outcome.appBundleId).toBe('com.example.app');
181+
expect(outcome.timing.postOpenObservation).toBe('unobservable');
182+
});
183+
184+
test('a cancelled open does not report the launch observation as unobservable', async () => {
185+
const controller = new AbortController();
186+
const { lifecycle } = createLifecycle({
187+
signal: controller.signal,
188+
snapshot: async () => {
189+
controller.abort(new Error('request cancelled'));
190+
throw unreadableLaunchContentError();
191+
},
192+
});
193+
194+
await expect(lifecycle.openApplication(openInput())).rejects.toThrow('request cancelled');
195+
});
196+
197+
test('an open whose launched app package is unknown does not observe the launch', async () => {
198+
const { calls, lifecycle } = createLifecycle({ openedAppBundleId: undefined });
199+
200+
const outcome = await lifecycle.openApplication(
201+
openInput({ target: 'https://example.com', positionals: ['https://example.com'] }),
202+
);
203+
204+
expect(calls).toEqual(['open:https://example.com']);
205+
expect(outcome.timing.postOpenObservation).toBe('not-eligible');
206+
});

‎packages/platform-android/src/lifecycle.ts‎

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -164,10 +164,35 @@ async function openAndroidApplication(
164164
if (appBundleId) {
165165
await host.androidApplications.resetFramePerfStats(binding.device, appBundleId);
166166
}
167-
timing.postOpenSettleDurationMs = 0;
167+
const settleStartedAtMs = Date.now();
168+
timing.postOpenObservation = await observeAndroidLaunch(binding, input, appBundleId);
169+
timing.postOpenSettleDurationMs = elapsed(settleStartedAtMs);
168170
return { appBundleId, timing };
169171
}
170172

173+
/**
174+
* `am start -W` returns once the activity draws its first frame, which can be a splash or an empty
175+
* root while the app still mounts its views, so the open itself captures the launched app. The
176+
* capture's content verdict and its bounded re-capture decide readiness. The open still succeeds
177+
* when the app stays unreadable or the capture fails: the capture reports why in its own
178+
* diagnostics, and the next observation meets the same state.
179+
*/
180+
async function observeAndroidLaunch(
181+
binding: ReturnType<typeof bindLocalApplicationLifecycleInteractor>,
182+
input: OpenApplicationInput,
183+
appBundleId: string | undefined,
184+
): Promise<NonNullable<OpenApplicationOutcome['timing']['postOpenObservation']>> {
185+
if (!appBundleId) return 'not-eligible';
186+
const interactor = await binding.resolveInteractor(input.execution, appBundleId);
187+
try {
188+
await interactor.snapshot({ appBundleId, signal: binding.signal });
189+
return 'observable';
190+
} catch {
191+
binding.signal.throwIfAborted();
192+
return 'unobservable';
193+
}
194+
}
195+
171196
function elapsed(startedAtMs: number): number {
172197
return Math.max(0, Date.now() - startedAtMs);
173198
}

‎test/integration/provider-scenarios/android-test-suite.test.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -191,7 +191,8 @@ test(
191191
.length,
192192
2,
193193
);
194-
assertSnapshotCountInRange(snapshots, 2, 3);
194+
// Each launchApp open captures the launched app once before it returns.
195+
assertSnapshotCountInRange(snapshots, 4, 5);
195196
},
196197
);
197198
},

0 commit comments

Comments
 (0)