Skip to main content

allwright/
client_mobile.rs

1use std::sync::atomic::{AtomicU64, Ordering};
2use std::sync::{Arc, Mutex};
3use std::{
4    fs,
5    io::{Read, Write},
6    marker::PhantomData,
7    path::Path,
8};
9
10use crate::proto::context_session_command::Command as ContextCommand;
11use crate::proto::context_session_event::Event as ContextEvent;
12use crate::proto::hook_completed_event::Result as HookCompletionResult;
13use crate::proto::register_hook_command::Hook as RegisterHook;
14use crate::proto::surface_session_command::Command as SurfaceCommand;
15use crate::proto::surface_session_event::Event as SurfaceEvent;
16use crate::proto::{
17    AccessibilitySnapshotCommand, AppLaunchedEvent, ClickElementCommand, ConnectMobileCommand,
18    ContextSessionCommand, CountElementsCommand, FillElementCommand, FocusElementCommand,
19    GetInnerTextCommand, GetTextContentCommand, LaunchAppCommand, MobileConnectedEvent,
20    MobilePlatform as ProtoMobilePlatform, PressKeyCommand, ReadFileChunkCommand,
21    RegisterHookCommand, RegisterMobileDownloadHook, RegisterMobileFileChooserHook,
22    SaveMobileDownloadCommand, ScreenshotCommand, SetMobileFileChooserFilesCommand,
23    SurfaceSessionCommand, UploadFileChunkCommand, WaitForHookCommand, WaitForSelectorCommand,
24};
25
26use super::hook::{DownloadHook, FileChooserHook};
27
28static MOBILE_TRANSFER_COUNTER: AtomicU64 = AtomicU64::new(1);
29
30pub trait AndroidHookType: private::Sealed + Clone + Send + Sync + 'static {
31    type Output;
32}
33
34mod private {
35    use super::*;
36    pub trait Sealed {
37        fn name() -> &'static str;
38        fn decode(
39            app: &AndroidApp,
40            result: HookCompletionResult,
41        ) -> Result<<Self as AndroidHookType>::Output>
42        where
43            Self: AndroidHookType;
44    }
45}
46
47impl AndroidHookType for FileChooserHook {
48    type Output = AndroidFileChooser;
49}
50impl private::Sealed for FileChooserHook {
51    fn name() -> &'static str {
52        "file_chooser"
53    }
54    fn decode(app: &AndroidApp, result: HookCompletionResult) -> Result<AndroidFileChooser> {
55        let HookCompletionResult::MobileFileChooser(result) = result else {
56            return Err(Error::new(
57                "Android file chooser hook returned an invalid result",
58            ));
59        };
60        Ok(AndroidFileChooser {
61            app: app.clone(),
62            id: result.file_chooser_id,
63            is_multiple: result.is_multiple,
64        })
65    }
66}
67impl AndroidHookType for DownloadHook {
68    type Output = AndroidDownload;
69}
70impl private::Sealed for DownloadHook {
71    fn name() -> &'static str {
72        "download"
73    }
74    fn decode(app: &AndroidApp, result: HookCompletionResult) -> Result<AndroidDownload> {
75        let HookCompletionResult::MobileDownload(result) = result else {
76            return Err(Error::new(
77                "Android download hook returned an invalid result",
78            ));
79        };
80        Ok(AndroidDownload {
81            app: app.clone(),
82            id: result.download_id,
83            suggested_filename: result.suggested_filename,
84        })
85    }
86}
87
88pub struct AndroidHook<T: AndroidHookType> {
89    app: AndroidApp,
90    id: String,
91    _type: PhantomData<T>,
92}
93
94#[derive(Clone)]
95pub struct AndroidFileChooser {
96    app: AndroidApp,
97    id: String,
98    is_multiple: bool,
99}
100
101#[derive(Clone)]
102pub struct AndroidDownload {
103    app: AndroidApp,
104    id: String,
105    suggested_filename: String,
106}
107use tokio::sync::{Mutex as AsyncMutex, mpsc};
108use tokio_stream::wrappers::ReceiverStream;
109
110use super::command::command_retry_options;
111use super::runtime::get_runtime;
112use super::types::{
113    AccessibilitySnapshotFormat, AccessibilitySnapshotMode, AccessibilitySnapshotOptions,
114    ClickResult, CommandOptions, CountResult, ElementResult, Error, FillResult, PressOptions,
115    PressResult, Result, RuntimeClient, ScreenshotOptions, ScreenshotResult, TextResult,
116    WaitForSelectorOptions, WaitForSelectorResult,
117};
118
119#[derive(Debug, Clone, Default)]
120pub struct MobileAndroidConnectOptions {
121    pub device: Option<String>,
122    pub adb_endpoint: Option<String>,
123    pub preserve_app_state: bool,
124    pub timeout_ms: Option<u32>,
125}
126
127#[derive(Debug, Clone, Default)]
128pub struct MobileAndroidLaunchOptions {
129    pub apk_path: Option<String>,
130    pub app_id: Option<String>,
131    pub launch_activity: Option<String>,
132    pub stop_before_launch: bool,
133    pub timeout_ms: Option<u32>,
134}
135
136#[derive(Clone)]
137pub struct AndroidLocator {
138    page: AndroidApp,
139    selector: String,
140}
141
142#[derive(Clone)]
143pub struct AndroidApp {
144    inner: Arc<AndroidAppInner>,
145}
146
147#[derive(Clone)]
148pub struct AndroidDevice {
149    inner: Arc<AndroidDeviceInner>,
150}
151
152struct AndroidDeviceInner {
153    runtime: Arc<RuntimeClient>,
154    state: AsyncMutex<AndroidDeviceState>,
155    session_id: String,
156    initial_app: AndroidApp,
157    current_app: Mutex<AndroidApp>,
158}
159
160struct AndroidDeviceState {
161    command_tx: mpsc::Sender<SurfaceSessionCommand>,
162    events: tonic::Streaming<crate::proto::SurfaceSessionEvent>,
163    closed: bool,
164}
165
166struct AndroidAppInner {
167    runtime: Arc<RuntimeClient>,
168    surface_session_id: String,
169    session_id: String,
170    state: AsyncMutex<AndroidAppState>,
171}
172
173#[derive(Default)]
174struct AndroidAppState {
175    handle: Option<AndroidTabHandle>,
176}
177
178struct AndroidTabHandle {
179    command_tx: mpsc::Sender<crate::proto::ContextSessionCommand>,
180    events: tonic::Streaming<crate::proto::ContextSessionEvent>,
181    closed: bool,
182}
183
184pub mod android {
185    use super::*;
186
187    pub async fn connect(options: MobileAndroidConnectOptions) -> Result<AndroidDevice> {
188        let runtime = get_runtime().await?;
189        let mut engine = runtime.engine.clone();
190        let (command_tx, command_rx) = mpsc::channel(16);
191        let response = engine
192            .surface_session(tonic::Request::new(ReceiverStream::new(command_rx)))
193            .await?;
194        let mut events = response.into_inner();
195
196        command_tx
197            .send(SurfaceSessionCommand {
198                command: Some(SurfaceCommand::ConnectMobile(ConnectMobileCommand {
199                    platform: ProtoMobilePlatform::Android as i32,
200                    device: options.device,
201                    adb_endpoint: options.adb_endpoint,
202                    preserve_app_state: options.preserve_app_state,
203                    retry_options: command_retry_options(options.timeout_ms),
204                })),
205            })
206            .await
207            .map_err(|_| Error::new("failed to send ConnectMobileCommand"))?;
208
209        loop {
210            let event = events.message().await?.ok_or_else(|| {
211                Error::new("surface session closed before mobile connect response")
212            })?;
213
214            match event.event {
215                Some(SurfaceEvent::MobileConnected(MobileConnectedEvent {
216                    initial_app_session_id,
217                    device_session_id,
218                    ..
219                })) => {
220                    let initial_app = AndroidApp {
221                        inner: Arc::new(AndroidAppInner {
222                            runtime: Arc::clone(&runtime),
223                            surface_session_id: event.session_id.clone(),
224                            session_id: initial_app_session_id,
225                            state: AsyncMutex::new(AndroidAppState::default()),
226                        }),
227                    };
228                    return Ok(AndroidDevice {
229                        inner: Arc::new(AndroidDeviceInner {
230                            runtime,
231                            state: AsyncMutex::new(AndroidDeviceState {
232                                command_tx,
233                                events,
234                                closed: false,
235                            }),
236                            session_id: if device_session_id.is_empty() {
237                                event.session_id
238                            } else {
239                                device_session_id
240                            },
241                            initial_app: initial_app.clone(),
242                            current_app: Mutex::new(initial_app),
243                        }),
244                    });
245                }
246                Some(SurfaceEvent::Error(error)) => {
247                    return Err(Error::new(format!(
248                        "surface session error during mobile connect: {}",
249                        error.message
250                    )));
251                }
252                _ => {}
253            }
254        }
255    }
256}
257
258impl AndroidDevice {
259    pub fn session_id(&self) -> &str {
260        &self.inner.session_id
261    }
262
263    pub fn app(&self) -> AndroidApp {
264        self.inner
265            .current_app
266            .lock()
267            .map(|app| app.clone())
268            .unwrap_or_else(|_| self.inner.initial_app.clone())
269    }
270
271    pub fn initial_app(&self) -> AndroidApp {
272        self.inner.initial_app.clone()
273    }
274
275    pub async fn launch(&self, options: MobileAndroidLaunchOptions) -> Result<AndroidApp> {
276        let mut state = self.inner.state.lock().await;
277        ensure_android_device_open(&state, &self.inner.session_id)?;
278
279        state
280            .command_tx
281            .send(SurfaceSessionCommand {
282                command: Some(SurfaceCommand::LaunchApp(LaunchAppCommand {
283                    apk_path: options.apk_path,
284                    app_id: options.app_id,
285                    launch_activity: options.launch_activity,
286                    stop_before_launch: options.stop_before_launch,
287                    retry_options: command_retry_options(options.timeout_ms),
288                })),
289            })
290            .await
291            .map_err(|_| Error::new("failed to send LaunchAppCommand"))?;
292
293        loop {
294            let event =
295                state.events.message().await?.ok_or_else(|| {
296                    Error::new("surface session closed before app launch response")
297                })?;
298
299            match event.event {
300                Some(SurfaceEvent::AppLaunched(AppLaunchedEvent { app_session_id, .. })) => {
301                    let app = AndroidApp {
302                        inner: Arc::new(AndroidAppInner {
303                            runtime: Arc::clone(&self.inner.runtime),
304                            surface_session_id: event.session_id,
305                            session_id: app_session_id,
306                            state: AsyncMutex::new(AndroidAppState::default()),
307                        }),
308                    };
309                    if let Ok(mut current_app) = self.inner.current_app.lock() {
310                        *current_app = app.clone();
311                    }
312                    return Ok(app);
313                }
314                Some(SurfaceEvent::Error(error)) => {
315                    return Err(Error::new(format!(
316                        "surface session error while launching Android app: {}",
317                        error.message
318                    )));
319                }
320                Some(SurfaceEvent::Closed(_)) => {
321                    state.closed = true;
322                    return Err(Error::new(
323                        "surface session closed while waiting for Android app launch",
324                    ));
325                }
326                _ => {}
327            }
328        }
329    }
330}
331
332impl AndroidApp {
333    pub fn session_id(&self) -> &str {
334        &self.inner.session_id
335    }
336
337    pub async fn register_hook<T: AndroidHookType>(&self, _hook_type: T) -> Result<AndroidHook<T>> {
338        let mut state = self.inner.state.lock().await;
339        let handle = self.ensure_handle(&mut state).await?;
340        ensure_android_app_open(handle, &self.inner.session_id)?;
341        let hook = match T::name() {
342            "file_chooser" => Some(RegisterHook::MobileFileChooser(
343                RegisterMobileFileChooserHook {},
344            )),
345            "download" => Some(RegisterHook::MobileDownload(RegisterMobileDownloadHook {})),
346            _ => return Err(Error::new("hook type is not supported by Android apps")),
347        };
348        handle
349            .command_tx
350            .send(ContextSessionCommand {
351                surface_session_id: self.inner.surface_session_id.clone(),
352                context_session_id: self.inner.session_id.clone(),
353                command: Some(ContextCommand::RegisterHook(RegisterHookCommand { hook })),
354            })
355            .await
356            .map_err(|_| Error::new("failed to send Android RegisterHookCommand"))?;
357        loop {
358            let event =
359                handle.events.message().await?.ok_or_else(|| {
360                    Error::new("Android app session closed while registering hook")
361                })?;
362            match event.event {
363                Some(ContextEvent::HookRegistered(event)) => {
364                    return Ok(AndroidHook {
365                        app: self.clone(),
366                        id: event.hook_id,
367                        _type: PhantomData,
368                    });
369                }
370                Some(ContextEvent::Error(error)) => return Err(Error::new(error.message)),
371                _ => {}
372            }
373        }
374    }
375
376    pub fn locator(&self, selector: impl Into<String>) -> AndroidLocator {
377        AndroidLocator {
378            page: self.clone(),
379            selector: normalize_mobile_selector_for_transport(&selector.into()),
380        }
381    }
382
383    pub async fn click(&self, selector: &str, options: CommandOptions) -> Result<ClickResult> {
384        let selector = normalize_mobile_selector_for_transport(selector);
385        let mut state = self.inner.state.lock().await;
386        let handle = self.ensure_handle(&mut state).await?;
387        ensure_android_app_open(handle, &self.inner.session_id)?;
388
389        handle
390            .command_tx
391            .send(ContextSessionCommand {
392                surface_session_id: self.inner.surface_session_id.clone(),
393                context_session_id: self.inner.session_id.clone(),
394                command: Some(ContextCommand::ClickElement(ClickElementCommand {
395                    css_selector: selector.clone(),
396                    retry_options: command_retry_options(options.timeout_ms),
397                })),
398            })
399            .await
400            .map_err(|_| Error::new("failed to send ClickElementCommand"))?;
401
402        loop {
403            let event =
404                handle.events.message().await?.ok_or_else(|| {
405                    Error::new("app session closed while waiting for click result")
406                })?;
407
408            match event.event {
409                Some(ContextEvent::Attached(_)) => {}
410                Some(ContextEvent::ElementClicked(clicked)) => {
411                    return Ok(ClickResult {
412                        selector: clicked.css_selector,
413                        note: clicked.note,
414                        bidi_session_id: clicked.bidi_session_id,
415                    });
416                }
417                Some(ContextEvent::Error(error)) => {
418                    return Err(Error::new(format!(
419                        "app session error while clicking Android locator {:?}: {}",
420                        selector, error.message,
421                    )));
422                }
423                Some(ContextEvent::Closed(_)) => {
424                    handle.closed = true;
425                    return Err(Error::new(format!(
426                        "app session {} closed while waiting for click result",
427                        self.inner.session_id
428                    )));
429                }
430                _ => {}
431            }
432        }
433    }
434
435    pub async fn fill(
436        &self,
437        selector: &str,
438        value: &str,
439        options: CommandOptions,
440    ) -> Result<FillResult> {
441        let selector = normalize_mobile_selector_for_transport(selector);
442        let mut state = self.inner.state.lock().await;
443        let handle = self.ensure_handle(&mut state).await?;
444        ensure_android_app_open(handle, &self.inner.session_id)?;
445
446        handle
447            .command_tx
448            .send(ContextSessionCommand {
449                surface_session_id: self.inner.surface_session_id.clone(),
450                context_session_id: self.inner.session_id.clone(),
451                command: Some(ContextCommand::FillElement(FillElementCommand {
452                    css_selector: selector.clone(),
453                    value: value.to_string(),
454                    retry_options: command_retry_options(options.timeout_ms),
455                })),
456            })
457            .await
458            .map_err(|_| Error::new("failed to send FillElementCommand"))?;
459
460        loop {
461            let event =
462                handle.events.message().await?.ok_or_else(|| {
463                    Error::new("app session closed while waiting for fill result")
464                })?;
465
466            match event.event {
467                Some(ContextEvent::Attached(_)) => {}
468                Some(ContextEvent::ElementFilled(filled)) => {
469                    return Ok(FillResult {
470                        selector: filled.css_selector,
471                        value: filled.value,
472                        note: filled.note,
473                    });
474                }
475                Some(ContextEvent::Error(error)) => {
476                    return Err(Error::new(format!(
477                        "app session error while filling Android locator {:?}: {}",
478                        selector, error.message,
479                    )));
480                }
481                Some(ContextEvent::Closed(_)) => {
482                    handle.closed = true;
483                    return Err(Error::new(format!(
484                        "app session {} closed while waiting for fill result",
485                        self.inner.session_id
486                    )));
487                }
488                _ => {}
489            }
490        }
491    }
492
493    pub async fn count(&self, selector: &str, options: CommandOptions) -> Result<CountResult> {
494        let selector = normalize_mobile_selector_for_transport(selector);
495        let mut state = self.inner.state.lock().await;
496        let handle = self.ensure_handle(&mut state).await?;
497        ensure_android_app_open(handle, &self.inner.session_id)?;
498
499        handle
500            .command_tx
501            .send(ContextSessionCommand {
502                surface_session_id: self.inner.surface_session_id.clone(),
503                context_session_id: self.inner.session_id.clone(),
504                command: Some(ContextCommand::CountElements(CountElementsCommand {
505                    css_selector: selector.clone(),
506                    retry_options: command_retry_options(options.timeout_ms),
507                })),
508            })
509            .await
510            .map_err(|_| Error::new("failed to send CountElementsCommand"))?;
511
512        loop {
513            let event =
514                handle.events.message().await?.ok_or_else(|| {
515                    Error::new("app session closed while waiting for count result")
516                })?;
517
518            match event.event {
519                Some(ContextEvent::Attached(_)) => {}
520                Some(ContextEvent::ElementCounted(counted)) => {
521                    return Ok(CountResult {
522                        selector: counted.css_selector,
523                        count: counted.count,
524                        note: counted.note,
525                    });
526                }
527                Some(ContextEvent::Error(error)) => {
528                    return Err(Error::new(format!(
529                        "app session error while counting Android locator {:?}: {}",
530                        selector, error.message,
531                    )));
532                }
533                Some(ContextEvent::Closed(_)) => {
534                    handle.closed = true;
535                    return Err(Error::new(format!(
536                        "app session {} closed while waiting for count result",
537                        self.inner.session_id
538                    )));
539                }
540                _ => {}
541            }
542        }
543    }
544
545    pub async fn focus(&self, selector: &str, options: CommandOptions) -> Result<ElementResult> {
546        let selector = normalize_mobile_selector_for_transport(selector);
547        let mut state = self.inner.state.lock().await;
548        let handle = self.ensure_handle(&mut state).await?;
549        ensure_android_app_open(handle, &self.inner.session_id)?;
550
551        handle
552            .command_tx
553            .send(ContextSessionCommand {
554                surface_session_id: self.inner.surface_session_id.clone(),
555                context_session_id: self.inner.session_id.clone(),
556                command: Some(ContextCommand::FocusElement(FocusElementCommand {
557                    css_selector: selector.clone(),
558                    retry_options: command_retry_options(options.timeout_ms),
559                })),
560            })
561            .await
562            .map_err(|_| Error::new("failed to send FocusElementCommand"))?;
563
564        loop {
565            let event =
566                handle.events.message().await?.ok_or_else(|| {
567                    Error::new("app session closed while waiting for focus result")
568                })?;
569
570            match event.event {
571                Some(ContextEvent::Attached(_)) => {}
572                Some(ContextEvent::ElementFocused(focused)) => {
573                    return Ok(ElementResult {
574                        selector: focused.css_selector,
575                        note: focused.note,
576                    });
577                }
578                Some(ContextEvent::Error(error)) => {
579                    return Err(Error::new(format!(
580                        "app session error while focusing Android locator {:?}: {}",
581                        selector, error.message,
582                    )));
583                }
584                Some(ContextEvent::Closed(_)) => {
585                    handle.closed = true;
586                    return Err(Error::new(format!(
587                        "app session {} closed while waiting for focus result",
588                        self.inner.session_id
589                    )));
590                }
591                _ => {}
592            }
593        }
594    }
595
596    pub async fn press(
597        &self,
598        selector: &str,
599        key: &str,
600        options: PressOptions,
601    ) -> Result<PressResult> {
602        let selector = normalize_mobile_selector_for_transport(selector);
603        let mut state = self.inner.state.lock().await;
604        let handle = self.ensure_handle(&mut state).await?;
605        ensure_android_app_open(handle, &self.inner.session_id)?;
606
607        handle
608            .command_tx
609            .send(ContextSessionCommand {
610                surface_session_id: self.inner.surface_session_id.clone(),
611                context_session_id: self.inner.session_id.clone(),
612                command: Some(ContextCommand::PressKey(PressKeyCommand {
613                    css_selector: selector.clone(),
614                    key: key.to_string(),
615                    text: options.text,
616                    retry_options: command_retry_options(options.timeout_ms),
617                })),
618            })
619            .await
620            .map_err(|_| Error::new("failed to send PressKeyCommand"))?;
621
622        loop {
623            let event =
624                handle.events.message().await?.ok_or_else(|| {
625                    Error::new("app session closed while waiting for press result")
626                })?;
627
628            match event.event {
629                Some(ContextEvent::Attached(_)) => {}
630                Some(ContextEvent::KeyPressed(pressed)) => {
631                    return Ok(PressResult {
632                        selector: pressed.css_selector,
633                        key: pressed.key,
634                        note: pressed.note,
635                    });
636                }
637                Some(ContextEvent::Error(error)) => {
638                    return Err(Error::new(format!(
639                        "app session error while pressing Android key on {:?}: {}",
640                        selector, error.message,
641                    )));
642                }
643                Some(ContextEvent::Closed(_)) => {
644                    handle.closed = true;
645                    return Err(Error::new(format!(
646                        "app session {} closed while waiting for press result",
647                        self.inner.session_id
648                    )));
649                }
650                _ => {}
651            }
652        }
653    }
654
655    pub async fn text_content(
656        &self,
657        selector: &str,
658        options: CommandOptions,
659    ) -> Result<TextResult> {
660        self.read_text(selector, options, true).await
661    }
662
663    pub async fn inner_text(&self, selector: &str, options: CommandOptions) -> Result<TextResult> {
664        self.read_text(selector, options, false).await
665    }
666
667    pub async fn wait_for_selector(
668        &self,
669        selector: &str,
670        options: WaitForSelectorOptions,
671    ) -> Result<WaitForSelectorResult> {
672        let selector = normalize_mobile_selector_for_transport(selector);
673        let mut state = self.inner.state.lock().await;
674        let handle = self.ensure_handle(&mut state).await?;
675        ensure_android_app_open(handle, &self.inner.session_id)?;
676
677        handle
678            .command_tx
679            .send(ContextSessionCommand {
680                surface_session_id: self.inner.surface_session_id.clone(),
681                context_session_id: self.inner.session_id.clone(),
682                command: Some(ContextCommand::WaitForSelector(WaitForSelectorCommand {
683                    css_selector: selector.clone(),
684                    visible: options.visible,
685                    retry_options: command_retry_options(options.timeout_ms),
686                })),
687            })
688            .await
689            .map_err(|_| Error::new("failed to send WaitForSelectorCommand"))?;
690
691        loop {
692            let event = handle.events.message().await?.ok_or_else(|| {
693                Error::new("app session closed while waiting for selector result")
694            })?;
695
696            match event.event {
697                Some(ContextEvent::Attached(_)) => {}
698                Some(ContextEvent::SelectorWaitSatisfied(wait)) => {
699                    return Ok(WaitForSelectorResult {
700                        selector: wait.css_selector,
701                        visible: wait.visible,
702                        note: wait.note,
703                    });
704                }
705                Some(ContextEvent::Error(error)) => {
706                    return Err(Error::new(format!(
707                        "app session error while waiting for Android locator {:?}: {}",
708                        selector, error.message,
709                    )));
710                }
711                Some(ContextEvent::Closed(_)) => {
712                    handle.closed = true;
713                    return Err(Error::new(format!(
714                        "app session {} closed while waiting for selector result",
715                        self.inner.session_id
716                    )));
717                }
718                _ => {}
719            }
720        }
721    }
722
723    pub async fn screenshot(&self) -> Result<ScreenshotResult> {
724        self.screenshot_with_options(ScreenshotOptions::default())
725            .await
726    }
727
728    pub async fn screenshot_with_options(
729        &self,
730        options: ScreenshotOptions,
731    ) -> Result<ScreenshotResult> {
732        let mut state = self.inner.state.lock().await;
733        let handle = self.ensure_handle(&mut state).await?;
734        ensure_android_app_open(handle, &self.inner.session_id)?;
735
736        handle
737            .command_tx
738            .send(ContextSessionCommand {
739                surface_session_id: self.inner.surface_session_id.clone(),
740                context_session_id: self.inner.session_id.clone(),
741                command: Some(ContextCommand::Screenshot(ScreenshotCommand {
742                    retry_options: command_retry_options(options.timeout_ms),
743                    full_page: Some(options.full_page),
744                })),
745            })
746            .await
747            .map_err(|_| Error::new("failed to send ScreenshotCommand"))?;
748
749        loop {
750            let event = handle.events.message().await?.ok_or_else(|| {
751                Error::new("app session closed while waiting for screenshot result")
752            })?;
753
754            match event.event {
755                Some(ContextEvent::Attached(_)) => {}
756                Some(ContextEvent::ScreenshotCaptured(screenshot)) => {
757                    let result = ScreenshotResult {
758                        png_data: screenshot.png_data,
759                        note: screenshot.note,
760                    };
761                    if let Some(path) = options.path.as_ref() {
762                        std::fs::write(path, &result.png_data).map_err(|error| {
763                            Error::new(format!("write screenshot to {}: {error}", path.display()))
764                        })?;
765                    }
766                    return Ok(result);
767                }
768                Some(ContextEvent::Error(error)) => {
769                    return Err(Error::new(format!(
770                        "app session error while capturing Android screenshot: {}",
771                        error.message
772                    )));
773                }
774                Some(ContextEvent::Closed(_)) => {
775                    handle.closed = true;
776                    return Err(Error::new(format!(
777                        "app session {} closed while waiting for screenshot result",
778                        self.inner.session_id
779                    )));
780                }
781                _ => {}
782            }
783        }
784    }
785
786    async fn ensure_handle<'a>(
787        &self,
788        state: &'a mut AndroidAppState,
789    ) -> Result<&'a mut AndroidTabHandle> {
790        if state.handle.is_none() {
791            let mut engine = self.inner.runtime.engine.clone();
792            let (command_tx, command_rx) = mpsc::channel(16);
793            let response = engine
794                .context_session(tonic::Request::new(ReceiverStream::new(command_rx)))
795                .await?;
796            state.handle = Some(AndroidTabHandle {
797                command_tx,
798                events: response.into_inner(),
799                closed: false,
800            });
801        }
802
803        state
804            .handle
805            .as_mut()
806            .ok_or_else(|| Error::new("android app session handle was not initialized"))
807    }
808
809    async fn read_text(
810        &self,
811        selector: &str,
812        options: CommandOptions,
813        text_content: bool,
814    ) -> Result<TextResult> {
815        let selector = normalize_mobile_selector_for_transport(selector);
816        let mut state = self.inner.state.lock().await;
817        let handle = self.ensure_handle(&mut state).await?;
818        ensure_android_app_open(handle, &self.inner.session_id)?;
819
820        let command = if text_content {
821            ContextCommand::GetTextContent(GetTextContentCommand {
822                css_selector: selector.clone(),
823                retry_options: command_retry_options(options.timeout_ms),
824            })
825        } else {
826            ContextCommand::GetInnerText(GetInnerTextCommand {
827                css_selector: selector.clone(),
828                retry_options: command_retry_options(options.timeout_ms),
829            })
830        };
831
832        handle
833            .command_tx
834            .send(ContextSessionCommand {
835                surface_session_id: self.inner.surface_session_id.clone(),
836                context_session_id: self.inner.session_id.clone(),
837                command: Some(command),
838            })
839            .await
840            .map_err(|_| Error::new("failed to send text read command"))?;
841
842        loop {
843            let event =
844                handle.events.message().await?.ok_or_else(|| {
845                    Error::new("app session closed while waiting for text result")
846                })?;
847
848            match event.event {
849                Some(ContextEvent::Attached(_)) => {}
850                Some(ContextEvent::TextContentResolved(text)) => {
851                    return Ok(TextResult {
852                        selector: text.css_selector,
853                        text: text.text,
854                        note: text.note,
855                    });
856                }
857                Some(ContextEvent::InnerTextResolved(text)) => {
858                    return Ok(TextResult {
859                        selector: text.css_selector,
860                        text: text.text,
861                        note: text.note,
862                    });
863                }
864                Some(ContextEvent::Error(error)) => {
865                    return Err(Error::new(format!(
866                        "app session error while reading Android text for {:?}: {}",
867                        selector, error.message,
868                    )));
869                }
870                Some(ContextEvent::Closed(_)) => {
871                    handle.closed = true;
872                    return Err(Error::new(format!(
873                        "app session {} closed while waiting for text result",
874                        self.inner.session_id
875                    )));
876                }
877                _ => {}
878            }
879        }
880    }
881}
882
883impl<T: AndroidHookType> AndroidHook<T> {
884    pub fn id(&self) -> &str {
885        &self.id
886    }
887
888    pub async fn wait(&self) -> Result<T::Output> {
889        self.wait_with_options(CommandOptions::default()).await
890    }
891
892    pub async fn wait_with_options(&self, options: CommandOptions) -> Result<T::Output> {
893        let mut state = self.app.inner.state.lock().await;
894        let handle = self.app.ensure_handle(&mut state).await?;
895        handle
896            .command_tx
897            .send(ContextSessionCommand {
898                surface_session_id: self.app.inner.surface_session_id.clone(),
899                context_session_id: self.app.inner.session_id.clone(),
900                command: Some(ContextCommand::WaitForHook(WaitForHookCommand {
901                    hook_id: self.id.clone(),
902                    retry_options: command_retry_options(options.timeout_ms),
903                })),
904            })
905            .await
906            .map_err(|_| Error::new("failed to send Android WaitForHookCommand"))?;
907        loop {
908            let event =
909                handle.events.message().await?.ok_or_else(|| {
910                    Error::new("Android app session closed while waiting for hook")
911                })?;
912            match event.event {
913                Some(ContextEvent::HookCompleted(event)) if event.hook_id == self.id => {
914                    return T::decode(
915                        &self.app,
916                        event
917                            .result
918                            .ok_or_else(|| Error::new("Android hook completed without a result"))?,
919                    );
920                }
921                Some(ContextEvent::Error(error)) => return Err(Error::new(error.message)),
922                _ => {}
923            }
924        }
925    }
926}
927
928impl AndroidFileChooser {
929    pub fn id(&self) -> &str {
930        &self.id
931    }
932    pub fn app(&self) -> &AndroidApp {
933        &self.app
934    }
935    pub fn is_multiple(&self) -> bool {
936        self.is_multiple
937    }
938    pub async fn set_file(&self, path: impl AsRef<Path>) -> Result<()> {
939        self.set_files([path]).await
940    }
941    pub async fn set_files<I, P>(&self, paths: I) -> Result<()>
942    where
943        I: IntoIterator<Item = P>,
944        P: AsRef<Path>,
945    {
946        let paths = paths
947            .into_iter()
948            .map(|path| path.as_ref().to_path_buf())
949            .collect::<Vec<_>>();
950        if !self.is_multiple && paths.len() > 1 {
951            return Err(Error::new(
952                "Android file chooser does not accept multiple files",
953            ));
954        }
955        let mut state = self.app.inner.state.lock().await;
956        let handle = self.app.ensure_handle(&mut state).await?;
957        let mut file_ids = Vec::with_capacity(paths.len());
958        for path in paths {
959            file_ids.push(upload_android_client_file(&self.app, handle, &path).await?);
960        }
961        handle
962            .command_tx
963            .send(ContextSessionCommand {
964                surface_session_id: self.app.inner.surface_session_id.clone(),
965                context_session_id: self.app.inner.session_id.clone(),
966                command: Some(ContextCommand::SetMobileFileChooserFiles(
967                    SetMobileFileChooserFilesCommand {
968                        file_chooser_id: self.id.clone(),
969                        file_ids,
970                        retry_options: None,
971                    },
972                )),
973            })
974            .await
975            .map_err(|_| Error::new("failed to send SetMobileFileChooserFilesCommand"))?;
976        loop {
977            let event = handle.events.message().await?.ok_or_else(|| {
978                Error::new("Android app session closed while setting chooser files")
979            })?;
980            match event.event {
981                Some(ContextEvent::MobileFileChooserFilesSet(result))
982                    if result.file_chooser_id == self.id =>
983                {
984                    return Ok(());
985                }
986                Some(ContextEvent::Error(error)) => return Err(Error::new(error.message)),
987                _ => {}
988            }
989        }
990    }
991}
992
993impl AndroidDownload {
994    pub fn id(&self) -> &str {
995        &self.id
996    }
997    pub fn app(&self) -> &AndroidApp {
998        &self.app
999    }
1000    pub fn suggested_filename(&self) -> &str {
1001        &self.suggested_filename
1002    }
1003    pub async fn save_as(&self, path: impl AsRef<Path>) -> Result<()> {
1004        let mut state = self.app.inner.state.lock().await;
1005        let handle = self.app.ensure_handle(&mut state).await?;
1006        handle
1007            .command_tx
1008            .send(ContextSessionCommand {
1009                surface_session_id: self.app.inner.surface_session_id.clone(),
1010                context_session_id: self.app.inner.session_id.clone(),
1011                command: Some(ContextCommand::SaveMobileDownload(
1012                    SaveMobileDownloadCommand {
1013                        download_id: self.id.clone(),
1014                        retry_options: None,
1015                    },
1016                )),
1017            })
1018            .await
1019            .map_err(|_| Error::new("failed to send SaveMobileDownloadCommand"))?;
1020        loop {
1021            let event =
1022                handle.events.message().await?.ok_or_else(|| {
1023                    Error::new("Android app session closed while saving download")
1024                })?;
1025            match event.event {
1026                Some(ContextEvent::MobileDownloadSaved(result))
1027                    if result.download_id == self.id =>
1028                {
1029                    return download_android_client_file(
1030                        &self.app,
1031                        handle,
1032                        &result.file_id,
1033                        path.as_ref(),
1034                    )
1035                    .await;
1036                }
1037                Some(ContextEvent::Error(error)) => return Err(Error::new(error.message)),
1038                _ => {}
1039            }
1040        }
1041    }
1042}
1043
1044async fn upload_android_client_file(
1045    app: &AndroidApp,
1046    handle: &mut AndroidTabHandle,
1047    path: &Path,
1048) -> Result<String> {
1049    let mut source = fs::File::open(path)
1050        .map_err(|error| Error::new(format!("open upload {}: {error}", path.display())))?;
1051    let size = source
1052        .metadata()
1053        .map_err(|error| Error::new(error.to_string()))?
1054        .len();
1055    let transfer_id = format!(
1056        "rust-mobile-upload-{}-{}",
1057        std::process::id(),
1058        MOBILE_TRANSFER_COUNTER.fetch_add(1, Ordering::Relaxed)
1059    );
1060    let name = path
1061        .file_name()
1062        .and_then(|name| name.to_str())
1063        .ok_or_else(|| Error::new("upload requires a valid file name"))?
1064        .to_string();
1065    let mut offset = 0_u64;
1066    loop {
1067        let mut data = vec![0; 256 * 1024];
1068        let count = source
1069            .read(&mut data)
1070            .map_err(|error| Error::new(error.to_string()))?;
1071        data.truncate(count);
1072        let last = offset + count as u64 >= size;
1073        handle
1074            .command_tx
1075            .send(ContextSessionCommand {
1076                surface_session_id: app.inner.surface_session_id.clone(),
1077                context_session_id: app.inner.session_id.clone(),
1078                command: Some(ContextCommand::UploadFileChunk(UploadFileChunkCommand {
1079                    transfer_id: transfer_id.clone(),
1080                    name: name.clone(),
1081                    offset,
1082                    data,
1083                    last,
1084                })),
1085            })
1086            .await
1087            .map_err(|_| Error::new("failed to send UploadFileChunkCommand"))?;
1088        offset += count as u64;
1089        if last {
1090            break;
1091        }
1092    }
1093    loop {
1094        let event = handle
1095            .events
1096            .message()
1097            .await?
1098            .ok_or_else(|| Error::new("Android app session closed while uploading file"))?;
1099        match event.event {
1100            Some(ContextEvent::FileUploaded(result)) if result.transfer_id == transfer_id => {
1101                return Ok(result.file_id);
1102            }
1103            Some(ContextEvent::Error(error)) => return Err(Error::new(error.message)),
1104            _ => {}
1105        }
1106    }
1107}
1108
1109async fn download_android_client_file(
1110    app: &AndroidApp,
1111    handle: &mut AndroidTabHandle,
1112    file_id: &str,
1113    path: &Path,
1114) -> Result<()> {
1115    let temporary = path.with_extension(format!(
1116        "allwright-{}.tmp",
1117        MOBILE_TRANSFER_COUNTER.fetch_add(1, Ordering::Relaxed)
1118    ));
1119    let mut output = fs::OpenOptions::new()
1120        .write(true)
1121        .create_new(true)
1122        .open(&temporary)
1123        .map_err(|error| Error::new(error.to_string()))?;
1124    let mut offset = 0_u64;
1125    loop {
1126        handle
1127            .command_tx
1128            .send(ContextSessionCommand {
1129                surface_session_id: app.inner.surface_session_id.clone(),
1130                context_session_id: app.inner.session_id.clone(),
1131                command: Some(ContextCommand::ReadFileChunk(ReadFileChunkCommand {
1132                    file_id: file_id.to_string(),
1133                    offset,
1134                    max_bytes: 256 * 1024,
1135                })),
1136            })
1137            .await
1138            .map_err(|_| Error::new("failed to send ReadFileChunkCommand"))?;
1139        let event = handle
1140            .events
1141            .message()
1142            .await?
1143            .ok_or_else(|| Error::new("Android app session closed while downloading file"))?;
1144        match event.event {
1145            Some(ContextEvent::FileChunk(chunk))
1146                if chunk.file_id == file_id && chunk.offset == offset =>
1147            {
1148                output
1149                    .write_all(&chunk.data)
1150                    .map_err(|error| Error::new(error.to_string()))?;
1151                offset += chunk.data.len() as u64;
1152                if chunk.last {
1153                    drop(output);
1154                    fs::rename(&temporary, path).map_err(|error| Error::new(error.to_string()))?;
1155                    return Ok(());
1156                }
1157            }
1158            Some(ContextEvent::Error(error)) => {
1159                let _ = fs::remove_file(&temporary);
1160                return Err(Error::new(error.message));
1161            }
1162            _ => {}
1163        }
1164    }
1165}
1166
1167impl AndroidLocator {
1168    pub fn app(&self) -> &AndroidApp {
1169        &self.page
1170    }
1171
1172    pub fn selector(&self) -> &str {
1173        &self.selector
1174    }
1175
1176    pub fn locator(&self, selector: impl Into<String>) -> AndroidLocator {
1177        AndroidLocator {
1178            page: self.page.clone(),
1179            selector: chain_mobile_selector_for_transport(&self.selector, &selector.into()),
1180        }
1181    }
1182
1183    pub async fn click(&self, options: CommandOptions) -> Result<ClickResult> {
1184        self.page.click(&self.selector, options).await
1185    }
1186
1187    pub async fn count(&self, options: CommandOptions) -> Result<CountResult> {
1188        self.page.count(&self.selector, options).await
1189    }
1190
1191    pub async fn focus(&self, options: CommandOptions) -> Result<ElementResult> {
1192        self.page.focus(&self.selector, options).await
1193    }
1194
1195    pub async fn fill(&self, value: &str, options: CommandOptions) -> Result<FillResult> {
1196        self.page.fill(&self.selector, value, options).await
1197    }
1198
1199    pub async fn press(&self, key: &str, options: PressOptions) -> Result<PressResult> {
1200        self.page.press(&self.selector, key, options).await
1201    }
1202
1203    pub async fn text_content(&self, options: CommandOptions) -> Result<TextResult> {
1204        self.page.text_content(&self.selector, options).await
1205    }
1206
1207    pub async fn inner_text(&self, options: CommandOptions) -> Result<TextResult> {
1208        self.page.inner_text(&self.selector, options).await
1209    }
1210
1211    pub async fn wait_for(&self, options: WaitForSelectorOptions) -> Result<WaitForSelectorResult> {
1212        self.page.wait_for_selector(&self.selector, options).await
1213    }
1214}
1215
1216fn ensure_android_device_open(state: &AndroidDeviceState, session_id: &str) -> Result<()> {
1217    if state.closed {
1218        return Err(Error::new(format!(
1219            "android device session {} is closed",
1220            session_id
1221        )));
1222    }
1223    Ok(())
1224}
1225
1226fn ensure_android_app_open(handle: &AndroidTabHandle, session_id: &str) -> Result<()> {
1227    if handle.closed {
1228        return Err(Error::new(format!(
1229            "android app session {} is closed",
1230            session_id
1231        )));
1232    }
1233    Ok(())
1234}
1235
1236#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1237enum MobileSelectorFlavor {
1238    Css,
1239    XPath,
1240    UiAutomator,
1241}
1242
1243impl MobileSelectorFlavor {
1244    fn as_str(self) -> &'static str {
1245        match self {
1246            Self::Css => "css",
1247            Self::XPath => "xpath",
1248            Self::UiAutomator => "uia",
1249        }
1250    }
1251}
1252
1253const UIAUTOMATOR_SELECTOR_KEYS: &[&str] = &[
1254    "text",
1255    "textcontains",
1256    "textmatches",
1257    "textstartswith",
1258    "classname",
1259    "classnamematches",
1260    "description",
1261    "desc",
1262    "descriptioncontains",
1263    "desccontains",
1264    "descriptionmatches",
1265    "descmatches",
1266    "descriptionstartswith",
1267    "descstartswith",
1268    "checkable",
1269    "checked",
1270    "clickable",
1271    "longclickable",
1272    "scrollable",
1273    "enabled",
1274    "focusable",
1275    "focused",
1276    "selected",
1277    "packagename",
1278    "package",
1279    "packagenamematches",
1280    "resourceid",
1281    "resourceidmatches",
1282    "index",
1283    "instance",
1284];
1285
1286fn parse_explicit_mobile_selector_prefix(selector: &str) -> Option<(MobileSelectorFlavor, usize)> {
1287    let lowered = selector.to_ascii_lowercase();
1288    if lowered.starts_with("xpath=") || lowered.starts_with("xpath:") {
1289        return Some((MobileSelectorFlavor::XPath, 6));
1290    }
1291    if lowered.starts_with("uia=") || lowered.starts_with("uia:") {
1292        return Some((MobileSelectorFlavor::UiAutomator, 4));
1293    }
1294    if let Some(prefix_len) = parse_ui_automator_selector_prefix(&lowered) {
1295        return Some((MobileSelectorFlavor::UiAutomator, prefix_len));
1296    }
1297    if lowered.starts_with("text=") || lowered.starts_with("text:") {
1298        return Some((MobileSelectorFlavor::UiAutomator, 5));
1299    }
1300    if lowered.starts_with("id=") || lowered.starts_with("id:") {
1301        return Some((MobileSelectorFlavor::Css, 3));
1302    }
1303    if lowered.starts_with("css=") || lowered.starts_with("css:") {
1304        return Some((MobileSelectorFlavor::Css, 4));
1305    }
1306    None
1307}
1308
1309fn parse_ui_automator_selector_prefix(selector: &str) -> Option<usize> {
1310    UIAUTOMATOR_SELECTOR_KEYS.iter().find_map(|key| {
1311        if selector.starts_with(key) {
1312            let separator = selector.as_bytes().get(key.len()).copied()?;
1313            if separator == b'=' || separator == b':' {
1314                return Some(key.len() + 1);
1315            }
1316        }
1317        None
1318    })
1319}
1320
1321fn find_json_string_end(value: &str) -> Option<usize> {
1322    let bytes = value.as_bytes();
1323    if bytes.first().copied()? != b'"' {
1324        return None;
1325    }
1326
1327    let mut index = 1usize;
1328    let mut escaped = false;
1329    while index < bytes.len() {
1330        let byte = bytes[index];
1331        if escaped {
1332            escaped = false;
1333            index += 1;
1334            continue;
1335        }
1336        match byte {
1337            b'\\' => escaped = true,
1338            b'"' => return Some(index + 1),
1339            _ => {}
1340        }
1341        index += 1;
1342    }
1343    None
1344}
1345
1346fn is_normalized_mobile_transport_selector(selector: &str) -> bool {
1347    let trimmed = selector.trim();
1348    if trimmed.is_empty() {
1349        return false;
1350    }
1351
1352    let mut index = 0usize;
1353    while index < trimmed.len() {
1354        let Some((_, prefix_len)) = parse_explicit_mobile_selector_prefix(&trimmed[index..]) else {
1355            return false;
1356        };
1357        index += prefix_len;
1358
1359        let remainder = &trimmed[index..];
1360        let Some(json_end) = find_json_string_end(remainder) else {
1361            return false;
1362        };
1363        index += json_end;
1364
1365        if index == trimmed.len() {
1366            return true;
1367        }
1368
1369        let whitespace_len = trimmed[index..]
1370            .chars()
1371            .take_while(|char| char.is_ascii_whitespace())
1372            .count();
1373        if whitespace_len == 0 {
1374            return false;
1375        }
1376        index += whitespace_len;
1377
1378        if parse_explicit_mobile_selector_prefix(&trimmed[index..]).is_none() {
1379            return false;
1380        }
1381    }
1382
1383    true
1384}
1385
1386fn decode_selector_body(body: &str) -> String {
1387    let candidate = body.trim();
1388    if candidate.len() >= 2 && candidate.starts_with('"') && candidate.ends_with('"') {
1389        if let Ok(decoded) = serde_json::from_str::<String>(candidate) {
1390            return unescape_shell_escaped_selector(&decoded);
1391        }
1392    }
1393    unescape_shell_escaped_selector(candidate)
1394}
1395
1396fn unescape_shell_escaped_selector(value: &str) -> String {
1397    let mut result = String::with_capacity(value.len());
1398    let mut chars = value.chars().peekable();
1399    while let Some(ch) = chars.next() {
1400        if ch == '\\' {
1401            match chars.peek().copied() {
1402                Some('_' | ' ' | '#' | ':' | '[' | ']' | '(' | ')' | '"' | '\'') => {
1403                    result.push(chars.next().expect("peeked char should exist"));
1404                    continue;
1405                }
1406                _ => {}
1407            }
1408        }
1409        result.push(ch);
1410    }
1411    result
1412}
1413
1414fn parse_mobile_selector_for_transport(selector: &str) -> (MobileSelectorFlavor, String) {
1415    let trimmed = selector.trim();
1416    if let Some((flavor, prefix_len)) = parse_explicit_mobile_selector_prefix(trimmed) {
1417        let body = decode_selector_body(&trimmed[prefix_len..]);
1418        return match flavor {
1419            MobileSelectorFlavor::Css if prefix_len == 3 => {
1420                let normalized = if body.starts_with('#') {
1421                    body
1422                } else {
1423                    format!("#{body}")
1424                };
1425                (MobileSelectorFlavor::Css, normalized)
1426            }
1427            MobileSelectorFlavor::UiAutomator
1428                if prefix_len != 4 && !trimmed[..prefix_len].eq_ignore_ascii_case("text=") =>
1429            {
1430                (
1431                    MobileSelectorFlavor::UiAutomator,
1432                    format!("{}={body}", &trimmed[..prefix_len - 1]),
1433                )
1434            }
1435            MobileSelectorFlavor::UiAutomator if prefix_len == 5 => {
1436                (MobileSelectorFlavor::UiAutomator, format!("text={body}"))
1437            }
1438            _ => (flavor, body),
1439        };
1440    }
1441
1442    if trimmed.starts_with("//")
1443        || trimmed.starts_with(".//")
1444        || trimmed.starts_with("../")
1445        || trimmed.starts_with('/')
1446        || trimmed.starts_with('(')
1447    {
1448        return (MobileSelectorFlavor::XPath, trimmed.to_string());
1449    }
1450
1451    (MobileSelectorFlavor::Css, trimmed.to_string())
1452}
1453
1454fn normalize_mobile_selector_for_transport(selector: &str) -> String {
1455    let trimmed = selector.trim();
1456    if trimmed.is_empty() {
1457        return String::new();
1458    }
1459    if is_normalized_mobile_transport_selector(trimmed) {
1460        return trimmed.to_string();
1461    }
1462    let (flavor, body) = parse_mobile_selector_for_transport(selector);
1463    format!(
1464        "{}={}",
1465        flavor.as_str(),
1466        serde_json::to_string(&body).unwrap_or_else(|_| format!("{body:?}"))
1467    )
1468}
1469
1470fn chain_mobile_selector_for_transport(parent: &str, child: &str) -> String {
1471    let parent = if parent.trim().is_empty() {
1472        String::new()
1473    } else {
1474        normalize_mobile_selector_for_transport(parent)
1475    };
1476    let child = if child.trim().is_empty() {
1477        String::new()
1478    } else {
1479        normalize_mobile_selector_for_transport(child)
1480    };
1481    if parent.is_empty() {
1482        return child;
1483    }
1484    if child.is_empty() {
1485        return parent;
1486    }
1487    format!("{parent} {child}")
1488}
1489
1490impl AndroidApp {
1491    pub async fn accessibility_snapshot(&self) -> Result<String> {
1492        self.accessibility_snapshot_with_options(AccessibilitySnapshotOptions::default())
1493            .await
1494    }
1495
1496    pub async fn accessibility_snapshot_with_options(
1497        &self,
1498        options: AccessibilitySnapshotOptions,
1499    ) -> Result<String> {
1500        let mut state = self.inner.state.lock().await;
1501        let handle = self.ensure_handle(&mut state).await?;
1502        ensure_android_app_open(handle, &self.inner.session_id)?;
1503        handle
1504            .command_tx
1505            .send(ContextSessionCommand {
1506                surface_session_id: self.inner.surface_session_id.clone(),
1507                context_session_id: self.inner.session_id.clone(),
1508                command: Some(ContextCommand::AccessibilitySnapshot(
1509                    AccessibilitySnapshotCommand {
1510                        format: match options.format {
1511                            AccessibilitySnapshotFormat::Json => "json",
1512                            AccessibilitySnapshotFormat::Yaml => "yaml",
1513                        }
1514                        .into(),
1515                        mode: match options.mode {
1516                            AccessibilitySnapshotMode::Default => "default",
1517                            AccessibilitySnapshotMode::Ai => "ai",
1518                            AccessibilitySnapshotMode::Autoexpect => "autoexpect",
1519                            AccessibilitySnapshotMode::Codegen => "codegen",
1520                        }
1521                        .into(),
1522                        retry_options: command_retry_options(options.timeout_ms),
1523                    },
1524                )),
1525            })
1526            .await
1527            .map_err(|_| Error::new("failed to send AccessibilitySnapshotCommand"))?;
1528        loop {
1529            let event = handle.events.message().await?.ok_or_else(|| {
1530                Error::new("android app session closed while capturing accessibility snapshot")
1531            })?;
1532            match event.event {
1533                Some(ContextEvent::AccessibilitySnapshotCaptured(result)) => {
1534                    return Ok(result.snapshot);
1535                }
1536                Some(ContextEvent::Error(error)) => return Err(Error::new(error.message)),
1537                Some(ContextEvent::Closed(_)) => {
1538                    handle.closed = true;
1539                    return Err(Error::new(
1540                        "android app session closed while capturing accessibility snapshot",
1541                    ));
1542                }
1543                _ => {}
1544            }
1545        }
1546    }
1547}