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' ;
27import { snapshotAndroid } from '../platforms/android/snapshot.ts' ;
38import { runAndroidAdb } from '../platforms/android/adb.ts' ;
49import { emitDiagnostic } from '../utils/diagnostics.ts' ;
10+ import { AppError } from '../utils/errors.ts' ;
511import { centerOfRect , attachRefs , type SnapshotNode } from '../utils/snapshot.ts' ;
612import { sleep } from '../utils/timeouts.ts' ;
713import { pruneGroupNodes } from './snapshot-processing.ts' ;
814import type { SessionState } from './types.ts' ;
915
10- const ANDROID_BLOCKING_MODAL_PATTERN = / \b i s (?: n ' t | n o t ) \s + r e s p o n d i n g \b / i;
16+ const ANDROID_BLOCKING_MODAL_PATTERN = / \b i s (?: n (?: ' | & a p o s ; | & # 3 9 ; ) ? t | n o t ) \s + r e s p o n d i n g \b / i;
1117const ANDROID_CLOSE_APP_PATTERN = / ^ c l o s e a p p $ / i;
1218const ANDROID_MODAL_POLL_MS = 500 ;
1319const 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
1523export 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
1736export 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+
110244async 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
170350function containsBlockingDialog ( nodes : SnapshotNode [ ] ) : boolean {
0 commit comments