1+ import { spawn } from 'node:child_process' ;
2+ import type { ChildProcess } from 'node:child_process' ;
3+
14import type {
25 DeviceBackend ,
36 DeviceButton ,
@@ -9,7 +12,9 @@ import type {
912 AppStateResult ,
1013 ElementQuery ,
1114 UIElement ,
15+ WindowSize ,
1216} from './types.js' ;
17+ import { ACCEPT_LABELS , DENY_LABELS } from '../utils/alert-labels.js' ;
1318import {
1419 findElement ,
1520 describeElement ,
@@ -22,6 +27,12 @@ export class AdbBackend implements DeviceBackend {
2227
2328 readonly #serial: string ;
2429
30+ #recordingProcess: ChildProcess | null = null ;
31+
32+ #recordingPath: string | null = null ;
33+
34+ readonly #recordingRemotePath = '/sdcard/device-mcp-recording.mp4' ;
35+
2536 constructor ( serial : string ) {
2637 this . #serial = serial ;
2738 }
@@ -97,6 +108,17 @@ export class AdbBackend implements DeviceBackend {
97108 }
98109
99110 async typeText ( text : string ) : Promise < void > {
111+ // Android keyevent 29+shift = Ctrl+A (select all), 67 = DEL
112+ await this . #adb( [ 'shell' , 'input' , 'keyevent' , 'KEYCODE_MOVE_HOME' ] ) ;
113+ await this . #adb( [
114+ 'shell' ,
115+ 'input' ,
116+ 'keyevent' ,
117+ '--longpress' ,
118+ 'KEYCODE_SHIFT_LEFT' ,
119+ 'KEYCODE_MOVE_END' ,
120+ ] ) ;
121+ await this . #adb( [ 'shell' , 'input' , 'keyevent' , '67' ] ) ;
100122 // Android input text requires escaping spaces and special chars
101123 const escaped = text . replace ( / / gu, '%s' ) . replace ( / [ & | ; < > ] / gu, '\\$&' ) ;
102124 await this . #adb( [ 'shell' , 'input' , 'text' , escaped ] ) ;
@@ -230,23 +252,20 @@ export class AdbBackend implements DeviceBackend {
230252 }
231253
232254 async dismissAlert ( accept : boolean ) : Promise < void > {
233- const snapshot = await this . snapshot ( ) ;
234- const buttonQuery = accept ? { text : 'Allow' } : { text : 'Deny' } ;
235- const fallbackQuery = accept ? { text : 'OK' } : { text : 'Cancel' } ;
236-
237- const element =
238- findElement ( snapshot . hierarchy , buttonQuery ) ||
239- findElement ( snapshot . hierarchy , fallbackQuery ) ;
240-
241- if ( element ) {
242- const cx = Math . round ( element . frame . x + element . frame . width / 2 ) ;
243- const cy = Math . round ( element . frame . y + element . frame . height / 2 ) ;
244- await this . tapCoordinates ( cx , cy ) ;
245- } else {
246- throw new Error (
247- `No alert button found. Looked for "${ buttonQuery . text } " and "${ fallbackQuery . text } "` ,
248- ) ;
255+ const snap = await this . snapshot ( ) ;
256+ const candidates = accept ? ACCEPT_LABELS : DENY_LABELS ;
257+
258+ for ( const label of candidates ) {
259+ const element = findElement ( snap . hierarchy , { text : label } ) ;
260+ if ( element ) {
261+ const cx = Math . round ( element . frame . x + element . frame . width / 2 ) ;
262+ const cy = Math . round ( element . frame . y + element . frame . height / 2 ) ;
263+ await this . tapCoordinates ( cx , cy ) ;
264+ return ;
265+ }
249266 }
267+
268+ throw new Error ( `No alert button found. Tried: ${ candidates . join ( ', ' ) } ` ) ;
250269 }
251270
252271 async getLogs ( durationSeconds = 30 , filter ?: string ) : Promise < LogsResult > {
@@ -303,6 +322,152 @@ export class AdbBackend implements DeviceBackend {
303322 targetDescription : describeElement ( element ) ,
304323 } ;
305324 }
325+
326+ async scrollToElement (
327+ query : ElementQuery ,
328+ direction : 'up' | 'down' = 'down' ,
329+ maxAttempts = 10 ,
330+ ) : Promise < UIElement > {
331+ let previousRaw = '' ;
332+
333+ for ( let i = 0 ; i < maxAttempts ; i ++ ) {
334+ const snap = await this . snapshot ( ) ;
335+ const element = findElement ( snap . hierarchy , query ) ;
336+ if ( element ) {
337+ return element ;
338+ }
339+ if ( snap . raw === previousRaw ) {
340+ break ;
341+ }
342+ previousRaw = snap . raw ;
343+ await this . swipe ( direction ) ;
344+ }
345+ throw new Error (
346+ `Element not found after scrolling: ${ JSON . stringify ( query ) } \n` +
347+ 'Use device_snapshot to inspect the current UI hierarchy.' ,
348+ ) ;
349+ }
350+
351+ async getAlertText ( ) : Promise < string > {
352+ const snap = await this . snapshot ( ) ;
353+ const texts = collectAndroidAlertTexts ( snap . hierarchy ) ;
354+ if ( texts . length === 0 ) {
355+ throw new Error (
356+ 'No alert is currently displayed.\n' +
357+ 'Use device_snapshot to inspect the current UI hierarchy.' ,
358+ ) ;
359+ }
360+ return texts . join ( '\n' ) ;
361+ }
362+
363+ async getWindowSize ( ) : Promise < WindowSize > {
364+ const raw = await this . #adb( [ 'shell' , 'wm' , 'size' ] ) ;
365+ const match = raw . match ( / ( \d + ) x ( \d + ) / u) ;
366+ if ( match ) {
367+ return {
368+ width : parseInt ( match [ 1 ] , 10 ) ,
369+ height : parseInt ( match [ 2 ] , 10 ) ,
370+ } ;
371+ }
372+ throw new Error ( `Unable to parse window size from: ${ raw . trim ( ) } ` ) ;
373+ }
374+
375+ async getContexts ( ) : Promise < string [ ] > {
376+ return [ 'NATIVE_APP' ] ;
377+ }
378+
379+ async setContext ( context : string ) : Promise < void > {
380+ if ( context === 'NATIVE_APP' ) {
381+ return ;
382+ }
383+ throw new Error (
384+ 'Context switching requires Appium backend. ADB only supports NATIVE_APP.' ,
385+ ) ;
386+ }
387+
388+ async getClipboard ( ) : Promise < string > {
389+ throw new Error (
390+ 'Clipboard access via ADB is not supported on modern Android. ' +
391+ 'Use the Appium backend for clipboard operations.' ,
392+ ) ;
393+ }
394+
395+ async setClipboard ( _text : string ) : Promise < void > {
396+ throw new Error (
397+ 'Clipboard access via ADB is not supported on modern Android. ' +
398+ 'Use the Appium backend for clipboard operations.' ,
399+ ) ;
400+ }
401+
402+ async startScreenRecording ( outputPath ?: string ) : Promise < void > {
403+ if ( this . #recordingProcess) {
404+ throw new Error ( 'Screen recording is already in progress' ) ;
405+ }
406+ this . #recordingPath =
407+ outputPath ?? `/tmp/device-mcp-recording-${ Date . now ( ) } .mp4` ;
408+ this . #recordingProcess = spawn ( 'adb' , [
409+ '-s' ,
410+ this . #serial,
411+ 'shell' ,
412+ 'screenrecord' ,
413+ this . #recordingRemotePath,
414+ ] ) ;
415+ this . #recordingProcess. on ( 'error' , ( ) => {
416+ this . #recordingProcess = null ;
417+ this . #recordingPath = null ;
418+ } ) ;
419+ }
420+
421+ async stopScreenRecording ( ) : Promise < string > {
422+ if ( ! this . #recordingProcess || ! this . #recordingPath) {
423+ throw new Error ( 'No screen recording in progress' ) ;
424+ }
425+ const localPath = this . #recordingPath;
426+ this . #recordingProcess. kill ( 'SIGINT' ) ;
427+ await new Promise ( ( resolve ) => setTimeout ( resolve , 2000 ) ) ;
428+ this . #recordingProcess = null ;
429+ this . #recordingPath = null ;
430+
431+ await execStrict ( 'adb' , [
432+ '-s' ,
433+ this . #serial,
434+ 'pull' ,
435+ this . #recordingRemotePath,
436+ localPath ,
437+ ] ) ;
438+ await this . #adb( [ 'shell' , 'rm' , '-f' , this . #recordingRemotePath] ) ;
439+ return localPath ;
440+ }
441+ }
442+
443+ function collectAndroidAlertTexts ( elements : UIElement [ ] ) : string [ ] {
444+ const texts : string [ ] = [ ] ;
445+ for ( const el of elements ) {
446+ const isDialog =
447+ el . type . includes ( 'Dialog' ) || el . type . includes ( 'AlertDialog' ) ;
448+ if ( isDialog ) {
449+ collectTextValues ( el . children ?? [ ] , texts ) ;
450+ return texts ;
451+ }
452+ if ( el . children ) {
453+ const found = collectAndroidAlertTexts ( el . children ) ;
454+ if ( found . length > 0 ) {
455+ return found ;
456+ }
457+ }
458+ }
459+ return texts ;
460+ }
461+
462+ function collectTextValues ( elements : UIElement [ ] , texts : string [ ] ) : void {
463+ for ( const el of elements ) {
464+ if ( el . type . includes ( 'TextView' ) && el . value ) {
465+ texts . push ( el . value ) ;
466+ }
467+ if ( el . children ) {
468+ collectTextValues ( el . children , texts ) ;
469+ }
470+ }
306471}
307472
308473export function parseAndroidHierarchy ( xml : string ) : UIElement [ ] {
@@ -319,14 +484,7 @@ export function parseAndroidHierarchy(xml: string): UIElement[] {
319484
320485 if ( isClosing ) {
321486 if ( stack . length > 1 ) {
322- const children = stack . pop ( ) ;
323- if ( children ?. length === 0 ) {
324- const parent = stack [ stack . length - 1 ] ;
325- const lastEl = parent [ parent . length - 1 ] ;
326- if ( lastEl ) {
327- delete lastEl . children ;
328- }
329- }
487+ stack . pop ( ) ;
330488 }
331489 } else if ( isSelfClosing && attrs ) {
332490 const element = parseNodeAttributes ( attrs ) ;
0 commit comments