Skip to main content

allwright/
client_mobile.rs

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