Skip to main content

allwright/
client_mobile.rs

1use std::ops::Deref;
2use std::sync::atomic::{AtomicU64, Ordering};
3use std::sync::{Arc, Mutex};
4use std::{
5    fs,
6    io::{Read, Write},
7    marker::PhantomData,
8    path::Path,
9};
10
11use crate::proto::context_session_command::Command as ContextCommand;
12use crate::proto::context_session_event::Event as ContextEvent;
13use crate::proto::hook_completed_event::Result as HookCompletionResult;
14use crate::proto::register_hook_command::Hook as RegisterHook;
15use crate::proto::surface_session_command::Command as SurfaceCommand;
16use crate::proto::surface_session_event::Event as SurfaceEvent;
17use crate::proto::{
18    AccessibilitySnapshotCommand, AppLaunchedEvent, CaptureCommand, ClickElementCommand,
19    CloseDesktopWindowCommand, ConnectDesktopCommand, ConnectMobileCommand, ContextSessionCommand,
20    CountElementsCommand, DesktopAppLaunchedEvent, DesktopConnectedEvent,
21    DesktopPlatform as ProtoDesktopPlatform, DesktopWindowInfo, FillElementCommand,
22    FocusElementCommand, GetInnerTextCommand, GetTextContentCommand, LaunchAppCommand,
23    LaunchDesktopAppCommand, ListDesktopWindowsCommand, MobileConnectedEvent,
24    MobilePlatform as ProtoMobilePlatform, NavigatePageCommand, PressKeyCommand,
25    ReadFileChunkCommand, RegisterDesktopWindowHook, RegisterHookCommand,
26    RegisterMobileDownloadHook, RegisterMobileFileChooserHook, SaveMobileDownloadCommand,
27    ScreenshotCommand, SetMobileFileChooserFilesCommand, SurfaceSessionCommand,
28    UploadFileChunkCommand, WaitForHookCommand, WaitForSelectorCommand,
29};
30
31use super::hook::{DownloadHook, FileChooserHook};
32
33static MOBILE_TRANSFER_COUNTER: AtomicU64 = AtomicU64::new(1);
34
35pub trait AndroidHookType: private::Sealed + Clone + Send + Sync + 'static {
36    type Output;
37}
38
39mod private {
40    use super::*;
41    pub trait Sealed {
42        fn name() -> &'static str;
43        fn decode(
44            app: &NativeApp,
45            result: HookCompletionResult,
46        ) -> Result<<Self as AndroidHookType>::Output>
47        where
48            Self: AndroidHookType;
49    }
50}
51
52impl AndroidHookType for FileChooserHook {
53    type Output = AndroidFileChooser;
54}
55impl private::Sealed for FileChooserHook {
56    fn name() -> &'static str {
57        "file_chooser"
58    }
59    fn decode(app: &NativeApp, result: HookCompletionResult) -> Result<AndroidFileChooser> {
60        let HookCompletionResult::MobileFileChooser(result) = result else {
61            return Err(Error::new(
62                "Android file chooser hook returned an invalid result",
63            ));
64        };
65        Ok(AndroidFileChooser {
66            app: app.clone(),
67            id: result.file_chooser_id,
68            is_multiple: result.is_multiple,
69        })
70    }
71}
72impl AndroidHookType for DownloadHook {
73    type Output = AndroidDownload;
74}
75impl private::Sealed for DownloadHook {
76    fn name() -> &'static str {
77        "download"
78    }
79    fn decode(app: &NativeApp, result: HookCompletionResult) -> Result<AndroidDownload> {
80        let HookCompletionResult::MobileDownload(result) = result else {
81            return Err(Error::new(
82                "Android download hook returned an invalid result",
83            ));
84        };
85        Ok(AndroidDownload {
86            app: app.clone(),
87            id: result.download_id,
88            suggested_filename: result.suggested_filename,
89        })
90    }
91}
92
93pub struct AndroidHook<T: AndroidHookType> {
94    app: NativeApp,
95    id: String,
96    _type: PhantomData<T>,
97}
98
99#[derive(Clone)]
100pub struct AndroidFileChooser {
101    app: NativeApp,
102    id: String,
103    is_multiple: bool,
104}
105
106#[derive(Clone)]
107pub struct AndroidDownload {
108    app: NativeApp,
109    id: String,
110    suggested_filename: String,
111}
112use tokio::sync::{Mutex as AsyncMutex, mpsc};
113use tokio_stream::wrappers::ReceiverStream;
114
115use super::command::command_retry_options;
116use super::runtime::get_runtime;
117use super::types::{
118    AccessibilitySnapshotFormat, AccessibilitySnapshotMode, AccessibilitySnapshotOptions,
119    BoundingBox, ClickOptions, CommandOptions, Error, PressOptions, Result, RuntimeClient,
120    ScreenshotOptions, TextResult, WaitForSelectorOptions,
121};
122use super::web_locators::{RoleOptions, TextMatcher, TextOptions};
123
124#[derive(Debug, Clone, Default)]
125pub struct MobileAndroidConnectOptions {
126    pub device: Option<String>,
127    pub adb_endpoint: Option<String>,
128    pub preserve_app_state: bool,
129    pub timeout_ms: Option<u32>,
130}
131
132#[derive(Debug, Clone, Default)]
133pub struct MobileAndroidLaunchOptions {
134    pub apk_path: Option<String>,
135    pub app_id: Option<String>,
136    pub launch_activity: Option<String>,
137    pub stop_before_launch: bool,
138    pub timeout_ms: Option<u32>,
139}
140
141#[derive(Debug, Clone, Default)]
142pub struct MobileIosConnectOptions {
143    pub device: Option<String>,
144    pub agent_endpoint: Option<String>,
145    pub preserve_app_state: bool,
146    pub timeout_ms: Option<u32>,
147}
148
149#[derive(Debug, Clone, Default)]
150pub struct MobileIosLaunchOptions {
151    pub app_path: Option<String>,
152    pub app_id: Option<String>,
153    pub stop_before_launch: bool,
154    pub timeout_ms: Option<u32>,
155}
156
157#[derive(Debug, Clone, Default)]
158pub struct DesktopMacConnectOptions {
159    pub agent_endpoint: Option<String>,
160    pub timeout_ms: Option<u32>,
161}
162
163#[derive(Debug, Clone)]
164pub struct DesktopMacLaunchOptions {
165    pub app_id: String,
166    pub terminate_running: bool,
167    pub timeout_ms: Option<u32>,
168}
169
170pub type DesktopWindowsConnectOptions = DesktopMacConnectOptions;
171pub type DesktopWindowsLaunchOptions = DesktopMacLaunchOptions;
172
173#[derive(Clone)]
174pub struct DesktopApp {
175    app: NativeApp,
176}
177
178#[derive(Clone)]
179pub struct DesktopWindow {
180    app: NativeApp,
181    window_id: String,
182    title: String,
183    is_main: bool,
184}
185
186#[derive(Clone, Copy)]
187pub struct WindowHook;
188pub const WINDOW: WindowHook = WindowHook;
189
190pub struct DesktopWindowHook {
191    app: DesktopApp,
192    id: String,
193}
194
195pub type MacApp = DesktopApp;
196pub type MacLocator = NativeLocator;
197pub type WindowsApp = DesktopApp;
198pub type WindowsLocator = NativeLocator;
199
200#[derive(Clone)]
201pub struct MacDesktop {
202    inner: AndroidDevice,
203}
204pub type WindowsDesktop = MacDesktop;
205
206pub type IosApp = NativeApp;
207pub type IosLocator = NativeLocator;
208
209#[derive(Clone)]
210pub struct IosDevice {
211    inner: AndroidDevice,
212}
213
214#[derive(Clone)]
215pub struct NativeLocator {
216    page: NativeApp,
217    selector: String,
218}
219
220#[derive(Clone)]
221pub struct NativeApp {
222    inner: Arc<NativeAppInner>,
223}
224
225pub type AndroidApp = NativeApp;
226pub type AndroidLocator = NativeLocator;
227
228#[derive(Clone)]
229pub struct AndroidDevice {
230    inner: Arc<AndroidDeviceInner>,
231}
232
233struct AndroidDeviceInner {
234    runtime: Arc<RuntimeClient>,
235    state: AsyncMutex<AndroidDeviceState>,
236    session_id: String,
237    initial_app: NativeApp,
238    current_app: Mutex<NativeApp>,
239}
240
241struct AndroidDeviceState {
242    command_tx: mpsc::Sender<SurfaceSessionCommand>,
243    events: tonic::Streaming<crate::proto::SurfaceSessionEvent>,
244    closed: bool,
245}
246
247struct NativeAppInner {
248    runtime: Arc<RuntimeClient>,
249    surface_session_id: String,
250    session_id: String,
251    state: AsyncMutex<NativeAppState>,
252}
253
254#[derive(Default)]
255struct NativeAppState {
256    handle: Option<AndroidTabHandle>,
257}
258
259struct AndroidTabHandle {
260    command_tx: mpsc::Sender<crate::proto::ContextSessionCommand>,
261    events: tonic::Streaming<crate::proto::ContextSessionEvent>,
262    closed: bool,
263}
264
265pub mod android {
266    use super::*;
267
268    pub async fn connect(options: MobileAndroidConnectOptions) -> Result<AndroidDevice> {
269        let runtime = get_runtime().await?;
270        let mut engine = runtime.engine.clone();
271        let (command_tx, command_rx) = mpsc::channel(16);
272        let response = engine
273            .surface_session(tonic::Request::new(ReceiverStream::new(command_rx)))
274            .await?;
275        let mut events = response.into_inner();
276
277        command_tx
278            .send(SurfaceSessionCommand {
279                command: Some(SurfaceCommand::ConnectMobile(ConnectMobileCommand {
280                    platform: ProtoMobilePlatform::Android as i32,
281                    device: options.device,
282                    adb_endpoint: options.adb_endpoint,
283                    preserve_app_state: options.preserve_app_state,
284                    retry_options: command_retry_options(options.timeout_ms),
285                })),
286            })
287            .await
288            .map_err(|_| Error::new("failed to send ConnectMobileCommand"))?;
289
290        loop {
291            let event = events.message().await?.ok_or_else(|| {
292                Error::new("surface session closed before mobile connect response")
293            })?;
294
295            match event.event {
296                Some(SurfaceEvent::MobileConnected(MobileConnectedEvent {
297                    initial_app_session_id,
298                    device_session_id,
299                    ..
300                })) => {
301                    let initial_app = NativeApp {
302                        inner: Arc::new(NativeAppInner {
303                            runtime: Arc::clone(&runtime),
304                            surface_session_id: event.session_id.clone(),
305                            session_id: initial_app_session_id,
306                            state: AsyncMutex::new(NativeAppState::default()),
307                        }),
308                    };
309                    return Ok(AndroidDevice {
310                        inner: Arc::new(AndroidDeviceInner {
311                            runtime,
312                            state: AsyncMutex::new(AndroidDeviceState {
313                                command_tx,
314                                events,
315                                closed: false,
316                            }),
317                            session_id: if device_session_id.is_empty() {
318                                event.session_id
319                            } else {
320                                device_session_id
321                            },
322                            initial_app: initial_app.clone(),
323                            current_app: Mutex::new(initial_app),
324                        }),
325                    });
326                }
327                Some(SurfaceEvent::Error(error)) => {
328                    return Err(Error::new(format!(
329                        "surface session error during mobile connect: {}",
330                        error.message
331                    )));
332                }
333                _ => {}
334            }
335        }
336    }
337}
338
339pub mod ios {
340    use super::*;
341
342    pub async fn connect(options: MobileIosConnectOptions) -> Result<IosDevice> {
343        let runtime = get_runtime().await?;
344        let mut engine = runtime.engine.clone();
345        let (command_tx, command_rx) = mpsc::channel(16);
346        let response = engine
347            .surface_session(tonic::Request::new(ReceiverStream::new(command_rx)))
348            .await?;
349        let mut events = response.into_inner();
350
351        command_tx
352            .send(SurfaceSessionCommand {
353                command: Some(SurfaceCommand::ConnectMobile(ConnectMobileCommand {
354                    platform: ProtoMobilePlatform::Ios as i32,
355                    device: options.device,
356                    // The protobuf field is transport-internal; the public iOS
357                    // option accurately calls this the native agent endpoint.
358                    adb_endpoint: options.agent_endpoint,
359                    preserve_app_state: options.preserve_app_state,
360                    retry_options: command_retry_options(options.timeout_ms),
361                })),
362            })
363            .await
364            .map_err(|_| Error::new("failed to send iOS ConnectMobileCommand"))?;
365
366        loop {
367            let event = events
368                .message()
369                .await?
370                .ok_or_else(|| Error::new("surface session closed before iOS connect response"))?;
371            match event.event {
372                Some(SurfaceEvent::MobileConnected(MobileConnectedEvent {
373                    initial_app_session_id,
374                    device_session_id,
375                    ..
376                })) => {
377                    let initial_app = NativeApp {
378                        inner: Arc::new(NativeAppInner {
379                            runtime: Arc::clone(&runtime),
380                            surface_session_id: event.session_id.clone(),
381                            session_id: initial_app_session_id,
382                            state: AsyncMutex::new(NativeAppState::default()),
383                        }),
384                    };
385                    return Ok(IosDevice {
386                        inner: AndroidDevice {
387                            inner: Arc::new(AndroidDeviceInner {
388                                runtime,
389                                state: AsyncMutex::new(AndroidDeviceState {
390                                    command_tx,
391                                    events,
392                                    closed: false,
393                                }),
394                                session_id: if device_session_id.is_empty() {
395                                    event.session_id
396                                } else {
397                                    device_session_id
398                                },
399                                initial_app: initial_app.clone(),
400                                current_app: Mutex::new(initial_app),
401                            }),
402                        },
403                    });
404                }
405                Some(SurfaceEvent::Error(error)) => {
406                    return Err(Error::new(format!(
407                        "surface session error during iOS connect: {}",
408                        error.message
409                    )));
410                }
411                _ => {}
412            }
413        }
414    }
415}
416
417pub mod desktop {
418    use super::*;
419    pub use super::{
420        DesktopApp, DesktopMacConnectOptions, DesktopMacLaunchOptions, DesktopWindow,
421        DesktopWindowHook, DesktopWindowsConnectOptions, DesktopWindowsLaunchOptions, MacApp,
422        MacDesktop, MacLocator, WINDOW, WindowHook, WindowsApp, WindowsDesktop, WindowsLocator,
423    };
424
425    pub mod mac {
426        use super::*;
427
428        pub async fn connect(options: DesktopMacConnectOptions) -> Result<MacDesktop> {
429            let runtime = get_runtime().await?;
430            let mut engine = runtime.engine.clone();
431            let (command_tx, command_rx) = mpsc::channel(16);
432            let response = engine
433                .surface_session(tonic::Request::new(ReceiverStream::new(command_rx)))
434                .await?;
435            let mut events = response.into_inner();
436            command_tx
437                .send(SurfaceSessionCommand {
438                    command: Some(SurfaceCommand::ConnectDesktop(ConnectDesktopCommand {
439                        platform: ProtoDesktopPlatform::Mac as i32,
440                        agent_endpoint: options.agent_endpoint,
441                        retry_options: command_retry_options(options.timeout_ms),
442                    })),
443                })
444                .await
445                .map_err(|_| Error::new("failed to send ConnectDesktopCommand"))?;
446            loop {
447                let event = events.message().await?.ok_or_else(|| {
448                    Error::new("surface session closed before macOS desktop connect response")
449                })?;
450                match event.event {
451                    Some(SurfaceEvent::DesktopConnected(DesktopConnectedEvent {
452                        initial_app_session_id,
453                        desktop_session_id,
454                        ..
455                    })) => {
456                        let initial_app = NativeApp {
457                            inner: Arc::new(NativeAppInner {
458                                runtime: Arc::clone(&runtime),
459                                surface_session_id: event.session_id.clone(),
460                                session_id: initial_app_session_id,
461                                state: AsyncMutex::new(NativeAppState::default()),
462                            }),
463                        };
464                        return Ok(MacDesktop {
465                            inner: AndroidDevice {
466                                inner: Arc::new(AndroidDeviceInner {
467                                    runtime,
468                                    state: AsyncMutex::new(AndroidDeviceState {
469                                        command_tx,
470                                        events,
471                                        closed: false,
472                                    }),
473                                    session_id: if desktop_session_id.is_empty() {
474                                        event.session_id
475                                    } else {
476                                        desktop_session_id
477                                    },
478                                    initial_app: initial_app.clone(),
479                                    current_app: Mutex::new(initial_app),
480                                }),
481                            },
482                        });
483                    }
484                    Some(SurfaceEvent::Error(error)) => return Err(Error::new(error.message)),
485                    _ => {}
486                }
487            }
488        }
489    }
490
491    pub mod windows {
492        use super::*;
493
494        pub async fn connect(options: DesktopWindowsConnectOptions) -> Result<WindowsDesktop> {
495            let runtime = get_runtime().await?;
496            let mut engine = runtime.engine.clone();
497            let (command_tx, command_rx) = mpsc::channel(16);
498            let response = engine
499                .surface_session(tonic::Request::new(ReceiverStream::new(command_rx)))
500                .await?;
501            let mut events = response.into_inner();
502            command_tx
503                .send(SurfaceSessionCommand {
504                    command: Some(SurfaceCommand::ConnectDesktop(ConnectDesktopCommand {
505                        platform: ProtoDesktopPlatform::Windows as i32,
506                        agent_endpoint: options.agent_endpoint,
507                        retry_options: command_retry_options(options.timeout_ms),
508                    })),
509                })
510                .await
511                .map_err(|_| Error::new("failed to send ConnectDesktopCommand"))?;
512            loop {
513                let event = events.message().await?.ok_or_else(|| {
514                    Error::new("surface session closed before Windows desktop connect response")
515                })?;
516                match event.event {
517                    Some(SurfaceEvent::DesktopConnected(DesktopConnectedEvent {
518                        initial_app_session_id,
519                        desktop_session_id,
520                        ..
521                    })) => {
522                        let initial_app = NativeApp {
523                            inner: Arc::new(NativeAppInner {
524                                runtime: Arc::clone(&runtime),
525                                surface_session_id: event.session_id.clone(),
526                                session_id: initial_app_session_id,
527                                state: AsyncMutex::new(NativeAppState::default()),
528                            }),
529                        };
530                        return Ok(MacDesktop {
531                            inner: AndroidDevice {
532                                inner: Arc::new(AndroidDeviceInner {
533                                    runtime,
534                                    state: AsyncMutex::new(AndroidDeviceState {
535                                        command_tx,
536                                        events,
537                                        closed: false,
538                                    }),
539                                    session_id: if desktop_session_id.is_empty() {
540                                        event.session_id
541                                    } else {
542                                        desktop_session_id
543                                    },
544                                    initial_app: initial_app.clone(),
545                                    current_app: Mutex::new(initial_app),
546                                }),
547                            },
548                        });
549                    }
550                    Some(SurfaceEvent::Error(error)) => return Err(Error::new(error.message)),
551                    _ => {}
552                }
553            }
554        }
555    }
556}
557
558impl MacDesktop {
559    pub fn session_id(&self) -> &str {
560        self.inner.session_id()
561    }
562
563    pub fn app(&self) -> MacApp {
564        DesktopApp {
565            app: self.inner.app(),
566        }
567    }
568
569    pub async fn launch(&self, options: DesktopMacLaunchOptions) -> Result<MacApp> {
570        let mut state = self.inner.inner.state.lock().await;
571        ensure_android_device_open(&state, &self.inner.inner.session_id)?;
572        state
573            .command_tx
574            .send(SurfaceSessionCommand {
575                command: Some(SurfaceCommand::LaunchDesktopApp(LaunchDesktopAppCommand {
576                    app_id: options.app_id,
577                    terminate_running: options.terminate_running,
578                    retry_options: command_retry_options(options.timeout_ms),
579                })),
580            })
581            .await
582            .map_err(|_| Error::new("failed to send LaunchDesktopAppCommand"))?;
583        loop {
584            let event = state.events.message().await?.ok_or_else(|| {
585                Error::new("surface session closed before macOS app launch response")
586            })?;
587            match event.event {
588                Some(SurfaceEvent::DesktopAppLaunched(DesktopAppLaunchedEvent {
589                    app_session_id,
590                    ..
591                })) => {
592                    let app = NativeApp {
593                        inner: Arc::new(NativeAppInner {
594                            runtime: Arc::clone(&self.inner.inner.runtime),
595                            surface_session_id: event.session_id,
596                            session_id: app_session_id,
597                            state: AsyncMutex::new(NativeAppState::default()),
598                        }),
599                    };
600                    if let Ok(mut current_app) = self.inner.inner.current_app.lock() {
601                        *current_app = app.clone();
602                    }
603                    return Ok(DesktopApp { app });
604                }
605                Some(SurfaceEvent::Error(error)) => return Err(Error::new(error.message)),
606                _ => {}
607            }
608        }
609    }
610}
611
612impl Deref for DesktopApp {
613    type Target = NativeApp;
614    fn deref(&self) -> &Self::Target {
615        &self.app
616    }
617}
618
619impl Deref for DesktopWindow {
620    type Target = NativeApp;
621    fn deref(&self) -> &Self::Target {
622        &self.app
623    }
624}
625
626impl DesktopApp {
627    pub async fn windows(&self) -> Result<Vec<DesktopWindow>> {
628        let mut state = self.app.inner.state.lock().await;
629        let handle = self.app.ensure_handle(&mut state).await?;
630        ensure_android_app_open(handle, &self.app.inner.session_id)?;
631        handle
632            .command_tx
633            .send(ContextSessionCommand {
634                surface_session_id: self.app.inner.surface_session_id.clone(),
635                context_session_id: self.app.inner.session_id.clone(),
636                command: Some(ContextCommand::ListDesktopWindows(
637                    ListDesktopWindowsCommand {},
638                )),
639            })
640            .await
641            .map_err(|_| Error::new("failed to send ListDesktopWindowsCommand"))?;
642        loop {
643            let event =
644                handle.events.message().await?.ok_or_else(|| {
645                    Error::new("desktop app session closed while listing windows")
646                })?;
647            match event.event {
648                Some(ContextEvent::DesktopWindowsResolved(resolved)) => {
649                    return resolved
650                        .windows
651                        .into_iter()
652                        .map(|info| self.window(info))
653                        .collect();
654                }
655                Some(ContextEvent::Error(error)) => return Err(Error::new(error.message)),
656                _ => {}
657            }
658        }
659    }
660
661    pub async fn main_window(&self) -> Result<DesktopWindow> {
662        self.windows()
663            .await?
664            .into_iter()
665            .find(|window| window.is_main)
666            .ok_or_else(|| {
667                Error::new("desktop application does not currently expose a main window")
668            })
669    }
670
671    pub async fn register_hook(&self, _hook_type: WindowHook) -> Result<DesktopWindowHook> {
672        let mut state = self.app.inner.state.lock().await;
673        let handle = self.app.ensure_handle(&mut state).await?;
674        ensure_android_app_open(handle, &self.app.inner.session_id)?;
675        handle
676            .command_tx
677            .send(ContextSessionCommand {
678                surface_session_id: self.app.inner.surface_session_id.clone(),
679                context_session_id: self.app.inner.session_id.clone(),
680                command: Some(ContextCommand::RegisterHook(RegisterHookCommand {
681                    hook: Some(RegisterHook::DesktopWindow(RegisterDesktopWindowHook {})),
682                })),
683            })
684            .await
685            .map_err(|_| Error::new("failed to send desktop RegisterHookCommand"))?;
686        loop {
687            let event = handle.events.message().await?.ok_or_else(|| {
688                Error::new("desktop app session closed while registering window hook")
689            })?;
690            match event.event {
691                Some(ContextEvent::HookRegistered(event)) => {
692                    return Ok(DesktopWindowHook {
693                        app: self.clone(),
694                        id: event.hook_id,
695                    });
696                }
697                Some(ContextEvent::Error(error)) => return Err(Error::new(error.message)),
698                _ => {}
699            }
700        }
701    }
702
703    fn window(&self, info: DesktopWindowInfo) -> Result<DesktopWindow> {
704        if info.context_session_id.is_empty() || info.window_id.is_empty() {
705            return Err(Error::new(
706                "desktop window result is missing its context or window id",
707            ));
708        }
709        Ok(DesktopWindow {
710            app: NativeApp {
711                inner: Arc::new(NativeAppInner {
712                    runtime: Arc::clone(&self.app.inner.runtime),
713                    surface_session_id: self.app.inner.surface_session_id.clone(),
714                    session_id: info.context_session_id,
715                    state: AsyncMutex::new(NativeAppState::default()),
716                }),
717            },
718            window_id: info.window_id,
719            title: info.title,
720            is_main: info.is_main,
721        })
722    }
723}
724
725impl DesktopWindow {
726    pub fn window_id(&self) -> &str {
727        &self.window_id
728    }
729    pub fn title(&self) -> &str {
730        &self.title
731    }
732    pub fn is_main(&self) -> bool {
733        self.is_main
734    }
735
736    pub async fn close(&self) -> Result<()> {
737        let mut state = self.app.inner.state.lock().await;
738        let handle = self.app.ensure_handle(&mut state).await?;
739        handle
740            .command_tx
741            .send(ContextSessionCommand {
742                surface_session_id: self.app.inner.surface_session_id.clone(),
743                context_session_id: self.app.inner.session_id.clone(),
744                command: Some(ContextCommand::CloseDesktopWindow(
745                    CloseDesktopWindowCommand {},
746                )),
747            })
748            .await
749            .map_err(|_| Error::new("failed to send CloseDesktopWindowCommand"))?;
750        loop {
751            let event =
752                handle.events.message().await?.ok_or_else(|| {
753                    Error::new("desktop window session closed while closing window")
754                })?;
755            match event.event {
756                Some(ContextEvent::DesktopWindowClosed(event))
757                    if event.window_id == self.window_id =>
758                {
759                    return Ok(());
760                }
761                Some(ContextEvent::Error(error)) => return Err(Error::new(error.message)),
762                _ => {}
763            }
764        }
765    }
766}
767
768impl DesktopWindowHook {
769    pub fn id(&self) -> &str {
770        &self.id
771    }
772    pub async fn wait(&self) -> Result<DesktopWindow> {
773        self.wait_with_options(CommandOptions::default()).await
774    }
775    pub async fn wait_with_options(&self, options: CommandOptions) -> Result<DesktopWindow> {
776        let mut state = self.app.app.inner.state.lock().await;
777        let handle = self.app.app.ensure_handle(&mut state).await?;
778        handle
779            .command_tx
780            .send(ContextSessionCommand {
781                surface_session_id: self.app.app.inner.surface_session_id.clone(),
782                context_session_id: self.app.app.inner.session_id.clone(),
783                command: Some(ContextCommand::WaitForHook(WaitForHookCommand {
784                    hook_id: self.id.clone(),
785                    retry_options: command_retry_options(options.timeout_ms),
786                })),
787            })
788            .await
789            .map_err(|_| Error::new("failed to send desktop WaitForHookCommand"))?;
790        loop {
791            let event = handle.events.message().await?.ok_or_else(|| {
792                Error::new("desktop app session closed while waiting for window hook")
793            })?;
794            match event.event {
795                Some(ContextEvent::HookCompleted(event)) if event.hook_id == self.id => {
796                    let Some(HookCompletionResult::DesktopWindow(result)) = event.result else {
797                        return Err(Error::new("desktop window hook returned an invalid result"));
798                    };
799                    return self.app.window(
800                        result
801                            .window
802                            .ok_or_else(|| Error::new("desktop window hook returned no window"))?,
803                    );
804                }
805                Some(ContextEvent::Error(error)) => return Err(Error::new(error.message)),
806                _ => {}
807            }
808        }
809    }
810}
811
812impl IosDevice {
813    pub fn session_id(&self) -> &str {
814        self.inner.session_id()
815    }
816
817    pub fn app(&self) -> IosApp {
818        self.inner.app()
819    }
820
821    pub fn initial_app(&self) -> IosApp {
822        self.inner.initial_app()
823    }
824
825    pub async fn launch(&self, options: MobileIosLaunchOptions) -> Result<IosApp> {
826        self.inner
827            .launch(MobileAndroidLaunchOptions {
828                apk_path: options.app_path,
829                app_id: options.app_id,
830                stop_before_launch: options.stop_before_launch,
831                timeout_ms: options.timeout_ms,
832                ..MobileAndroidLaunchOptions::default()
833            })
834            .await
835    }
836}
837
838impl AndroidDevice {
839    pub fn session_id(&self) -> &str {
840        &self.inner.session_id
841    }
842
843    pub fn app(&self) -> NativeApp {
844        self.inner
845            .current_app
846            .lock()
847            .map(|app| app.clone())
848            .unwrap_or_else(|_| self.inner.initial_app.clone())
849    }
850
851    pub fn initial_app(&self) -> NativeApp {
852        self.inner.initial_app.clone()
853    }
854
855    pub async fn launch(&self, options: MobileAndroidLaunchOptions) -> Result<NativeApp> {
856        let mut state = self.inner.state.lock().await;
857        ensure_android_device_open(&state, &self.inner.session_id)?;
858
859        state
860            .command_tx
861            .send(SurfaceSessionCommand {
862                command: Some(SurfaceCommand::LaunchApp(LaunchAppCommand {
863                    apk_path: options.apk_path,
864                    app_id: options.app_id,
865                    launch_activity: options.launch_activity,
866                    stop_before_launch: options.stop_before_launch,
867                    retry_options: command_retry_options(options.timeout_ms),
868                })),
869            })
870            .await
871            .map_err(|_| Error::new("failed to send LaunchAppCommand"))?;
872
873        loop {
874            let event =
875                state.events.message().await?.ok_or_else(|| {
876                    Error::new("surface session closed before app launch response")
877                })?;
878
879            match event.event {
880                Some(SurfaceEvent::AppLaunched(AppLaunchedEvent { app_session_id, .. })) => {
881                    let app = NativeApp {
882                        inner: Arc::new(NativeAppInner {
883                            runtime: Arc::clone(&self.inner.runtime),
884                            surface_session_id: event.session_id,
885                            session_id: app_session_id,
886                            state: AsyncMutex::new(NativeAppState::default()),
887                        }),
888                    };
889                    if let Ok(mut current_app) = self.inner.current_app.lock() {
890                        *current_app = app.clone();
891                    }
892                    return Ok(app);
893                }
894                Some(SurfaceEvent::Error(error)) => {
895                    return Err(Error::new(format!(
896                        "surface session error while launching Android app: {}",
897                        error.message
898                    )));
899                }
900                Some(SurfaceEvent::Closed(_)) => {
901                    state.closed = true;
902                    return Err(Error::new(
903                        "surface session closed while waiting for Android app launch",
904                    ));
905                }
906                _ => {}
907            }
908        }
909    }
910}
911
912impl NativeApp {
913    pub fn session_id(&self) -> &str {
914        &self.inner.session_id
915    }
916
917    pub async fn register_hook<T: AndroidHookType>(&self, _hook_type: T) -> Result<AndroidHook<T>> {
918        let mut state = self.inner.state.lock().await;
919        let handle = self.ensure_handle(&mut state).await?;
920        ensure_android_app_open(handle, &self.inner.session_id)?;
921        let hook = match T::name() {
922            "file_chooser" => Some(RegisterHook::MobileFileChooser(
923                RegisterMobileFileChooserHook {},
924            )),
925            "download" => Some(RegisterHook::MobileDownload(RegisterMobileDownloadHook {})),
926            _ => return Err(Error::new("hook type is not supported by Android apps")),
927        };
928        handle
929            .command_tx
930            .send(ContextSessionCommand {
931                surface_session_id: self.inner.surface_session_id.clone(),
932                context_session_id: self.inner.session_id.clone(),
933                command: Some(ContextCommand::RegisterHook(RegisterHookCommand { hook })),
934            })
935            .await
936            .map_err(|_| Error::new("failed to send Android RegisterHookCommand"))?;
937        loop {
938            let event =
939                handle.events.message().await?.ok_or_else(|| {
940                    Error::new("Android app session closed while registering hook")
941                })?;
942            match event.event {
943                Some(ContextEvent::HookRegistered(event)) => {
944                    return Ok(AndroidHook {
945                        app: self.clone(),
946                        id: event.hook_id,
947                        _type: PhantomData,
948                    });
949                }
950                Some(ContextEvent::Error(error)) => return Err(Error::new(error.message)),
951                _ => {}
952            }
953        }
954    }
955
956    pub fn locator(&self, selector: impl Into<String>) -> NativeLocator {
957        NativeLocator {
958            page: self.clone(),
959            selector: normalize_mobile_selector_for_transport(&selector.into()),
960        }
961    }
962
963    pub fn get_by_role(&self, role: impl Into<String>) -> NativeLocator {
964        self.get_by_role_with_options(role, RoleOptions::default())
965    }
966
967    pub fn get_by_role_with_options(
968        &self,
969        role: impl Into<String>,
970        options: RoleOptions,
971    ) -> NativeLocator {
972        let mut spec = serde_json::to_value(options).expect("role options");
973        spec["kind"] = serde_json::json!("role");
974        spec["role"] = serde_json::json!(role.into());
975        self.locator(mobile_semantic_selector(spec))
976    }
977
978    pub fn get_by_text(&self, text: impl Into<TextMatcher>) -> NativeLocator {
979        self.get_by_text_with_options(text, TextOptions::default())
980    }
981
982    pub fn get_by_text_with_options(
983        &self,
984        text: impl Into<TextMatcher>,
985        options: TextOptions,
986    ) -> NativeLocator {
987        self.locator(mobile_text_selector("text", text.into(), options))
988    }
989
990    pub fn get_by_label(&self, text: impl Into<TextMatcher>) -> NativeLocator {
991        self.get_by_label_with_options(text, TextOptions::default())
992    }
993
994    pub fn get_by_label_with_options(
995        &self,
996        text: impl Into<TextMatcher>,
997        options: TextOptions,
998    ) -> NativeLocator {
999        self.locator(mobile_text_selector("label", text.into(), options))
1000    }
1001
1002    pub fn get_by_test_id(&self, text: impl Into<TextMatcher>) -> NativeLocator {
1003        self.locator(mobile_semantic_selector(
1004            serde_json::json!({"kind":"testId", "text":text.into()}),
1005        ))
1006    }
1007
1008    pub async fn goto(&self, url: &str, options: CommandOptions) -> Result<()> {
1009        self.navigate(url, options).await
1010    }
1011
1012    pub async fn navigate(&self, url: &str, options: CommandOptions) -> Result<()> {
1013        let mut state = self.inner.state.lock().await;
1014        let handle = self.ensure_handle(&mut state).await?;
1015        ensure_android_app_open(handle, &self.inner.session_id)?;
1016        handle
1017            .command_tx
1018            .send(ContextSessionCommand {
1019                surface_session_id: self.inner.surface_session_id.clone(),
1020                context_session_id: self.inner.session_id.clone(),
1021                command: Some(ContextCommand::Navigate(NavigatePageCommand {
1022                    url: url.to_string(),
1023                    retry_options: command_retry_options(options.timeout_ms),
1024                })),
1025            })
1026            .await
1027            .map_err(|_| Error::new("failed to send mobile NavigatePageCommand"))?;
1028        loop {
1029            let event =
1030                handle.events.message().await?.ok_or_else(|| {
1031                    Error::new("native app session closed while opening deep link")
1032                })?;
1033            match event.event {
1034                Some(ContextEvent::Navigated(_)) => return Ok(()),
1035                Some(ContextEvent::Error(error)) => return Err(Error::new(error.message)),
1036                Some(ContextEvent::Closed(_)) => {
1037                    handle.closed = true;
1038                    return Err(Error::new(
1039                        "native app session closed while opening deep link",
1040                    ));
1041                }
1042                _ => {}
1043            }
1044        }
1045    }
1046
1047    pub async fn click(&self, selector: &str, options: CommandOptions) -> Result<()> {
1048        self.click_with_options(selector, options.into()).await
1049    }
1050
1051    pub async fn click_with_options(&self, selector: &str, options: ClickOptions) -> Result<()> {
1052        let selector = normalize_mobile_selector_for_transport(selector);
1053        let mut state = self.inner.state.lock().await;
1054        let handle = self.ensure_handle(&mut state).await?;
1055        ensure_android_app_open(handle, &self.inner.session_id)?;
1056
1057        handle
1058            .command_tx
1059            .send(ContextSessionCommand {
1060                surface_session_id: self.inner.surface_session_id.clone(),
1061                context_session_id: self.inner.session_id.clone(),
1062                command: Some(ContextCommand::ClickElement(ClickElementCommand {
1063                    css_selector: selector.clone(),
1064                    retry_options: command_retry_options(options.timeout_ms),
1065                    button: Some(options.button.as_str().to_string()),
1066                    click_count: options.click_count,
1067                })),
1068            })
1069            .await
1070            .map_err(|_| Error::new("failed to send ClickElementCommand"))?;
1071
1072        loop {
1073            let event =
1074                handle.events.message().await?.ok_or_else(|| {
1075                    Error::new("app session closed while waiting for click result")
1076                })?;
1077
1078            match event.event {
1079                Some(ContextEvent::Attached(_)) => {}
1080                Some(ContextEvent::ElementClicked(_)) => return Ok(()),
1081                Some(ContextEvent::Error(error)) => {
1082                    return Err(Error::new(format!(
1083                        "app session error while clicking Android locator {:?}: {}",
1084                        selector, error.message,
1085                    )));
1086                }
1087                Some(ContextEvent::Closed(_)) => {
1088                    handle.closed = true;
1089                    return Err(Error::new(format!(
1090                        "app session {} closed while waiting for click result",
1091                        self.inner.session_id
1092                    )));
1093                }
1094                _ => {}
1095            }
1096        }
1097    }
1098
1099    pub async fn fill(&self, selector: &str, value: &str, options: CommandOptions) -> Result<()> {
1100        let selector = normalize_mobile_selector_for_transport(selector);
1101        let mut state = self.inner.state.lock().await;
1102        let handle = self.ensure_handle(&mut state).await?;
1103        ensure_android_app_open(handle, &self.inner.session_id)?;
1104
1105        handle
1106            .command_tx
1107            .send(ContextSessionCommand {
1108                surface_session_id: self.inner.surface_session_id.clone(),
1109                context_session_id: self.inner.session_id.clone(),
1110                command: Some(ContextCommand::FillElement(FillElementCommand {
1111                    css_selector: selector.clone(),
1112                    value: value.to_string(),
1113                    retry_options: command_retry_options(options.timeout_ms),
1114                })),
1115            })
1116            .await
1117            .map_err(|_| Error::new("failed to send FillElementCommand"))?;
1118
1119        loop {
1120            let event =
1121                handle.events.message().await?.ok_or_else(|| {
1122                    Error::new("app session closed while waiting for fill result")
1123                })?;
1124
1125            match event.event {
1126                Some(ContextEvent::Attached(_)) => {}
1127                Some(ContextEvent::ElementFilled(_)) => return Ok(()),
1128                Some(ContextEvent::Error(error)) => {
1129                    return Err(Error::new(format!(
1130                        "app session error while filling Android locator {:?}: {}",
1131                        selector, error.message,
1132                    )));
1133                }
1134                Some(ContextEvent::Closed(_)) => {
1135                    handle.closed = true;
1136                    return Err(Error::new(format!(
1137                        "app session {} closed while waiting for fill result",
1138                        self.inner.session_id
1139                    )));
1140                }
1141                _ => {}
1142            }
1143        }
1144    }
1145
1146    pub async fn count(&self, selector: &str, options: CommandOptions) -> Result<u32> {
1147        let selector = normalize_mobile_selector_for_transport(selector);
1148        let mut state = self.inner.state.lock().await;
1149        let handle = self.ensure_handle(&mut state).await?;
1150        ensure_android_app_open(handle, &self.inner.session_id)?;
1151
1152        handle
1153            .command_tx
1154            .send(ContextSessionCommand {
1155                surface_session_id: self.inner.surface_session_id.clone(),
1156                context_session_id: self.inner.session_id.clone(),
1157                command: Some(ContextCommand::CountElements(CountElementsCommand {
1158                    css_selector: selector.clone(),
1159                    retry_options: command_retry_options(options.timeout_ms),
1160                })),
1161            })
1162            .await
1163            .map_err(|_| Error::new("failed to send CountElementsCommand"))?;
1164
1165        loop {
1166            let event =
1167                handle.events.message().await?.ok_or_else(|| {
1168                    Error::new("app session closed while waiting for count result")
1169                })?;
1170
1171            match event.event {
1172                Some(ContextEvent::Attached(_)) => {}
1173                Some(ContextEvent::ElementCounted(counted)) => return Ok(counted.count),
1174                Some(ContextEvent::Error(error)) => {
1175                    return Err(Error::new(format!(
1176                        "app session error while counting Android locator {:?}: {}",
1177                        selector, error.message,
1178                    )));
1179                }
1180                Some(ContextEvent::Closed(_)) => {
1181                    handle.closed = true;
1182                    return Err(Error::new(format!(
1183                        "app session {} closed while waiting for count result",
1184                        self.inner.session_id
1185                    )));
1186                }
1187                _ => {}
1188            }
1189        }
1190    }
1191
1192    pub async fn focus(&self, selector: &str, options: CommandOptions) -> Result<()> {
1193        let selector = normalize_mobile_selector_for_transport(selector);
1194        let mut state = self.inner.state.lock().await;
1195        let handle = self.ensure_handle(&mut state).await?;
1196        ensure_android_app_open(handle, &self.inner.session_id)?;
1197
1198        handle
1199            .command_tx
1200            .send(ContextSessionCommand {
1201                surface_session_id: self.inner.surface_session_id.clone(),
1202                context_session_id: self.inner.session_id.clone(),
1203                command: Some(ContextCommand::FocusElement(FocusElementCommand {
1204                    css_selector: selector.clone(),
1205                    retry_options: command_retry_options(options.timeout_ms),
1206                })),
1207            })
1208            .await
1209            .map_err(|_| Error::new("failed to send FocusElementCommand"))?;
1210
1211        loop {
1212            let event =
1213                handle.events.message().await?.ok_or_else(|| {
1214                    Error::new("app session closed while waiting for focus result")
1215                })?;
1216
1217            match event.event {
1218                Some(ContextEvent::Attached(_)) => {}
1219                Some(ContextEvent::ElementFocused(_)) => return Ok(()),
1220                Some(ContextEvent::Error(error)) => {
1221                    return Err(Error::new(format!(
1222                        "app session error while focusing Android locator {:?}: {}",
1223                        selector, error.message,
1224                    )));
1225                }
1226                Some(ContextEvent::Closed(_)) => {
1227                    handle.closed = true;
1228                    return Err(Error::new(format!(
1229                        "app session {} closed while waiting for focus result",
1230                        self.inner.session_id
1231                    )));
1232                }
1233                _ => {}
1234            }
1235        }
1236    }
1237
1238    pub async fn press(&self, selector: &str, key: &str, options: PressOptions) -> Result<()> {
1239        let selector = normalize_mobile_selector_for_transport(selector);
1240        let mut state = self.inner.state.lock().await;
1241        let handle = self.ensure_handle(&mut state).await?;
1242        ensure_android_app_open(handle, &self.inner.session_id)?;
1243
1244        handle
1245            .command_tx
1246            .send(ContextSessionCommand {
1247                surface_session_id: self.inner.surface_session_id.clone(),
1248                context_session_id: self.inner.session_id.clone(),
1249                command: Some(ContextCommand::PressKey(PressKeyCommand {
1250                    css_selector: selector.clone(),
1251                    key: key.to_string(),
1252                    text: options.text,
1253                    retry_options: command_retry_options(options.timeout_ms),
1254                })),
1255            })
1256            .await
1257            .map_err(|_| Error::new("failed to send PressKeyCommand"))?;
1258
1259        loop {
1260            let event =
1261                handle.events.message().await?.ok_or_else(|| {
1262                    Error::new("app session closed while waiting for press result")
1263                })?;
1264
1265            match event.event {
1266                Some(ContextEvent::Attached(_)) => {}
1267                Some(ContextEvent::KeyPressed(_)) => return Ok(()),
1268                Some(ContextEvent::Error(error)) => {
1269                    return Err(Error::new(format!(
1270                        "app session error while pressing Android key on {:?}: {}",
1271                        selector, error.message,
1272                    )));
1273                }
1274                Some(ContextEvent::Closed(_)) => {
1275                    handle.closed = true;
1276                    return Err(Error::new(format!(
1277                        "app session {} closed while waiting for press result",
1278                        self.inner.session_id
1279                    )));
1280                }
1281                _ => {}
1282            }
1283        }
1284    }
1285
1286    pub async fn text_content(
1287        &self,
1288        selector: &str,
1289        options: CommandOptions,
1290    ) -> Result<Option<String>> {
1291        self.read_text(selector, options, true)
1292            .await
1293            .map(|result| Some(result.text))
1294    }
1295
1296    pub async fn inner_text(&self, selector: &str, options: CommandOptions) -> Result<String> {
1297        self.read_text(selector, options, false)
1298            .await
1299            .map(|result| result.text)
1300    }
1301
1302    async fn capture(
1303        &self,
1304        kind: crate::proto::CaptureKind,
1305        selector: &str,
1306        attribute_name: &str,
1307        options: CommandOptions,
1308    ) -> Result<crate::proto::CaptureResolvedEvent> {
1309        let mut state = self.inner.state.lock().await;
1310        let handle = self.ensure_handle(&mut state).await?;
1311        ensure_android_app_open(handle, &self.inner.session_id)?;
1312        handle
1313            .command_tx
1314            .send(ContextSessionCommand {
1315                surface_session_id: self.inner.surface_session_id.clone(),
1316                context_session_id: self.inner.session_id.clone(),
1317                command: Some(ContextCommand::Capture(CaptureCommand {
1318                    kind: kind as i32,
1319                    css_selector: normalize_mobile_selector_for_transport(selector),
1320                    attribute_name: attribute_name.to_string(),
1321                    retry_options: command_retry_options(options.timeout_ms),
1322                })),
1323            })
1324            .await
1325            .map_err(|_| Error::new("failed to send native capture command"))?;
1326        loop {
1327            let event = handle
1328                .events
1329                .message()
1330                .await?
1331                .ok_or_else(|| Error::new("native app session closed while capturing"))?;
1332            match event.event {
1333                Some(ContextEvent::CaptureResolved(result)) => return Ok(result),
1334                Some(ContextEvent::Error(error)) => return Err(Error::new(error.message)),
1335                Some(ContextEvent::Closed(_)) => {
1336                    handle.closed = true;
1337                    return Err(Error::new("native app session closed while capturing"));
1338                }
1339                _ => {}
1340            }
1341        }
1342    }
1343
1344    pub async fn input_value(&self, selector: &str, options: CommandOptions) -> Result<String> {
1345        Ok(self
1346            .capture(crate::proto::CaptureKind::InputValue, selector, "", options)
1347            .await?
1348            .value
1349            .unwrap_or_default())
1350    }
1351
1352    pub async fn is_checked(&self, selector: &str, options: CommandOptions) -> Result<bool> {
1353        Ok(self
1354            .capture(crate::proto::CaptureKind::Checked, selector, "", options)
1355            .await?
1356            .checked
1357            .unwrap_or(false))
1358    }
1359
1360    pub async fn get_attribute(
1361        &self,
1362        selector: &str,
1363        name: &str,
1364        options: CommandOptions,
1365    ) -> Result<Option<String>> {
1366        Ok(self
1367            .capture(
1368                crate::proto::CaptureKind::Attribute,
1369                selector,
1370                name,
1371                options,
1372            )
1373            .await?
1374            .value)
1375    }
1376
1377    pub async fn bounding_box(
1378        &self,
1379        selector: &str,
1380        options: CommandOptions,
1381    ) -> Result<Option<BoundingBox>> {
1382        Ok(self
1383            .capture(
1384                crate::proto::CaptureKind::BoundingBox,
1385                selector,
1386                "",
1387                options,
1388            )
1389            .await?
1390            .bounding_box
1391            .map(|value| BoundingBox {
1392                x: value.x,
1393                y: value.y,
1394                width: value.width,
1395                height: value.height,
1396            }))
1397    }
1398
1399    pub async fn wait_for_selector(
1400        &self,
1401        selector: &str,
1402        options: WaitForSelectorOptions,
1403    ) -> Result<()> {
1404        let selector = normalize_mobile_selector_for_transport(selector);
1405        let mut state = self.inner.state.lock().await;
1406        let handle = self.ensure_handle(&mut state).await?;
1407        ensure_android_app_open(handle, &self.inner.session_id)?;
1408
1409        handle
1410            .command_tx
1411            .send(ContextSessionCommand {
1412                surface_session_id: self.inner.surface_session_id.clone(),
1413                context_session_id: self.inner.session_id.clone(),
1414                command: Some(ContextCommand::WaitForSelector(WaitForSelectorCommand {
1415                    css_selector: selector.clone(),
1416                    visible: options.visible,
1417                    retry_options: command_retry_options(options.timeout_ms),
1418                })),
1419            })
1420            .await
1421            .map_err(|_| Error::new("failed to send WaitForSelectorCommand"))?;
1422
1423        loop {
1424            let event = handle.events.message().await?.ok_or_else(|| {
1425                Error::new("app session closed while waiting for selector result")
1426            })?;
1427
1428            match event.event {
1429                Some(ContextEvent::Attached(_)) => {}
1430                Some(ContextEvent::SelectorWaitSatisfied(_)) => return Ok(()),
1431                Some(ContextEvent::Error(error)) => {
1432                    return Err(Error::new(format!(
1433                        "app session error while waiting for Android locator {:?}: {}",
1434                        selector, error.message,
1435                    )));
1436                }
1437                Some(ContextEvent::Closed(_)) => {
1438                    handle.closed = true;
1439                    return Err(Error::new(format!(
1440                        "app session {} closed while waiting for selector result",
1441                        self.inner.session_id
1442                    )));
1443                }
1444                _ => {}
1445            }
1446        }
1447    }
1448
1449    pub async fn screenshot(&self) -> Result<Vec<u8>> {
1450        self.screenshot_with_options(ScreenshotOptions::default())
1451            .await
1452    }
1453
1454    pub async fn screenshot_with_options(&self, options: ScreenshotOptions) -> Result<Vec<u8>> {
1455        let mut state = self.inner.state.lock().await;
1456        let handle = self.ensure_handle(&mut state).await?;
1457        ensure_android_app_open(handle, &self.inner.session_id)?;
1458
1459        handle
1460            .command_tx
1461            .send(ContextSessionCommand {
1462                surface_session_id: self.inner.surface_session_id.clone(),
1463                context_session_id: self.inner.session_id.clone(),
1464                command: Some(ContextCommand::Screenshot(ScreenshotCommand {
1465                    retry_options: command_retry_options(options.timeout_ms),
1466                    full_page: Some(options.full_page),
1467                })),
1468            })
1469            .await
1470            .map_err(|_| Error::new("failed to send ScreenshotCommand"))?;
1471
1472        loop {
1473            let event = handle.events.message().await?.ok_or_else(|| {
1474                Error::new("app session closed while waiting for screenshot result")
1475            })?;
1476
1477            match event.event {
1478                Some(ContextEvent::Attached(_)) => {}
1479                Some(ContextEvent::ScreenshotCaptured(screenshot)) => {
1480                    if let Some(path) = options.path.as_ref() {
1481                        std::fs::write(path, &screenshot.png_data).map_err(|error| {
1482                            Error::new(format!("write screenshot to {}: {error}", path.display()))
1483                        })?;
1484                    }
1485                    return Ok(screenshot.png_data);
1486                }
1487                Some(ContextEvent::Error(error)) => {
1488                    return Err(Error::new(format!(
1489                        "app session error while capturing Android screenshot: {}",
1490                        error.message
1491                    )));
1492                }
1493                Some(ContextEvent::Closed(_)) => {
1494                    handle.closed = true;
1495                    return Err(Error::new(format!(
1496                        "app session {} closed while waiting for screenshot result",
1497                        self.inner.session_id
1498                    )));
1499                }
1500                _ => {}
1501            }
1502        }
1503    }
1504
1505    async fn ensure_handle<'a>(
1506        &self,
1507        state: &'a mut NativeAppState,
1508    ) -> Result<&'a mut AndroidTabHandle> {
1509        if state.handle.is_none() {
1510            let mut engine = self.inner.runtime.engine.clone();
1511            let (command_tx, command_rx) = mpsc::channel(16);
1512            let response = engine
1513                .context_session(tonic::Request::new(ReceiverStream::new(command_rx)))
1514                .await?;
1515            state.handle = Some(AndroidTabHandle {
1516                command_tx,
1517                events: response.into_inner(),
1518                closed: false,
1519            });
1520        }
1521
1522        state
1523            .handle
1524            .as_mut()
1525            .ok_or_else(|| Error::new("android app session handle was not initialized"))
1526    }
1527
1528    async fn read_text(
1529        &self,
1530        selector: &str,
1531        options: CommandOptions,
1532        text_content: bool,
1533    ) -> Result<TextResult> {
1534        let selector = normalize_mobile_selector_for_transport(selector);
1535        let mut state = self.inner.state.lock().await;
1536        let handle = self.ensure_handle(&mut state).await?;
1537        ensure_android_app_open(handle, &self.inner.session_id)?;
1538
1539        let command = if text_content {
1540            ContextCommand::GetTextContent(GetTextContentCommand {
1541                css_selector: selector.clone(),
1542                retry_options: command_retry_options(options.timeout_ms),
1543            })
1544        } else {
1545            ContextCommand::GetInnerText(GetInnerTextCommand {
1546                css_selector: selector.clone(),
1547                retry_options: command_retry_options(options.timeout_ms),
1548            })
1549        };
1550
1551        handle
1552            .command_tx
1553            .send(ContextSessionCommand {
1554                surface_session_id: self.inner.surface_session_id.clone(),
1555                context_session_id: self.inner.session_id.clone(),
1556                command: Some(command),
1557            })
1558            .await
1559            .map_err(|_| Error::new("failed to send text read command"))?;
1560
1561        loop {
1562            let event =
1563                handle.events.message().await?.ok_or_else(|| {
1564                    Error::new("app session closed while waiting for text result")
1565                })?;
1566
1567            match event.event {
1568                Some(ContextEvent::Attached(_)) => {}
1569                Some(ContextEvent::TextContentResolved(text)) => {
1570                    return Ok(TextResult {
1571                        selector: text.css_selector,
1572                        text: text.text,
1573                        note: text.note,
1574                    });
1575                }
1576                Some(ContextEvent::InnerTextResolved(text)) => {
1577                    return Ok(TextResult {
1578                        selector: text.css_selector,
1579                        text: text.text,
1580                        note: text.note,
1581                    });
1582                }
1583                Some(ContextEvent::Error(error)) => {
1584                    return Err(Error::new(format!(
1585                        "app session error while reading Android text for {:?}: {}",
1586                        selector, error.message,
1587                    )));
1588                }
1589                Some(ContextEvent::Closed(_)) => {
1590                    handle.closed = true;
1591                    return Err(Error::new(format!(
1592                        "app session {} closed while waiting for text result",
1593                        self.inner.session_id
1594                    )));
1595                }
1596                _ => {}
1597            }
1598        }
1599    }
1600}
1601
1602impl<T: AndroidHookType> AndroidHook<T> {
1603    pub fn id(&self) -> &str {
1604        &self.id
1605    }
1606
1607    pub async fn wait(&self) -> Result<T::Output> {
1608        self.wait_with_options(CommandOptions::default()).await
1609    }
1610
1611    pub async fn wait_with_options(&self, options: CommandOptions) -> Result<T::Output> {
1612        let mut state = self.app.inner.state.lock().await;
1613        let handle = self.app.ensure_handle(&mut state).await?;
1614        handle
1615            .command_tx
1616            .send(ContextSessionCommand {
1617                surface_session_id: self.app.inner.surface_session_id.clone(),
1618                context_session_id: self.app.inner.session_id.clone(),
1619                command: Some(ContextCommand::WaitForHook(WaitForHookCommand {
1620                    hook_id: self.id.clone(),
1621                    retry_options: command_retry_options(options.timeout_ms),
1622                })),
1623            })
1624            .await
1625            .map_err(|_| Error::new("failed to send Android WaitForHookCommand"))?;
1626        loop {
1627            let event =
1628                handle.events.message().await?.ok_or_else(|| {
1629                    Error::new("Android app session closed while waiting for hook")
1630                })?;
1631            match event.event {
1632                Some(ContextEvent::HookCompleted(event)) if event.hook_id == self.id => {
1633                    return T::decode(
1634                        &self.app,
1635                        event
1636                            .result
1637                            .ok_or_else(|| Error::new("Android hook completed without a result"))?,
1638                    );
1639                }
1640                Some(ContextEvent::Error(error)) => return Err(Error::new(error.message)),
1641                _ => {}
1642            }
1643        }
1644    }
1645}
1646
1647impl AndroidFileChooser {
1648    pub fn id(&self) -> &str {
1649        &self.id
1650    }
1651    pub fn app(&self) -> &NativeApp {
1652        &self.app
1653    }
1654    pub fn is_multiple(&self) -> bool {
1655        self.is_multiple
1656    }
1657    pub async fn set_file(&self, path: impl AsRef<Path>) -> Result<()> {
1658        self.set_files([path]).await
1659    }
1660    pub async fn set_files<I, P>(&self, paths: I) -> Result<()>
1661    where
1662        I: IntoIterator<Item = P>,
1663        P: AsRef<Path>,
1664    {
1665        let paths = paths
1666            .into_iter()
1667            .map(|path| path.as_ref().to_path_buf())
1668            .collect::<Vec<_>>();
1669        if !self.is_multiple && paths.len() > 1 {
1670            return Err(Error::new(
1671                "Android file chooser does not accept multiple files",
1672            ));
1673        }
1674        let mut state = self.app.inner.state.lock().await;
1675        let handle = self.app.ensure_handle(&mut state).await?;
1676        let mut file_ids = Vec::with_capacity(paths.len());
1677        for path in paths {
1678            file_ids.push(upload_android_client_file(&self.app, handle, &path).await?);
1679        }
1680        handle
1681            .command_tx
1682            .send(ContextSessionCommand {
1683                surface_session_id: self.app.inner.surface_session_id.clone(),
1684                context_session_id: self.app.inner.session_id.clone(),
1685                command: Some(ContextCommand::SetMobileFileChooserFiles(
1686                    SetMobileFileChooserFilesCommand {
1687                        file_chooser_id: self.id.clone(),
1688                        file_ids,
1689                        retry_options: None,
1690                    },
1691                )),
1692            })
1693            .await
1694            .map_err(|_| Error::new("failed to send SetMobileFileChooserFilesCommand"))?;
1695        loop {
1696            let event = handle.events.message().await?.ok_or_else(|| {
1697                Error::new("Android app session closed while setting chooser files")
1698            })?;
1699            match event.event {
1700                Some(ContextEvent::MobileFileChooserFilesSet(result))
1701                    if result.file_chooser_id == self.id =>
1702                {
1703                    return Ok(());
1704                }
1705                Some(ContextEvent::Error(error)) => return Err(Error::new(error.message)),
1706                _ => {}
1707            }
1708        }
1709    }
1710}
1711
1712impl AndroidDownload {
1713    pub fn id(&self) -> &str {
1714        &self.id
1715    }
1716    pub fn app(&self) -> &NativeApp {
1717        &self.app
1718    }
1719    pub fn suggested_filename(&self) -> &str {
1720        &self.suggested_filename
1721    }
1722    pub async fn save_as(&self, path: impl AsRef<Path>) -> Result<()> {
1723        let mut state = self.app.inner.state.lock().await;
1724        let handle = self.app.ensure_handle(&mut state).await?;
1725        handle
1726            .command_tx
1727            .send(ContextSessionCommand {
1728                surface_session_id: self.app.inner.surface_session_id.clone(),
1729                context_session_id: self.app.inner.session_id.clone(),
1730                command: Some(ContextCommand::SaveMobileDownload(
1731                    SaveMobileDownloadCommand {
1732                        download_id: self.id.clone(),
1733                        retry_options: None,
1734                    },
1735                )),
1736            })
1737            .await
1738            .map_err(|_| Error::new("failed to send SaveMobileDownloadCommand"))?;
1739        loop {
1740            let event =
1741                handle.events.message().await?.ok_or_else(|| {
1742                    Error::new("Android app session closed while saving download")
1743                })?;
1744            match event.event {
1745                Some(ContextEvent::MobileDownloadSaved(result))
1746                    if result.download_id == self.id =>
1747                {
1748                    return download_android_client_file(
1749                        &self.app,
1750                        handle,
1751                        &result.file_id,
1752                        path.as_ref(),
1753                    )
1754                    .await;
1755                }
1756                Some(ContextEvent::Error(error)) => return Err(Error::new(error.message)),
1757                _ => {}
1758            }
1759        }
1760    }
1761}
1762
1763async fn upload_android_client_file(
1764    app: &NativeApp,
1765    handle: &mut AndroidTabHandle,
1766    path: &Path,
1767) -> Result<String> {
1768    let mut source = fs::File::open(path)
1769        .map_err(|error| Error::new(format!("open upload {}: {error}", path.display())))?;
1770    let size = source
1771        .metadata()
1772        .map_err(|error| Error::new(error.to_string()))?
1773        .len();
1774    let transfer_id = format!(
1775        "rust-mobile-upload-{}-{}",
1776        std::process::id(),
1777        MOBILE_TRANSFER_COUNTER.fetch_add(1, Ordering::Relaxed)
1778    );
1779    let name = path
1780        .file_name()
1781        .and_then(|name| name.to_str())
1782        .ok_or_else(|| Error::new("upload requires a valid file name"))?
1783        .to_string();
1784    let mut offset = 0_u64;
1785    loop {
1786        let mut data = vec![0; 256 * 1024];
1787        let count = source
1788            .read(&mut data)
1789            .map_err(|error| Error::new(error.to_string()))?;
1790        data.truncate(count);
1791        let last = offset + count as u64 >= size;
1792        handle
1793            .command_tx
1794            .send(ContextSessionCommand {
1795                surface_session_id: app.inner.surface_session_id.clone(),
1796                context_session_id: app.inner.session_id.clone(),
1797                command: Some(ContextCommand::UploadFileChunk(UploadFileChunkCommand {
1798                    transfer_id: transfer_id.clone(),
1799                    name: name.clone(),
1800                    offset,
1801                    data,
1802                    last,
1803                })),
1804            })
1805            .await
1806            .map_err(|_| Error::new("failed to send UploadFileChunkCommand"))?;
1807        offset += count as u64;
1808        if last {
1809            break;
1810        }
1811    }
1812    loop {
1813        let event = handle
1814            .events
1815            .message()
1816            .await?
1817            .ok_or_else(|| Error::new("Android app session closed while uploading file"))?;
1818        match event.event {
1819            Some(ContextEvent::FileUploaded(result)) if result.transfer_id == transfer_id => {
1820                return Ok(result.file_id);
1821            }
1822            Some(ContextEvent::Error(error)) => return Err(Error::new(error.message)),
1823            _ => {}
1824        }
1825    }
1826}
1827
1828async fn download_android_client_file(
1829    app: &NativeApp,
1830    handle: &mut AndroidTabHandle,
1831    file_id: &str,
1832    path: &Path,
1833) -> Result<()> {
1834    let temporary = path.with_extension(format!(
1835        "allwright-{}.tmp",
1836        MOBILE_TRANSFER_COUNTER.fetch_add(1, Ordering::Relaxed)
1837    ));
1838    let mut output = fs::OpenOptions::new()
1839        .write(true)
1840        .create_new(true)
1841        .open(&temporary)
1842        .map_err(|error| Error::new(error.to_string()))?;
1843    let mut offset = 0_u64;
1844    loop {
1845        handle
1846            .command_tx
1847            .send(ContextSessionCommand {
1848                surface_session_id: app.inner.surface_session_id.clone(),
1849                context_session_id: app.inner.session_id.clone(),
1850                command: Some(ContextCommand::ReadFileChunk(ReadFileChunkCommand {
1851                    file_id: file_id.to_string(),
1852                    offset,
1853                    max_bytes: 256 * 1024,
1854                })),
1855            })
1856            .await
1857            .map_err(|_| Error::new("failed to send ReadFileChunkCommand"))?;
1858        let event = handle
1859            .events
1860            .message()
1861            .await?
1862            .ok_or_else(|| Error::new("Android app session closed while downloading file"))?;
1863        match event.event {
1864            Some(ContextEvent::FileChunk(chunk))
1865                if chunk.file_id == file_id && chunk.offset == offset =>
1866            {
1867                output
1868                    .write_all(&chunk.data)
1869                    .map_err(|error| Error::new(error.to_string()))?;
1870                offset += chunk.data.len() as u64;
1871                if chunk.last {
1872                    drop(output);
1873                    fs::rename(&temporary, path).map_err(|error| Error::new(error.to_string()))?;
1874                    return Ok(());
1875                }
1876            }
1877            Some(ContextEvent::Error(error)) => {
1878                let _ = fs::remove_file(&temporary);
1879                return Err(Error::new(error.message));
1880            }
1881            _ => {}
1882        }
1883    }
1884}
1885
1886impl NativeLocator {
1887    pub fn app(&self) -> &NativeApp {
1888        &self.page
1889    }
1890
1891    pub fn selector(&self) -> &str {
1892        &self.selector
1893    }
1894
1895    pub fn locator(&self, selector: impl Into<String>) -> NativeLocator {
1896        NativeLocator {
1897            page: self.page.clone(),
1898            selector: chain_mobile_selector_for_transport(&self.selector, &selector.into()),
1899        }
1900    }
1901
1902    pub fn get_by_role(&self, role: impl Into<String>) -> NativeLocator {
1903        self.get_by_role_with_options(role, RoleOptions::default())
1904    }
1905
1906    pub fn get_by_role_with_options(
1907        &self,
1908        role: impl Into<String>,
1909        options: RoleOptions,
1910    ) -> NativeLocator {
1911        let mut spec = serde_json::to_value(options).expect("role options");
1912        spec["kind"] = serde_json::json!("role");
1913        spec["role"] = serde_json::json!(role.into());
1914        self.locator(mobile_semantic_selector(spec))
1915    }
1916
1917    pub fn get_by_text(&self, text: impl Into<TextMatcher>) -> NativeLocator {
1918        self.get_by_text_with_options(text, TextOptions::default())
1919    }
1920
1921    pub fn get_by_text_with_options(
1922        &self,
1923        text: impl Into<TextMatcher>,
1924        options: TextOptions,
1925    ) -> NativeLocator {
1926        self.locator(mobile_text_selector("text", text.into(), options))
1927    }
1928
1929    pub fn get_by_label(&self, text: impl Into<TextMatcher>) -> NativeLocator {
1930        self.get_by_label_with_options(text, TextOptions::default())
1931    }
1932
1933    pub fn get_by_label_with_options(
1934        &self,
1935        text: impl Into<TextMatcher>,
1936        options: TextOptions,
1937    ) -> NativeLocator {
1938        self.locator(mobile_text_selector("label", text.into(), options))
1939    }
1940
1941    pub fn get_by_test_id(&self, text: impl Into<TextMatcher>) -> NativeLocator {
1942        self.locator(mobile_semantic_selector(
1943            serde_json::json!({"kind":"testId", "text":text.into()}),
1944        ))
1945    }
1946
1947    pub async fn click(&self, options: CommandOptions) -> Result<()> {
1948        self.page.click(&self.selector, options).await
1949    }
1950
1951    pub async fn click_with_options(&self, options: ClickOptions) -> Result<()> {
1952        self.page.click_with_options(&self.selector, options).await
1953    }
1954
1955    pub async fn count(&self, options: CommandOptions) -> Result<u32> {
1956        self.page.count(&self.selector, options).await
1957    }
1958
1959    pub async fn focus(&self, options: CommandOptions) -> Result<()> {
1960        self.page.focus(&self.selector, options).await
1961    }
1962
1963    pub async fn fill(&self, value: &str, options: CommandOptions) -> Result<()> {
1964        self.page.fill(&self.selector, value, options).await
1965    }
1966
1967    pub async fn press(&self, key: &str, options: PressOptions) -> Result<()> {
1968        self.page.press(&self.selector, key, options).await
1969    }
1970
1971    pub async fn text_content(&self, options: CommandOptions) -> Result<Option<String>> {
1972        self.page.text_content(&self.selector, options).await
1973    }
1974
1975    pub async fn inner_text(&self, options: CommandOptions) -> Result<String> {
1976        self.page.inner_text(&self.selector, options).await
1977    }
1978
1979    pub async fn input_value(&self, options: CommandOptions) -> Result<String> {
1980        self.page.input_value(&self.selector, options).await
1981    }
1982
1983    pub async fn is_checked(&self, options: CommandOptions) -> Result<bool> {
1984        self.page.is_checked(&self.selector, options).await
1985    }
1986
1987    pub async fn get_attribute(
1988        &self,
1989        name: &str,
1990        options: CommandOptions,
1991    ) -> Result<Option<String>> {
1992        self.page.get_attribute(&self.selector, name, options).await
1993    }
1994
1995    pub async fn bounding_box(&self, options: CommandOptions) -> Result<Option<BoundingBox>> {
1996        self.page.bounding_box(&self.selector, options).await
1997    }
1998
1999    pub async fn wait_for(&self, options: WaitForSelectorOptions) -> Result<()> {
2000        self.page.wait_for_selector(&self.selector, options).await
2001    }
2002}
2003
2004fn ensure_android_device_open(state: &AndroidDeviceState, session_id: &str) -> Result<()> {
2005    if state.closed {
2006        return Err(Error::new(format!(
2007            "android device session {} is closed",
2008            session_id
2009        )));
2010    }
2011    Ok(())
2012}
2013
2014fn ensure_android_app_open(handle: &AndroidTabHandle, session_id: &str) -> Result<()> {
2015    if handle.closed {
2016        return Err(Error::new(format!(
2017            "android app session {} is closed",
2018            session_id
2019        )));
2020    }
2021    Ok(())
2022}
2023
2024fn mobile_semantic_selector(spec: serde_json::Value) -> String {
2025    format!(
2026        "aw={}",
2027        serde_json::to_string(&spec.to_string()).expect("JSON string")
2028    )
2029}
2030
2031fn mobile_text_selector(kind: &str, text: TextMatcher, options: TextOptions) -> String {
2032    mobile_semantic_selector(serde_json::json!({"kind":kind, "text":text, "exact":options.exact}))
2033}
2034
2035#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2036enum MobileSelectorFlavor {
2037    Css,
2038    XPath,
2039    UiAutomator,
2040    Semantic,
2041}
2042
2043impl MobileSelectorFlavor {
2044    fn as_str(self) -> &'static str {
2045        match self {
2046            Self::Css => "css",
2047            Self::XPath => "xpath",
2048            Self::UiAutomator => "uia",
2049            Self::Semantic => "aw",
2050        }
2051    }
2052}
2053
2054const UIAUTOMATOR_SELECTOR_KEYS: &[&str] = &[
2055    "text",
2056    "textcontains",
2057    "textmatches",
2058    "textstartswith",
2059    "classname",
2060    "classnamematches",
2061    "description",
2062    "desc",
2063    "descriptioncontains",
2064    "desccontains",
2065    "descriptionmatches",
2066    "descmatches",
2067    "descriptionstartswith",
2068    "descstartswith",
2069    "checkable",
2070    "checked",
2071    "clickable",
2072    "longclickable",
2073    "scrollable",
2074    "enabled",
2075    "focusable",
2076    "focused",
2077    "selected",
2078    "packagename",
2079    "package",
2080    "packagenamematches",
2081    "resourceid",
2082    "resourceidmatches",
2083    "index",
2084    "instance",
2085];
2086
2087fn parse_explicit_mobile_selector_prefix(selector: &str) -> Option<(MobileSelectorFlavor, usize)> {
2088    let lowered = selector.to_ascii_lowercase();
2089    if lowered.starts_with("xpath=") || lowered.starts_with("xpath:") {
2090        return Some((MobileSelectorFlavor::XPath, 6));
2091    }
2092    if lowered.starts_with("uia=") || lowered.starts_with("uia:") {
2093        return Some((MobileSelectorFlavor::UiAutomator, 4));
2094    }
2095    if lowered.starts_with("aw=") || lowered.starts_with("aw:") {
2096        return Some((MobileSelectorFlavor::Semantic, 3));
2097    }
2098    if let Some(prefix_len) = parse_ui_automator_selector_prefix(&lowered) {
2099        return Some((MobileSelectorFlavor::UiAutomator, prefix_len));
2100    }
2101    if lowered.starts_with("text=") || lowered.starts_with("text:") {
2102        return Some((MobileSelectorFlavor::UiAutomator, 5));
2103    }
2104    if lowered.starts_with("id=") || lowered.starts_with("id:") {
2105        return Some((MobileSelectorFlavor::Css, 3));
2106    }
2107    if lowered.starts_with("css=") || lowered.starts_with("css:") {
2108        return Some((MobileSelectorFlavor::Css, 4));
2109    }
2110    None
2111}
2112
2113fn parse_ui_automator_selector_prefix(selector: &str) -> Option<usize> {
2114    UIAUTOMATOR_SELECTOR_KEYS.iter().find_map(|key| {
2115        if selector.starts_with(key) {
2116            let separator = selector.as_bytes().get(key.len()).copied()?;
2117            if separator == b'=' || separator == b':' {
2118                return Some(key.len() + 1);
2119            }
2120        }
2121        None
2122    })
2123}
2124
2125fn find_json_string_end(value: &str) -> Option<usize> {
2126    let bytes = value.as_bytes();
2127    if bytes.first().copied()? != b'"' {
2128        return None;
2129    }
2130
2131    let mut index = 1usize;
2132    let mut escaped = false;
2133    while index < bytes.len() {
2134        let byte = bytes[index];
2135        if escaped {
2136            escaped = false;
2137            index += 1;
2138            continue;
2139        }
2140        match byte {
2141            b'\\' => escaped = true,
2142            b'"' => return Some(index + 1),
2143            _ => {}
2144        }
2145        index += 1;
2146    }
2147    None
2148}
2149
2150fn is_normalized_mobile_transport_selector(selector: &str) -> bool {
2151    let trimmed = selector.trim();
2152    if trimmed.is_empty() {
2153        return false;
2154    }
2155
2156    let mut index = 0usize;
2157    while index < trimmed.len() {
2158        let Some((_, prefix_len)) = parse_explicit_mobile_selector_prefix(&trimmed[index..]) else {
2159            return false;
2160        };
2161        index += prefix_len;
2162
2163        let remainder = &trimmed[index..];
2164        let Some(json_end) = find_json_string_end(remainder) else {
2165            return false;
2166        };
2167        index += json_end;
2168
2169        if index == trimmed.len() {
2170            return true;
2171        }
2172
2173        let whitespace_len = trimmed[index..]
2174            .chars()
2175            .take_while(|char| char.is_ascii_whitespace())
2176            .count();
2177        if whitespace_len == 0 {
2178            return false;
2179        }
2180        index += whitespace_len;
2181
2182        if parse_explicit_mobile_selector_prefix(&trimmed[index..]).is_none() {
2183            return false;
2184        }
2185    }
2186
2187    true
2188}
2189
2190fn decode_selector_body(body: &str) -> String {
2191    let candidate = body.trim();
2192    if candidate.len() >= 2 && candidate.starts_with('"') && candidate.ends_with('"') {
2193        if let Ok(decoded) = serde_json::from_str::<String>(candidate) {
2194            return unescape_shell_escaped_selector(&decoded);
2195        }
2196    }
2197    unescape_shell_escaped_selector(candidate)
2198}
2199
2200fn unescape_shell_escaped_selector(value: &str) -> String {
2201    let mut result = String::with_capacity(value.len());
2202    let mut chars = value.chars().peekable();
2203    while let Some(ch) = chars.next() {
2204        if ch == '\\' {
2205            match chars.peek().copied() {
2206                Some('_' | ' ' | '#' | ':' | '[' | ']' | '(' | ')' | '"' | '\'') => {
2207                    result.push(chars.next().expect("peeked char should exist"));
2208                    continue;
2209                }
2210                _ => {}
2211            }
2212        }
2213        result.push(ch);
2214    }
2215    result
2216}
2217
2218fn parse_mobile_selector_for_transport(selector: &str) -> (MobileSelectorFlavor, String) {
2219    let trimmed = selector.trim();
2220    if let Some((flavor, prefix_len)) = parse_explicit_mobile_selector_prefix(trimmed) {
2221        let body = decode_selector_body(&trimmed[prefix_len..]);
2222        return match flavor {
2223            MobileSelectorFlavor::Css if prefix_len == 3 => {
2224                let normalized = if body.starts_with('#') {
2225                    body
2226                } else {
2227                    format!("#{body}")
2228                };
2229                (MobileSelectorFlavor::Css, normalized)
2230            }
2231            MobileSelectorFlavor::UiAutomator
2232                if prefix_len != 4 && !trimmed[..prefix_len].eq_ignore_ascii_case("text=") =>
2233            {
2234                (
2235                    MobileSelectorFlavor::UiAutomator,
2236                    format!("{}={body}", &trimmed[..prefix_len - 1]),
2237                )
2238            }
2239            MobileSelectorFlavor::UiAutomator if prefix_len == 5 => {
2240                (MobileSelectorFlavor::UiAutomator, format!("text={body}"))
2241            }
2242            _ => (flavor, body),
2243        };
2244    }
2245
2246    if trimmed.starts_with("//")
2247        || trimmed.starts_with(".//")
2248        || trimmed.starts_with("../")
2249        || trimmed.starts_with('/')
2250        || trimmed.starts_with('(')
2251    {
2252        return (MobileSelectorFlavor::XPath, trimmed.to_string());
2253    }
2254
2255    (MobileSelectorFlavor::Css, trimmed.to_string())
2256}
2257
2258fn normalize_mobile_selector_for_transport(selector: &str) -> String {
2259    let trimmed = selector.trim();
2260    if trimmed.is_empty() {
2261        return String::new();
2262    }
2263    let lowered = trimmed.to_ascii_lowercase();
2264    if lowered.starts_with("ref=") || lowered.starts_with("ref:") {
2265        return format!("ref={}", decode_selector_body(&trimmed[4..]));
2266    }
2267    if is_normalized_mobile_transport_selector(trimmed) {
2268        return trimmed.to_string();
2269    }
2270    let (flavor, body) = parse_mobile_selector_for_transport(selector);
2271    format!(
2272        "{}={}",
2273        flavor.as_str(),
2274        serde_json::to_string(&body).unwrap_or_else(|_| format!("{body:?}"))
2275    )
2276}
2277
2278fn chain_mobile_selector_for_transport(parent: &str, child: &str) -> String {
2279    let parent = if parent.trim().is_empty() {
2280        String::new()
2281    } else {
2282        normalize_mobile_selector_for_transport(parent)
2283    };
2284    let child = if child.trim().is_empty() {
2285        String::new()
2286    } else {
2287        normalize_mobile_selector_for_transport(child)
2288    };
2289    if parent.is_empty() {
2290        return child;
2291    }
2292    if child.is_empty() {
2293        return parent;
2294    }
2295    format!("{parent} {child}")
2296}
2297
2298impl NativeApp {
2299    pub async fn accessibility_snapshot(&self) -> Result<String> {
2300        self.accessibility_snapshot_with_options(AccessibilitySnapshotOptions::default())
2301            .await
2302    }
2303
2304    pub async fn accessibility_snapshot_with_options(
2305        &self,
2306        options: AccessibilitySnapshotOptions,
2307    ) -> Result<String> {
2308        let mut state = self.inner.state.lock().await;
2309        let handle = self.ensure_handle(&mut state).await?;
2310        ensure_android_app_open(handle, &self.inner.session_id)?;
2311        handle
2312            .command_tx
2313            .send(ContextSessionCommand {
2314                surface_session_id: self.inner.surface_session_id.clone(),
2315                context_session_id: self.inner.session_id.clone(),
2316                command: Some(ContextCommand::AccessibilitySnapshot(
2317                    AccessibilitySnapshotCommand {
2318                        format: match options.format {
2319                            AccessibilitySnapshotFormat::Json => "json",
2320                            AccessibilitySnapshotFormat::Yaml => "yaml",
2321                        }
2322                        .into(),
2323                        mode: match options.mode {
2324                            AccessibilitySnapshotMode::Default => "default",
2325                            AccessibilitySnapshotMode::Ai => "ai",
2326                            AccessibilitySnapshotMode::Autoexpect => "autoexpect",
2327                            AccessibilitySnapshotMode::Codegen => "codegen",
2328                        }
2329                        .into(),
2330                        retry_options: command_retry_options(options.timeout_ms),
2331                    },
2332                )),
2333            })
2334            .await
2335            .map_err(|_| Error::new("failed to send AccessibilitySnapshotCommand"))?;
2336        loop {
2337            let event = handle.events.message().await?.ok_or_else(|| {
2338                Error::new("android app session closed while capturing accessibility snapshot")
2339            })?;
2340            match event.event {
2341                Some(ContextEvent::AccessibilitySnapshotCaptured(result)) => {
2342                    return Ok(result.snapshot);
2343                }
2344                Some(ContextEvent::Error(error)) => return Err(Error::new(error.message)),
2345                Some(ContextEvent::Closed(_)) => {
2346                    handle.closed = true;
2347                    return Err(Error::new(
2348                        "android app session closed while capturing accessibility snapshot",
2349                    ));
2350                }
2351                _ => {}
2352            }
2353        }
2354    }
2355}