Skip to content

Commit b1be819

Browse files
committed
fix: recover Android app-owned ANRs
1 parent bbe7c06 commit b1be819

12 files changed

Lines changed: 474 additions & 29 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: 170 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,29 @@
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 };
1627

1728
export async function recoverAndroidBlockingSystemDialog(params: {
1829
session: SessionState;
@@ -107,6 +118,146 @@ export async function recoverAndroidBlockingSystemDialog(params: {
107118
}
108119
}
109120

121+
export async function ensureAndroidBlockingSystemDialogReady(params: {
122+
session: SessionState;
123+
command: string;
124+
phase: 'before-command' | 'after-command';
125+
}): Promise<AndroidBlockingDialogReadinessResult> {
126+
const { session, command } = params;
127+
if (session.device.platform !== 'android') return { status: 'clear' };
128+
129+
const focus = await getAndroidBlockingDialogFocus(session.device);
130+
if (!focus) return { status: 'clear' };
131+
132+
if (isSessionAppAnr(session, focus)) {
133+
const recovered = await recoverAppOwnedAndroidBlockingSystemDialogSafely(session);
134+
if (recovered) {
135+
const warning = `Recovered Android app ANR before ${command}: closed and relaunched ${session.appBundleId}.`;
136+
if (params.phase === 'before-command') return { status: 'recovered', warning };
137+
138+
throw androidBlockingDialogError({
139+
session,
140+
command,
141+
focus,
142+
message: `Android app ANR appeared after ${command}; ${session.appBundleId} was closed and relaunched. Retry the command against the fresh app session.`,
143+
hint: 'Retry the command. If the ANR returns, inspect app logs or restart the emulator.',
144+
});
145+
}
146+
147+
throw androidBlockingDialogError({
148+
session,
149+
command,
150+
focus,
151+
message: `Android app ANR blocked ${command}: ${formatAndroidBlockingDialogFocus(focus)}. Automatic recovery failed.`,
152+
hint: ANDROID_BLOCKING_DIALOG_HINT,
153+
});
154+
}
155+
156+
throw androidBlockingDialogError({
157+
session,
158+
command,
159+
focus,
160+
message: `Android system dialog is blocking ${command}: ${formatAndroidBlockingDialogFocus(focus)}.`,
161+
hint: ANDROID_BLOCKING_DIALOG_HINT,
162+
});
163+
}
164+
165+
async function recoverAppOwnedAndroidBlockingSystemDialogSafely(
166+
session: SessionState,
167+
): Promise<boolean> {
168+
try {
169+
return await recoverAppOwnedAndroidBlockingSystemDialog(session);
170+
} catch (error) {
171+
emitDiagnostic({
172+
level: 'warn',
173+
phase: 'android_app_anr_recovery_failed',
174+
data: {
175+
session: session.name,
176+
deviceId: session.device.id,
177+
appBundleId: session.appBundleId,
178+
error: error instanceof Error ? error.message : String(error),
179+
},
180+
});
181+
return false;
182+
}
183+
}
184+
185+
function isSessionAppAnr(session: SessionState, focus: AndroidBlockingDialogFocus): boolean {
186+
return Boolean(session.appBundleId && focus.package === session.appBundleId);
187+
}
188+
189+
async function recoverAppOwnedAndroidBlockingSystemDialog(session: SessionState): Promise<boolean> {
190+
if (!session.appBundleId) return false;
191+
192+
const nodes = await readAndroidSnapshotNodes(session);
193+
const closeAppButton = findCloseAppButton(nodes, { requireDialogSignal: false });
194+
if (!closeAppButton?.rect) return false;
195+
196+
const { x, y } = centerOfRect(closeAppButton.rect);
197+
const tapResult = await runAndroidAdb(
198+
session.device,
199+
['shell', 'input', 'tap', String(Math.round(x)), String(Math.round(y))],
200+
{ allowFailure: true },
201+
);
202+
if (tapResult.exitCode !== 0) return false;
203+
204+
await openAndroidApp(session.device, session.appBundleId);
205+
const focused = await waitForRecoveredAndroidAppFocus(session, session.appBundleId);
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,
215+
y,
216+
},
217+
});
218+
}
219+
return focused;
220+
}
221+
222+
async function waitForRecoveredAndroidAppFocus(
223+
session: SessionState,
224+
appBundleId: string,
225+
): Promise<boolean> {
226+
for (let attempt = 0; attempt < ANDROID_MODAL_POLL_ATTEMPTS; attempt += 1) {
227+
const blockingFocus = await getAndroidBlockingDialogFocus(session.device);
228+
const state = await getAndroidAppState(session.device);
229+
if (!blockingFocus && state.package === appBundleId) {
230+
return true;
231+
}
232+
await sleep(ANDROID_MODAL_POLL_MS);
233+
}
234+
const blockingFocus = await getAndroidBlockingDialogFocus(session.device);
235+
const state = await getAndroidAppState(session.device);
236+
return !blockingFocus && state.package === appBundleId;
237+
}
238+
239+
function androidBlockingDialogError(params: {
240+
session: SessionState;
241+
command: string;
242+
focus: AndroidBlockingDialogFocus;
243+
message: string;
244+
hint: string;
245+
}): AppError {
246+
const { session, command, focus, message, hint } = params;
247+
return new AppError('COMMAND_FAILED', message, {
248+
command,
249+
expectedPackage: session.appBundleId,
250+
focusedPackage: focus.package,
251+
focusedWindow: focus.focusedWindow,
252+
rawFocus: focus.raw,
253+
hint,
254+
});
255+
}
256+
257+
function formatAndroidBlockingDialogFocus(focus: AndroidBlockingDialogFocus): string {
258+
return focus.package ? `${focus.focusedWindow} (package ${focus.package})` : focus.focusedWindow;
259+
}
260+
110261
async function readAndroidSnapshotNodes(session: SessionState): Promise<SnapshotNode[]> {
111262
const rawSnapshot = await snapshotAndroid(session.device, {
112263
interactiveOnly: false,
@@ -115,13 +266,17 @@ async function readAndroidSnapshotNodes(session: SessionState): Promise<Snapshot
115266
return attachRefs(pruneGroupNodes(rawSnapshot.nodes));
116267
}
117268

118-
function findCloseAppButton(nodes: SnapshotNode[]): SnapshotNode | undefined {
119-
if (!containsBlockingDialog(nodes)) {
269+
function findCloseAppButton(
270+
nodes: SnapshotNode[],
271+
options: { requireDialogSignal?: boolean } = {},
272+
): SnapshotNode | undefined {
273+
if (options.requireDialogSignal !== false && !containsBlockingDialog(nodes)) {
120274
return undefined;
121275
}
122276
return nodes.find((node) => {
123-
const text = readNodeText(node);
124-
return text.length > 0 && ANDROID_CLOSE_APP_PATTERN.test(text) && node.rect;
277+
return (
278+
readNodeTextParts(node).some((text) => ANDROID_CLOSE_APP_PATTERN.test(text)) && node.rect
279+
);
125280
});
126281
}
127282

@@ -157,14 +312,21 @@ function readNodeText(node: {
157312
value?: string | number | boolean | null;
158313
identifier?: string;
159314
}): string {
315+
return readNodeTextParts(node).join(' ').trim();
316+
}
317+
318+
function readNodeTextParts(node: {
319+
label?: string;
320+
value?: string | number | boolean | null;
321+
identifier?: string;
322+
}): string[] {
160323
const parts = [node.label, node.identifier];
161324
if (typeof node.value === 'string' && node.value.trim().length > 0) {
162325
parts.push(node.value);
163326
}
164327
return parts
165328
.filter((part): part is string => typeof part === 'string' && part.trim().length > 0)
166-
.join(' ')
167-
.trim();
329+
.map((part) => part.trim());
168330
}
169331

170332
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();

src/daemon/handlers/interaction-touch.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ import {
4646
readSimpleIosSelectorTarget,
4747
type DirectIosSelectorTarget,
4848
} from '../direct-ios-selector.ts';
49+
import { ensureAndroidBlockingSystemDialogReady } from '../android-system-dialog.ts';
4950

5051
export async function handleTouchInteractionCommands(
5152
params: InteractionHandlerParams & {
@@ -429,10 +430,24 @@ async function dispatchRuntimeInteraction<
429430
const runtime = createInteractionRuntime(params);
430431
const actionStartedAt = Date.now();
431432
try {
433+
const readiness = await ensureAndroidBlockingSystemDialogReady({
434+
session,
435+
command: params.req.command,
436+
phase: 'before-command',
437+
});
432438
const runtimeResult = await options.run(runtime);
433439
await options.afterRun?.(runtimeResult);
440+
await ensureAndroidBlockingSystemDialogReady({
441+
session,
442+
command: params.req.command,
443+
phase: 'after-command',
444+
});
434445
const actionFinishedAt = Date.now();
435446
const { result, responseData } = await options.buildPayloads(runtimeResult);
447+
if (readiness.status === 'recovered') {
448+
result.warning = readiness.warning;
449+
responseData.warning = readiness.warning;
450+
}
436451
return finalizeTouchInteraction({
437452
session,
438453
sessionStore: params.sessionStore,

src/daemon/handlers/interaction.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,10 @@ import { isCommandSupportedOnDevice } from '../../core/capabilities.ts';
1111
import { typeCommandDefinition } from '../../commands/interactions/definition.ts';
1212
import { normalizeError } from '../../utils/errors.ts';
1313
import { successText } from '../../utils/success-text.ts';
14-
import { recoverAndroidBlockingSystemDialog } from '../android-system-dialog.ts';
14+
import {
15+
ensureAndroidBlockingSystemDialogReady,
16+
recoverAndroidBlockingSystemDialog,
17+
} from '../android-system-dialog.ts';
1518

1619
export async function handleInteractionCommands(
1720
params: InteractionHandlerParams,
@@ -62,18 +65,29 @@ async function dispatchTypeViaRuntime(
6265
const runtime = createInteractionRuntime(params);
6366
const actionStartedAt = Date.now();
6467
try {
68+
const readiness = await ensureAndroidBlockingSystemDialogReady({
69+
session,
70+
command: req.command,
71+
phase: 'before-command',
72+
});
6573
const result = await runtime.interactions.typeText(text, {
6674
session: sessionName,
6775
requestId: req.meta?.requestId,
6876
delayMs: req.flags?.delayMs,
6977
});
78+
await ensureAndroidBlockingSystemDialogReady({
79+
session,
80+
command: req.command,
81+
phase: 'after-command',
82+
});
7083
const actionFinishedAt = Date.now();
71-
const responseData = {
84+
const responseData: Record<string, unknown> = {
7285
...(result.backendResult ?? {}),
7386
text: result.text,
7487
delayMs: result.delayMs,
7588
...successText(result.message ?? `Typed ${Array.from(result.text).length} chars`),
7689
};
90+
if (readiness.status === 'recovered') responseData.warning = readiness.warning;
7791
return finalizeTouchInteraction({
7892
session,
7993
sessionStore,

0 commit comments

Comments
 (0)