Skip to main content

allwright/
client_mobile.rs

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