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