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