Skip to content

Commit dcc7421

Browse files
authored
fix: recover Android app-owned ANRs (#592)
* fix: recover Android app-owned ANRs * refactor: tidy Android ANR recovery helpers * fix: satisfy Android ANR fallow audit
1 parent bbe7c06 commit dcc7421

12 files changed

Lines changed: 523 additions & 45 deletions

src/command-catalog.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,26 @@ export const DAEMON_COMMAND_GROUPS = {
110110
PUBLIC_COMMANDS.type,
111111
PUBLIC_COMMANDS.wait,
112112
),
113+
androidBlockingDialogGuardedAction: commandSet(
114+
PUBLIC_COMMANDS.back,
115+
PUBLIC_COMMANDS.click,
116+
PUBLIC_COMMANDS.fill,
117+
PUBLIC_COMMANDS.focus,
118+
PUBLIC_COMMANDS.gesture,
119+
PUBLIC_COMMANDS.home,
120+
PUBLIC_COMMANDS.keyboard,
121+
PUBLIC_COMMANDS.longPress,
122+
'fling',
123+
'pan',
124+
'pinch',
125+
PUBLIC_COMMANDS.press,
126+
PUBLIC_COMMANDS.rotate,
127+
'rotate-gesture',
128+
PUBLIC_COMMANDS.scroll,
129+
PUBLIC_COMMANDS.swipe,
130+
'transform-gesture',
131+
PUBLIC_COMMANDS.type,
132+
),
113133
selectorValidationExempt: commandSet(
114134
INTERNAL_COMMANDS.sessionList,
115135
PUBLIC_COMMANDS.devices,

src/daemon/__tests__/request-router-android-modal.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ vi.mock('../../platforms/android/app-lifecycle.ts', async (importOriginal) => {
5656
...actual,
5757
openAndroidApp: vi.fn(async () => {}),
5858
getAndroidAppState: vi.fn(async () => ({ package: 'com.android.settings' })),
59+
getAndroidBlockingDialogFocus: vi.fn(async () => null),
5960
};
6061
});
6162

src/daemon/__tests__/request-router-screenshot.test.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,14 @@ vi.mock('../../core/dispatch.ts', async (importOriginal) => {
88
return { ...actual, dispatchCommand: vi.fn(async () => ({})) };
99
});
1010

11+
vi.mock('../../platforms/android/app-lifecycle.ts', async (importOriginal) => {
12+
const actual = await importOriginal<typeof import('../../platforms/android/app-lifecycle.ts')>();
13+
return {
14+
...actual,
15+
getAndroidBlockingDialogFocus: vi.fn(async () => null),
16+
};
17+
});
18+
1119
import { dispatchCommand } from '../../core/dispatch.ts';
1220
import { createRequestHandler } from '../request-router.ts';
1321
import { dispatchScreenshotViaRuntime } from '../screenshot-runtime.ts';

src/daemon/android-system-dialog.ts

Lines changed: 201 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,37 @@
1-
import { getAndroidAppState, openAndroidApp } from '../platforms/android/app-lifecycle.ts';
1+
import {
2+
getAndroidAppState,
3+
getAndroidBlockingDialogFocus,
4+
openAndroidApp,
5+
type AndroidBlockingDialogFocus,
6+
} from '../platforms/android/app-lifecycle.ts';
27
import { snapshotAndroid } from '../platforms/android/snapshot.ts';
38
import { runAndroidAdb } from '../platforms/android/adb.ts';
49
import { emitDiagnostic } from '../utils/diagnostics.ts';
10+
import { AppError } from '../utils/errors.ts';
511
import { centerOfRect, attachRefs, type SnapshotNode } from '../utils/snapshot.ts';
612
import { sleep } from '../utils/timeouts.ts';
713
import { pruneGroupNodes } from './snapshot-processing.ts';
814
import type { SessionState } from './types.ts';
915

10-
const ANDROID_BLOCKING_MODAL_PATTERN = /\bis(?:n't| not)\s+responding\b/i;
16+
const ANDROID_BLOCKING_MODAL_PATTERN = /\bis(?:n(?:'|&apos;|&#39;)?t| not)\s+responding\b/i;
1117
const ANDROID_CLOSE_APP_PATTERN = /^close app$/i;
1218
const ANDROID_MODAL_POLL_MS = 500;
1319
const ANDROID_MODAL_POLL_ATTEMPTS = 12;
20+
const ANDROID_BLOCKING_DIALOG_HINT =
21+
'Wait for Android to recover, close the dialog, restart the app, or reboot the emulator, then retry.';
1422

1523
export type AndroidBlockingDialogRecoveryResult = 'absent' | 'recovered' | 'failed';
24+
export type AndroidBlockingDialogReadinessResult =
25+
| { status: 'clear' }
26+
| { status: 'recovered'; warning: string };
27+
type AndroidDialogButtonTapResult =
28+
| { ok: true; x: number; y: number }
29+
| {
30+
ok: false;
31+
exitCode: number;
32+
stdout: string;
33+
stderr: string;
34+
};
1635

1736
export async function recoverAndroidBlockingSystemDialog(params: {
1837
session: SessionState;
@@ -30,13 +49,8 @@ export async function recoverAndroidBlockingSystemDialog(params: {
3049
return 'absent';
3150
}
3251

33-
const { x, y } = centerOfRect(closeAppButton.rect);
34-
const tapResult = await runAndroidAdb(
35-
session.device,
36-
['shell', 'input', 'tap', String(Math.round(x)), String(Math.round(y))],
37-
{ allowFailure: true },
38-
);
39-
if (tapResult.exitCode !== 0) {
52+
const tapResult = await tapAndroidDialogButton(session, closeAppButton);
53+
if (!tapResult.ok) {
4054
emitDiagnostic({
4155
level: 'warn',
4256
phase: 'android_blocking_dialog_tap_failed',
@@ -66,7 +80,7 @@ export async function recoverAndroidBlockingSystemDialog(params: {
6680

6781
if (session.appBundleId) {
6882
await openAndroidApp(session.device, session.appBundleId);
69-
const focused = await waitForFocusedAndroidApp(session, session.appBundleId);
83+
const focused = await waitForAndroidAppFocus(session, session.appBundleId);
7084
if (!focused) {
7185
emitDiagnostic({
7286
level: 'warn',
@@ -88,8 +102,8 @@ export async function recoverAndroidBlockingSystemDialog(params: {
88102
session: session.name,
89103
deviceId: session.device.id,
90104
appBundleId: session.appBundleId,
91-
x,
92-
y,
105+
x: tapResult.x,
106+
y: tapResult.y,
93107
},
94108
});
95109
return 'recovered';
@@ -107,6 +121,126 @@ export async function recoverAndroidBlockingSystemDialog(params: {
107121
}
108122
}
109123

124+
export async function ensureAndroidBlockingSystemDialogReady(params: {
125+
session: SessionState;
126+
command: string;
127+
phase: 'before-command' | 'after-command';
128+
}): Promise<AndroidBlockingDialogReadinessResult> {
129+
const { session, command } = params;
130+
if (session.device.platform !== 'android') return { status: 'clear' };
131+
132+
const focus = await getAndroidBlockingDialogFocus(session.device);
133+
if (!focus) return { status: 'clear' };
134+
135+
if (isSessionAppAnr(session, focus)) {
136+
const recovered = await recoverAppOwnedAndroidBlockingSystemDialogSafely(session);
137+
if (recovered) {
138+
const warning = `Recovered Android app ANR before ${command}: closed and relaunched ${session.appBundleId}.`;
139+
if (params.phase === 'before-command') return { status: 'recovered', warning };
140+
141+
throw androidBlockingDialogError({
142+
session,
143+
command,
144+
focus,
145+
message: `Android app ANR appeared after ${command}; ${session.appBundleId} was closed and relaunched. Retry the command against the fresh app session.`,
146+
hint: 'Retry the command. If the ANR returns, inspect app logs or restart the emulator.',
147+
});
148+
}
149+
150+
throw androidBlockingDialogError({
151+
session,
152+
command,
153+
focus,
154+
message: `Android app ANR blocked ${command}: ${formatAndroidBlockingDialogFocus(focus)}. Automatic recovery failed.`,
155+
hint: ANDROID_BLOCKING_DIALOG_HINT,
156+
});
157+
}
158+
159+
throw androidBlockingDialogError({
160+
session,
161+
command,
162+
focus,
163+
message: `Android system dialog is blocking ${command}: ${formatAndroidBlockingDialogFocus(focus)}.`,
164+
hint: ANDROID_BLOCKING_DIALOG_HINT,
165+
});
166+
}
167+
168+
async function recoverAppOwnedAndroidBlockingSystemDialogSafely(
169+
session: SessionState,
170+
): Promise<boolean> {
171+
try {
172+
return await recoverAppOwnedAndroidBlockingSystemDialog(session);
173+
} catch (error) {
174+
emitDiagnostic({
175+
level: 'warn',
176+
phase: 'android_app_anr_recovery_failed',
177+
data: {
178+
session: session.name,
179+
deviceId: session.device.id,
180+
appBundleId: session.appBundleId,
181+
error: error instanceof Error ? error.message : String(error),
182+
},
183+
});
184+
return false;
185+
}
186+
}
187+
188+
function isSessionAppAnr(session: SessionState, focus: AndroidBlockingDialogFocus): boolean {
189+
return Boolean(session.appBundleId && focus.package === session.appBundleId);
190+
}
191+
192+
async function recoverAppOwnedAndroidBlockingSystemDialog(session: SessionState): Promise<boolean> {
193+
if (!session.appBundleId) return false;
194+
195+
const nodes = await readAndroidSnapshotNodes(session);
196+
const closeAppButton = findCloseAppButton(nodes, { requireDialogSignal: false });
197+
if (!closeAppButton?.rect) return false;
198+
199+
const tapResult = await tapAndroidDialogButton(session, closeAppButton);
200+
if (!tapResult.ok) return false;
201+
202+
await openAndroidApp(session.device, session.appBundleId);
203+
const focused = await waitForAndroidAppFocus(session, session.appBundleId, {
204+
requireNoBlockingDialog: true,
205+
});
206+
if (focused) {
207+
emitDiagnostic({
208+
level: 'warn',
209+
phase: 'android_app_anr_recovered',
210+
data: {
211+
session: session.name,
212+
deviceId: session.device.id,
213+
appBundleId: session.appBundleId,
214+
x: tapResult.x,
215+
y: tapResult.y,
216+
},
217+
});
218+
}
219+
return focused;
220+
}
221+
222+
function androidBlockingDialogError(params: {
223+
session: SessionState;
224+
command: string;
225+
focus: AndroidBlockingDialogFocus;
226+
message: string;
227+
hint: string;
228+
}): AppError {
229+
const { session, command, focus, message, hint } = params;
230+
return new AppError('COMMAND_FAILED', message, {
231+
command,
232+
expectedPackage: session.appBundleId,
233+
focusedPackage: focus.package,
234+
focusedWindow: focus.focusedWindow,
235+
rawFocus: focus.raw,
236+
hint,
237+
});
238+
}
239+
240+
function formatAndroidBlockingDialogFocus(focus: AndroidBlockingDialogFocus): string {
241+
return focus.package ? `${focus.focusedWindow} (package ${focus.package})` : focus.focusedWindow;
242+
}
243+
110244
async function readAndroidSnapshotNodes(session: SessionState): Promise<SnapshotNode[]> {
111245
const rawSnapshot = await snapshotAndroid(session.device, {
112246
interactiveOnly: false,
@@ -115,13 +249,41 @@ async function readAndroidSnapshotNodes(session: SessionState): Promise<Snapshot
115249
return attachRefs(pruneGroupNodes(rawSnapshot.nodes));
116250
}
117251

118-
function findCloseAppButton(nodes: SnapshotNode[]): SnapshotNode | undefined {
119-
if (!containsBlockingDialog(nodes)) {
252+
async function tapAndroidDialogButton(
253+
session: SessionState,
254+
button: SnapshotNode,
255+
): Promise<AndroidDialogButtonTapResult> {
256+
if (!button.rect) {
257+
return { ok: false, exitCode: 1, stdout: '', stderr: 'button has no rect' };
258+
}
259+
const { x, y } = centerOfRect(button.rect);
260+
const result = await runAndroidAdb(
261+
session.device,
262+
['shell', 'input', 'tap', String(Math.round(x)), String(Math.round(y))],
263+
{ allowFailure: true },
264+
);
265+
if (result.exitCode !== 0) {
266+
return {
267+
ok: false,
268+
exitCode: result.exitCode,
269+
stdout: result.stdout.trim(),
270+
stderr: result.stderr.trim(),
271+
};
272+
}
273+
return { ok: true, x, y };
274+
}
275+
276+
function findCloseAppButton(
277+
nodes: SnapshotNode[],
278+
options: { requireDialogSignal?: boolean } = {},
279+
): SnapshotNode | undefined {
280+
if (options.requireDialogSignal !== false && !containsBlockingDialog(nodes)) {
120281
return undefined;
121282
}
122283
return nodes.find((node) => {
123-
const text = readNodeText(node);
124-
return text.length > 0 && ANDROID_CLOSE_APP_PATTERN.test(text) && node.rect;
284+
return (
285+
readNodeTextParts(node).some((text) => ANDROID_CLOSE_APP_PATTERN.test(text)) && node.rect
286+
);
125287
});
126288
}
127289

@@ -137,17 +299,28 @@ async function waitForBlockingDialogToDismiss(session: SessionState): Promise<bo
137299
return !containsBlockingDialog(nodes);
138300
}
139301

140-
async function waitForFocusedAndroidApp(
302+
async function waitForAndroidAppFocus(
141303
session: SessionState,
142304
appBundleId: string,
305+
options: { requireNoBlockingDialog?: boolean } = {},
143306
): Promise<boolean> {
144307
for (let attempt = 0; attempt < ANDROID_MODAL_POLL_ATTEMPTS; attempt += 1) {
145-
const state = await getAndroidAppState(session.device);
146-
if (state.package === appBundleId) {
308+
if (await isAndroidAppFocused(session, appBundleId, options)) {
147309
return true;
148310
}
149311
await sleep(ANDROID_MODAL_POLL_MS);
150312
}
313+
return await isAndroidAppFocused(session, appBundleId, options);
314+
}
315+
316+
async function isAndroidAppFocused(
317+
session: SessionState,
318+
appBundleId: string,
319+
options: { requireNoBlockingDialog?: boolean },
320+
): Promise<boolean> {
321+
if (options.requireNoBlockingDialog && (await getAndroidBlockingDialogFocus(session.device))) {
322+
return false;
323+
}
151324
const state = await getAndroidAppState(session.device);
152325
return state.package === appBundleId;
153326
}
@@ -157,14 +330,21 @@ function readNodeText(node: {
157330
value?: string | number | boolean | null;
158331
identifier?: string;
159332
}): string {
333+
return readNodeTextParts(node).join(' ').trim();
334+
}
335+
336+
function readNodeTextParts(node: {
337+
label?: string;
338+
value?: string | number | boolean | null;
339+
identifier?: string;
340+
}): string[] {
160341
const parts = [node.label, node.identifier];
161342
if (typeof node.value === 'string' && node.value.trim().length > 0) {
162343
parts.push(node.value);
163344
}
164345
return parts
165346
.filter((part): part is string => typeof part === 'string' && part.trim().length > 0)
166-
.join(' ')
167-
.trim();
347+
.map((part) => part.trim());
168348
}
169349

170350
function containsBlockingDialog(nodes: SnapshotNode[]): boolean {

src/daemon/handlers/__tests__/interaction.test.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ vi.mock('../../../platforms/android/app-lifecycle.ts', async (importOriginal) =>
4040
return {
4141
...actual,
4242
getAndroidAppState: vi.fn(async () => ({})),
43+
getAndroidBlockingDialogFocus: vi.fn(async () => null),
4344
};
4445
});
4546

@@ -64,11 +65,15 @@ vi.mock('../../../platforms/ios/runner-client.ts', async (importOriginal) => {
6465
});
6566

6667
import { dispatchCommand } from '../../../core/dispatch.ts';
67-
import { getAndroidAppState } from '../../../platforms/android/app-lifecycle.ts';
68+
import {
69+
getAndroidAppState,
70+
getAndroidBlockingDialogFocus,
71+
} from '../../../platforms/android/app-lifecycle.ts';
6872
import { getAndroidScreenSize } from '../../../platforms/android/input-actions.ts';
6973
import { captureSnapshotForSession } from '../interaction-snapshot.ts';
7074
const mockDispatch = vi.mocked(dispatchCommand);
7175
const mockGetAndroidAppState = vi.mocked(getAndroidAppState);
76+
const mockGetAndroidBlockingDialogFocus = vi.mocked(getAndroidBlockingDialogFocus);
7277
const mockGetAndroidScreenSize = vi.mocked(getAndroidScreenSize);
7378
const mockCaptureSnapshotForSession = vi.mocked(captureSnapshotForSession);
7479

@@ -132,6 +137,8 @@ beforeEach(() => {
132137
mockDispatch.mockResolvedValue({});
133138
mockGetAndroidAppState.mockReset();
134139
mockGetAndroidAppState.mockResolvedValue({});
140+
mockGetAndroidBlockingDialogFocus.mockReset();
141+
mockGetAndroidBlockingDialogFocus.mockResolvedValue(null);
135142
mockGetAndroidScreenSize.mockReset();
136143
mockGetAndroidScreenSize.mockResolvedValue({ width: 1344, height: 2992 });
137144
mockCaptureSnapshotForSession.mockReset();

0 commit comments

Comments
 (0)