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 } ;
1627
1728export 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+
110261async 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
170332function containsBlockingDialog ( nodes : SnapshotNode [ ] ) : boolean {
0 commit comments