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, PressKeyCommand, ReadFileChunkCommand,
21    RegisterHookCommand, RegisterMobileDownloadHook, RegisterMobileFileChooserHook,
22    SaveMobileDownloadCommand, ScreenshotCommand, SetMobileFileChooserFilesCommand,
23    SurfaceSessionCommand, UploadFileChunkCommand, WaitForHookCommand, WaitForSelectorCommand,
24};
25
26use super::hook::{DownloadHook, FileChooserHook};
27
28static MOBILE_TRANSFER_COUNTER: AtomicU64 = AtomicU64::new(1);
29
30pub trait AndroidHookType: private::Sealed + Clone + Send + Sync + 'static {
31    type Output;
32}
33
34mod private {
35    use super::*;
36    pub trait Sealed {
37        fn name() -> &'static str;
38        fn decode(
39            app: &AndroidApp,
40            result: HookCompletionResult,
41        ) -> Result<<Self as AndroidHookType>::Output>
42        where
43            Self: AndroidHookType;
44    }
45}
46
47impl AndroidHookType for FileChooserHook {
48    type Output = AndroidFileChooser;
49}
50impl private::Sealed for FileChooserHook {
51    fn name() -> &'static str {
52        "file_chooser"
53    }
54    fn decode(app: &AndroidApp, result: HookCompletionResult) -> Result<AndroidFileChooser> {
55        let HookCompletionResult::MobileFileChooser(result) = result else {
56            return Err(Error::new(
57                "Android file chooser hook returned an invalid result",
58            ));
59        };
60        Ok(AndroidFileChooser {
61            app: app.clone(),
62            id: result.file_chooser_id,
63            is_multiple: result.is_multiple,
64        })
65    }
66}
67impl AndroidHookType for DownloadHook {
68    type Output = AndroidDownload;
69}
70impl private::Sealed for DownloadHook {
71    fn name() -> &'static str {
72        "download"
73    }
74    fn decode(app: &AndroidApp, result: HookCompletionResult) -> Result<AndroidDownload> {
75        let HookCompletionResult::MobileDownload(result) = result else {
76            return Err(Error::new(
77                "Android download hook returned an invalid result",
78            ));
79        };
80        Ok(AndroidDownload {
81            app: app.clone(),
82            id: result.download_id,
83            suggested_filename: result.suggested_filename,
84        })
85    }
86}
87
88pub struct AndroidHook<T: AndroidHookType> {
89    app: AndroidApp,
90    id: String,
91    _type: PhantomData<T>,
92}
93
94#[derive(Clone)]
95pub struct AndroidFileChooser {
96    app: AndroidApp,
97    id: String,
98    is_multiple: bool,
99}
100
101#[derive(Clone)]
102pub struct AndroidDownload {
103    app: AndroidApp,
104    id: String,
105    suggested_filename: String,
106}
107use tokio::sync::{Mutex as AsyncMutex, mpsc};
108use tokio_stream::wrappers::ReceiverStream;
109
110use super::command::command_retry_options;
111use super::runtime::get_runtime;
112use super::types::{
113    AccessibilitySnapshotFormat, AccessibilitySnapshotMode, AccessibilitySnapshotOptions,
114    ClickResult, CommandOptions, CountResult, ElementResult, Error, FillResult, PressOptions,
115    PressResult, Result, RuntimeClient, ScreenshotOptions, ScreenshotResult, TextResult,
116    WaitForSelectorOptions, WaitForSelectorResult,
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 click(&self, selector: &str, options: CommandOptions) -> Result<ClickResult> {
558        let selector = normalize_mobile_selector_for_transport(selector);
559        let mut state = self.inner.state.lock().await;
560        let handle = self.ensure_handle(&mut state).await?;
561        ensure_android_app_open(handle, &self.inner.session_id)?;
562
563        handle
564            .command_tx
565            .send(ContextSessionCommand {
566                surface_session_id: self.inner.surface_session_id.clone(),
567                context_session_id: self.inner.session_id.clone(),
568                command: Some(ContextCommand::ClickElement(ClickElementCommand {
569                    css_selector: selector.clone(),
570                    retry_options: command_retry_options(options.timeout_ms),
571                })),
572            })
573            .await
574            .map_err(|_| Error::new("failed to send ClickElementCommand"))?;
575
576        loop {
577            let event =
578                handle.events.message().await?.ok_or_else(|| {
579                    Error::new("app session closed while waiting for click result")
580                })?;
581
582            match event.event {
583                Some(ContextEvent::Attached(_)) => {}
584                Some(ContextEvent::ElementClicked(clicked)) => {
585                    return Ok(ClickResult {
586                        selector: clicked.css_selector,
587                        note: clicked.note,
588                        bidi_session_id: clicked.bidi_session_id,
589                    });
590                }
591                Some(ContextEvent::Error(error)) => {
592                    return Err(Error::new(format!(
593                        "app session error while clicking Android locator {:?}: {}",
594                        selector, error.message,
595                    )));
596                }
597                Some(ContextEvent::Closed(_)) => {
598                    handle.closed = true;
599                    return Err(Error::new(format!(
600                        "app session {} closed while waiting for click result",
601                        self.inner.session_id
602                    )));
603                }
604                _ => {}
605            }
606        }
607    }
608
609    pub async fn fill(
610        &self,
611        selector: &str,
612        value: &str,
613        options: CommandOptions,
614    ) -> Result<FillResult> {
615        let selector = normalize_mobile_selector_for_transport(selector);
616        let mut state = self.inner.state.lock().await;
617        let handle = self.ensure_handle(&mut state).await?;
618        ensure_android_app_open(handle, &self.inner.session_id)?;
619
620        handle
621            .command_tx
622            .send(ContextSessionCommand {
623                surface_session_id: self.inner.surface_session_id.clone(),
624                context_session_id: self.inner.session_id.clone(),
625                command: Some(ContextCommand::FillElement(FillElementCommand {
626                    css_selector: selector.clone(),
627                    value: value.to_string(),
628                    retry_options: command_retry_options(options.timeout_ms),
629                })),
630            })
631            .await
632            .map_err(|_| Error::new("failed to send FillElementCommand"))?;
633
634        loop {
635            let event =
636                handle.events.message().await?.ok_or_else(|| {
637                    Error::new("app session closed while waiting for fill result")
638                })?;
639
640            match event.event {
641                Some(ContextEvent::Attached(_)) => {}
642                Some(ContextEvent::ElementFilled(filled)) => {
643                    return Ok(FillResult {
644                        selector: filled.css_selector,
645                        value: filled.value,
646                        note: filled.note,
647                    });
648                }
649                Some(ContextEvent::Error(error)) => {
650                    return Err(Error::new(format!(
651                        "app session error while filling Android locator {:?}: {}",
652                        selector, error.message,
653                    )));
654                }
655                Some(ContextEvent::Closed(_)) => {
656                    handle.closed = true;
657                    return Err(Error::new(format!(
658                        "app session {} closed while waiting for fill result",
659                        self.inner.session_id
660                    )));
661                }
662                _ => {}
663            }
664        }
665    }
666
667    pub async fn count(&self, selector: &str, options: CommandOptions) -> Result<CountResult> {
668        let selector = normalize_mobile_selector_for_transport(selector);
669        let mut state = self.inner.state.lock().await;
670        let handle = self.ensure_handle(&mut state).await?;
671        ensure_android_app_open(handle, &self.inner.session_id)?;
672
673        handle
674            .command_tx
675            .send(ContextSessionCommand {
676                surface_session_id: self.inner.surface_session_id.clone(),
677                context_session_id: self.inner.session_id.clone(),
678                command: Some(ContextCommand::CountElements(CountElementsCommand {
679                    css_selector: selector.clone(),
680                    retry_options: command_retry_options(options.timeout_ms),
681                })),
682            })
683            .await
684            .map_err(|_| Error::new("failed to send CountElementsCommand"))?;
685
686        loop {
687            let event =
688                handle.events.message().await?.ok_or_else(|| {
689                    Error::new("app session closed while waiting for count result")
690                })?;
691
692            match event.event {
693                Some(ContextEvent::Attached(_)) => {}
694                Some(ContextEvent::ElementCounted(counted)) => {
695                    return Ok(CountResult {
696                        selector: counted.css_selector,
697                        count: counted.count,
698                        note: counted.note,
699                    });
700                }
701                Some(ContextEvent::Error(error)) => {
702                    return Err(Error::new(format!(
703                        "app session error while counting Android locator {:?}: {}",
704                        selector, error.message,
705                    )));
706                }
707                Some(ContextEvent::Closed(_)) => {
708                    handle.closed = true;
709                    return Err(Error::new(format!(
710                        "app session {} closed while waiting for count result",
711                        self.inner.session_id
712                    )));
713                }
714                _ => {}
715            }
716        }
717    }
718
719    pub async fn focus(&self, selector: &str, options: CommandOptions) -> Result<ElementResult> {
720        let selector = normalize_mobile_selector_for_transport(selector);
721        let mut state = self.inner.state.lock().await;
722        let handle = self.ensure_handle(&mut state).await?;
723        ensure_android_app_open(handle, &self.inner.session_id)?;
724
725        handle
726            .command_tx
727            .send(ContextSessionCommand {
728                surface_session_id: self.inner.surface_session_id.clone(),
729                context_session_id: self.inner.session_id.clone(),
730                command: Some(ContextCommand::FocusElement(FocusElementCommand {
731                    css_selector: selector.clone(),
732                    retry_options: command_retry_options(options.timeout_ms),
733                })),
734            })
735            .await
736            .map_err(|_| Error::new("failed to send FocusElementCommand"))?;
737
738        loop {
739            let event =
740                handle.events.message().await?.ok_or_else(|| {
741                    Error::new("app session closed while waiting for focus result")
742                })?;
743
744            match event.event {
745                Some(ContextEvent::Attached(_)) => {}
746                Some(ContextEvent::ElementFocused(focused)) => {
747                    return Ok(ElementResult {
748                        selector: focused.css_selector,
749                        note: focused.note,
750                    });
751                }
752                Some(ContextEvent::Error(error)) => {
753                    return Err(Error::new(format!(
754                        "app session error while focusing Android locator {:?}: {}",
755                        selector, error.message,
756                    )));
757                }
758                Some(ContextEvent::Closed(_)) => {
759                    handle.closed = true;
760                    return Err(Error::new(format!(
761                        "app session {} closed while waiting for focus result",
762                        self.inner.session_id
763                    )));
764                }
765                _ => {}
766            }
767        }
768    }
769
770    pub async fn press(
771        &self,
772        selector: &str,
773        key: &str,
774        options: PressOptions,
775    ) -> Result<PressResult> {
776        let selector = normalize_mobile_selector_for_transport(selector);
777        let mut state = self.inner.state.lock().await;
778        let handle = self.ensure_handle(&mut state).await?;
779        ensure_android_app_open(handle, &self.inner.session_id)?;
780
781        handle
782            .command_tx
783            .send(ContextSessionCommand {
784                surface_session_id: self.inner.surface_session_id.clone(),
785                context_session_id: self.inner.session_id.clone(),
786                command: Some(ContextCommand::PressKey(PressKeyCommand {
787                    css_selector: selector.clone(),
788                    key: key.to_string(),
789                    text: options.text,
790                    retry_options: command_retry_options(options.timeout_ms),
791                })),
792            })
793            .await
794            .map_err(|_| Error::new("failed to send PressKeyCommand"))?;
795
796        loop {
797            let event =
798                handle.events.message().await?.ok_or_else(|| {
799                    Error::new("app session closed while waiting for press result")
800                })?;
801
802            match event.event {
803                Some(ContextEvent::Attached(_)) => {}
804                Some(ContextEvent::KeyPressed(pressed)) => {
805                    return Ok(PressResult {
806                        selector: pressed.css_selector,
807                        key: pressed.key,
808                        note: pressed.note,
809                    });
810                }
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<TextResult> {
834        self.read_text(selector, options, true).await
835    }
836
837    pub async fn inner_text(&self, selector: &str, options: CommandOptions) -> Result<TextResult> {
838        self.read_text(selector, options, false).await
839    }
840
841    pub async fn wait_for_selector(
842        &self,
843        selector: &str,
844        options: WaitForSelectorOptions,
845    ) -> Result<WaitForSelectorResult> {
846        let selector = normalize_mobile_selector_for_transport(selector);
847        let mut state = self.inner.state.lock().await;
848        let handle = self.ensure_handle(&mut state).await?;
849        ensure_android_app_open(handle, &self.inner.session_id)?;
850
851        handle
852            .command_tx
853            .send(ContextSessionCommand {
854                surface_session_id: self.inner.surface_session_id.clone(),
855                context_session_id: self.inner.session_id.clone(),
856                command: Some(ContextCommand::WaitForSelector(WaitForSelectorCommand {
857                    css_selector: selector.clone(),
858                    visible: options.visible,
859                    retry_options: command_retry_options(options.timeout_ms),
860                })),
861            })
862            .await
863            .map_err(|_| Error::new("failed to send WaitForSelectorCommand"))?;
864
865        loop {
866            let event = handle.events.message().await?.ok_or_else(|| {
867                Error::new("app session closed while waiting for selector result")
868            })?;
869
870            match event.event {
871                Some(ContextEvent::Attached(_)) => {}
872                Some(ContextEvent::SelectorWaitSatisfied(wait)) => {
873                    return Ok(WaitForSelectorResult {
874                        selector: wait.css_selector,
875                        visible: wait.visible,
876                        note: wait.note,
877                    });
878                }
879                Some(ContextEvent::Error(error)) => {
880                    return Err(Error::new(format!(
881                        "app session error while waiting for Android locator {:?}: {}",
882                        selector, error.message,
883                    )));
884                }
885                Some(ContextEvent::Closed(_)) => {
886                    handle.closed = true;
887                    return Err(Error::new(format!(
888                        "app session {} closed while waiting for selector result",
889                        self.inner.session_id
890                    )));
891                }
892                _ => {}
893            }
894        }
895    }
896
897    pub async fn screenshot(&self) -> Result<ScreenshotResult> {
898        self.screenshot_with_options(ScreenshotOptions::default())
899            .await
900    }
901
902    pub async fn screenshot_with_options(
903        &self,
904        options: ScreenshotOptions,
905    ) -> Result<ScreenshotResult> {
906        let mut state = self.inner.state.lock().await;
907        let handle = self.ensure_handle(&mut state).await?;
908        ensure_android_app_open(handle, &self.inner.session_id)?;
909
910        handle
911            .command_tx
912            .send(ContextSessionCommand {
913                surface_session_id: self.inner.surface_session_id.clone(),
914                context_session_id: self.inner.session_id.clone(),
915                command: Some(ContextCommand::Screenshot(ScreenshotCommand {
916                    retry_options: command_retry_options(options.timeout_ms),
917                    full_page: Some(options.full_page),
918                })),
919            })
920            .await
921            .map_err(|_| Error::new("failed to send ScreenshotCommand"))?;
922
923        loop {
924            let event = handle.events.message().await?.ok_or_else(|| {
925                Error::new("app session closed while waiting for screenshot result")
926            })?;
927
928            match event.event {
929                Some(ContextEvent::Attached(_)) => {}
930                Some(ContextEvent::ScreenshotCaptured(screenshot)) => {
931                    let result = ScreenshotResult {
932                        png_data: screenshot.png_data,
933                        note: screenshot.note,
934                    };
935                    if let Some(path) = options.path.as_ref() {
936                        std::fs::write(path, &result.png_data).map_err(|error| {
937                            Error::new(format!("write screenshot to {}: {error}", path.display()))
938                        })?;
939                    }
940                    return Ok(result);
941                }
942                Some(ContextEvent::Error(error)) => {
943                    return Err(Error::new(format!(
944                        "app session error while capturing Android screenshot: {}",
945                        error.message
946                    )));
947                }
948                Some(ContextEvent::Closed(_)) => {
949                    handle.closed = true;
950                    return Err(Error::new(format!(
951                        "app session {} closed while waiting for screenshot result",
952                        self.inner.session_id
953                    )));
954                }
955                _ => {}
956            }
957        }
958    }
959
960    async fn ensure_handle<'a>(
961        &self,
962        state: &'a mut AndroidAppState,
963    ) -> Result<&'a mut AndroidTabHandle> {
964        if state.handle.is_none() {
965            let mut engine = self.inner.runtime.engine.clone();
966            let (command_tx, command_rx) = mpsc::channel(16);
967            let response = engine
968                .context_session(tonic::Request::new(ReceiverStream::new(command_rx)))
969                .await?;
970            state.handle = Some(AndroidTabHandle {
971                command_tx,
972                events: response.into_inner(),
973                closed: false,
974            });
975        }
976
977        state
978            .handle
979            .as_mut()
980            .ok_or_else(|| Error::new("android app session handle was not initialized"))
981    }
982
983    async fn read_text(
984        &self,
985        selector: &str,
986        options: CommandOptions,
987        text_content: bool,
988    ) -> Result<TextResult> {
989        let selector = normalize_mobile_selector_for_transport(selector);
990        let mut state = self.inner.state.lock().await;
991        let handle = self.ensure_handle(&mut state).await?;
992        ensure_android_app_open(handle, &self.inner.session_id)?;
993
994        let command = if text_content {
995            ContextCommand::GetTextContent(GetTextContentCommand {
996                css_selector: selector.clone(),
997                retry_options: command_retry_options(options.timeout_ms),
998            })
999        } else {
1000            ContextCommand::GetInnerText(GetInnerTextCommand {
1001                css_selector: selector.clone(),
1002                retry_options: command_retry_options(options.timeout_ms),
1003            })
1004        };
1005
1006        handle
1007            .command_tx
1008            .send(ContextSessionCommand {
1009                surface_session_id: self.inner.surface_session_id.clone(),
1010                context_session_id: self.inner.session_id.clone(),
1011                command: Some(command),
1012            })
1013            .await
1014            .map_err(|_| Error::new("failed to send text read command"))?;
1015
1016        loop {
1017            let event =
1018                handle.events.message().await?.ok_or_else(|| {
1019                    Error::new("app session closed while waiting for text result")
1020                })?;
1021
1022            match event.event {
1023                Some(ContextEvent::Attached(_)) => {}
1024                Some(ContextEvent::TextContentResolved(text)) => {
1025                    return Ok(TextResult {
1026                        selector: text.css_selector,
1027                        text: text.text,
1028                        note: text.note,
1029                    });
1030                }
1031                Some(ContextEvent::InnerTextResolved(text)) => {
1032                    return Ok(TextResult {
1033                        selector: text.css_selector,
1034                        text: text.text,
1035                        note: text.note,
1036                    });
1037                }
1038                Some(ContextEvent::Error(error)) => {
1039                    return Err(Error::new(format!(
1040                        "app session error while reading Android text for {:?}: {}",
1041                        selector, error.message,
1042                    )));
1043                }
1044                Some(ContextEvent::Closed(_)) => {
1045                    handle.closed = true;
1046                    return Err(Error::new(format!(
1047                        "app session {} closed while waiting for text result",
1048                        self.inner.session_id
1049                    )));
1050                }
1051                _ => {}
1052            }
1053        }
1054    }
1055}
1056
1057impl<T: AndroidHookType> AndroidHook<T> {
1058    pub fn id(&self) -> &str {
1059        &self.id
1060    }
1061
1062    pub async fn wait(&self) -> Result<T::Output> {
1063        self.wait_with_options(CommandOptions::default()).await
1064    }
1065
1066    pub async fn wait_with_options(&self, options: CommandOptions) -> Result<T::Output> {
1067        let mut state = self.app.inner.state.lock().await;
1068        let handle = self.app.ensure_handle(&mut state).await?;
1069        handle
1070            .command_tx
1071            .send(ContextSessionCommand {
1072                surface_session_id: self.app.inner.surface_session_id.clone(),
1073                context_session_id: self.app.inner.session_id.clone(),
1074                command: Some(ContextCommand::WaitForHook(WaitForHookCommand {
1075                    hook_id: self.id.clone(),
1076                    retry_options: command_retry_options(options.timeout_ms),
1077                })),
1078            })
1079            .await
1080            .map_err(|_| Error::new("failed to send Android WaitForHookCommand"))?;
1081        loop {
1082            let event =
1083                handle.events.message().await?.ok_or_else(|| {
1084                    Error::new("Android app session closed while waiting for hook")
1085                })?;
1086            match event.event {
1087                Some(ContextEvent::HookCompleted(event)) if event.hook_id == self.id => {
1088                    return T::decode(
1089                        &self.app,
1090                        event
1091                            .result
1092                            .ok_or_else(|| Error::new("Android hook completed without a result"))?,
1093                    );
1094                }
1095                Some(ContextEvent::Error(error)) => return Err(Error::new(error.message)),
1096                _ => {}
1097            }
1098        }
1099    }
1100}
1101
1102impl AndroidFileChooser {
1103    pub fn id(&self) -> &str {
1104        &self.id
1105    }
1106    pub fn app(&self) -> &AndroidApp {
1107        &self.app
1108    }
1109    pub fn is_multiple(&self) -> bool {
1110        self.is_multiple
1111    }
1112    pub async fn set_file(&self, path: impl AsRef<Path>) -> Result<()> {
1113        self.set_files([path]).await
1114    }
1115    pub async fn set_files<I, P>(&self, paths: I) -> Result<()>
1116    where
1117        I: IntoIterator<Item = P>,
1118        P: AsRef<Path>,
1119    {
1120        let paths = paths
1121            .into_iter()
1122            .map(|path| path.as_ref().to_path_buf())
1123            .collect::<Vec<_>>();
1124        if !self.is_multiple && paths.len() > 1 {
1125            return Err(Error::new(
1126                "Android file chooser does not accept multiple files",
1127            ));
1128        }
1129        let mut state = self.app.inner.state.lock().await;
1130        let handle = self.app.ensure_handle(&mut state).await?;
1131        let mut file_ids = Vec::with_capacity(paths.len());
1132        for path in paths {
1133            file_ids.push(upload_android_client_file(&self.app, handle, &path).await?);
1134        }
1135        handle
1136            .command_tx
1137            .send(ContextSessionCommand {
1138                surface_session_id: self.app.inner.surface_session_id.clone(),
1139                context_session_id: self.app.inner.session_id.clone(),
1140                command: Some(ContextCommand::SetMobileFileChooserFiles(
1141                    SetMobileFileChooserFilesCommand {
1142                        file_chooser_id: self.id.clone(),
1143                        file_ids,
1144                        retry_options: None,
1145                    },
1146                )),
1147            })
1148            .await
1149            .map_err(|_| Error::new("failed to send SetMobileFileChooserFilesCommand"))?;
1150        loop {
1151            let event = handle.events.message().await?.ok_or_else(|| {
1152                Error::new("Android app session closed while setting chooser files")
1153            })?;
1154            match event.event {
1155                Some(ContextEvent::MobileFileChooserFilesSet(result))
1156                    if result.file_chooser_id == self.id =>
1157                {
1158                    return Ok(());
1159                }
1160                Some(ContextEvent::Error(error)) => return Err(Error::new(error.message)),
1161                _ => {}
1162            }
1163        }
1164    }
1165}
1166
1167impl AndroidDownload {
1168    pub fn id(&self) -> &str {
1169        &self.id
1170    }
1171    pub fn app(&self) -> &AndroidApp {
1172        &self.app
1173    }
1174    pub fn suggested_filename(&self) -> &str {
1175        &self.suggested_filename
1176    }
1177    pub async fn save_as(&self, path: impl AsRef<Path>) -> Result<()> {
1178        let mut state = self.app.inner.state.lock().await;
1179        let handle = self.app.ensure_handle(&mut state).await?;
1180        handle
1181            .command_tx
1182            .send(ContextSessionCommand {
1183                surface_session_id: self.app.inner.surface_session_id.clone(),
1184                context_session_id: self.app.inner.session_id.clone(),
1185                command: Some(ContextCommand::SaveMobileDownload(
1186                    SaveMobileDownloadCommand {
1187                        download_id: self.id.clone(),
1188                        retry_options: None,
1189                    },
1190                )),
1191            })
1192            .await
1193            .map_err(|_| Error::new("failed to send SaveMobileDownloadCommand"))?;
1194        loop {
1195            let event =
1196                handle.events.message().await?.ok_or_else(|| {
1197                    Error::new("Android app session closed while saving download")
1198                })?;
1199            match event.event {
1200                Some(ContextEvent::MobileDownloadSaved(result))
1201                    if result.download_id == self.id =>
1202                {
1203                    return download_android_client_file(
1204                        &self.app,
1205                        handle,
1206                        &result.file_id,
1207                        path.as_ref(),
1208                    )
1209                    .await;
1210                }
1211                Some(ContextEvent::Error(error)) => return Err(Error::new(error.message)),
1212                _ => {}
1213            }
1214        }
1215    }
1216}
1217
1218async fn upload_android_client_file(
1219    app: &AndroidApp,
1220    handle: &mut AndroidTabHandle,
1221    path: &Path,
1222) -> Result<String> {
1223    let mut source = fs::File::open(path)
1224        .map_err(|error| Error::new(format!("open upload {}: {error}", path.display())))?;
1225    let size = source
1226        .metadata()
1227        .map_err(|error| Error::new(error.to_string()))?
1228        .len();
1229    let transfer_id = format!(
1230        "rust-mobile-upload-{}-{}",
1231        std::process::id(),
1232        MOBILE_TRANSFER_COUNTER.fetch_add(1, Ordering::Relaxed)
1233    );
1234    let name = path
1235        .file_name()
1236        .and_then(|name| name.to_str())
1237        .ok_or_else(|| Error::new("upload requires a valid file name"))?
1238        .to_string();
1239    let mut offset = 0_u64;
1240    loop {
1241        let mut data = vec![0; 256 * 1024];
1242        let count = source
1243            .read(&mut data)
1244            .map_err(|error| Error::new(error.to_string()))?;
1245        data.truncate(count);
1246        let last = offset + count as u64 >= size;
1247        handle
1248            .command_tx
1249            .send(ContextSessionCommand {
1250                surface_session_id: app.inner.surface_session_id.clone(),
1251                context_session_id: app.inner.session_id.clone(),
1252                command: Some(ContextCommand::UploadFileChunk(UploadFileChunkCommand {
1253                    transfer_id: transfer_id.clone(),
1254                    name: name.clone(),
1255                    offset,
1256                    data,
1257                    last,
1258                })),
1259            })
1260            .await
1261            .map_err(|_| Error::new("failed to send UploadFileChunkCommand"))?;
1262        offset += count as u64;
1263        if last {
1264            break;
1265        }
1266    }
1267    loop {
1268        let event = handle
1269            .events
1270            .message()
1271            .await?
1272            .ok_or_else(|| Error::new("Android app session closed while uploading file"))?;
1273        match event.event {
1274            Some(ContextEvent::FileUploaded(result)) if result.transfer_id == transfer_id => {
1275                return Ok(result.file_id);
1276            }
1277            Some(ContextEvent::Error(error)) => return Err(Error::new(error.message)),
1278            _ => {}
1279        }
1280    }
1281}
1282
1283async fn download_android_client_file(
1284    app: &AndroidApp,
1285    handle: &mut AndroidTabHandle,
1286    file_id: &str,
1287    path: &Path,
1288) -> Result<()> {
1289    let temporary = path.with_extension(format!(
1290        "allwright-{}.tmp",
1291        MOBILE_TRANSFER_COUNTER.fetch_add(1, Ordering::Relaxed)
1292    ));
1293    let mut output = fs::OpenOptions::new()
1294        .write(true)
1295        .create_new(true)
1296        .open(&temporary)
1297        .map_err(|error| Error::new(error.to_string()))?;
1298    let mut offset = 0_u64;
1299    loop {
1300        handle
1301            .command_tx
1302            .send(ContextSessionCommand {
1303                surface_session_id: app.inner.surface_session_id.clone(),
1304                context_session_id: app.inner.session_id.clone(),
1305                command: Some(ContextCommand::ReadFileChunk(ReadFileChunkCommand {
1306                    file_id: file_id.to_string(),
1307                    offset,
1308                    max_bytes: 256 * 1024,
1309                })),
1310            })
1311            .await
1312            .map_err(|_| Error::new("failed to send ReadFileChunkCommand"))?;
1313        let event = handle
1314            .events
1315            .message()
1316            .await?
1317            .ok_or_else(|| Error::new("Android app session closed while downloading file"))?;
1318        match event.event {
1319            Some(ContextEvent::FileChunk(chunk))
1320                if chunk.file_id == file_id && chunk.offset == offset =>
1321            {
1322                output
1323                    .write_all(&chunk.data)
1324                    .map_err(|error| Error::new(error.to_string()))?;
1325                offset += chunk.data.len() as u64;
1326                if chunk.last {
1327                    drop(output);
1328                    fs::rename(&temporary, path).map_err(|error| Error::new(error.to_string()))?;
1329                    return Ok(());
1330                }
1331            }
1332            Some(ContextEvent::Error(error)) => {
1333                let _ = fs::remove_file(&temporary);
1334                return Err(Error::new(error.message));
1335            }
1336            _ => {}
1337        }
1338    }
1339}
1340
1341impl AndroidLocator {
1342    pub fn app(&self) -> &AndroidApp {
1343        &self.page
1344    }
1345
1346    pub fn selector(&self) -> &str {
1347        &self.selector
1348    }
1349
1350    pub fn locator(&self, selector: impl Into<String>) -> AndroidLocator {
1351        AndroidLocator {
1352            page: self.page.clone(),
1353            selector: chain_mobile_selector_for_transport(&self.selector, &selector.into()),
1354        }
1355    }
1356
1357    pub fn get_by_role(&self, role: impl Into<String>) -> AndroidLocator {
1358        self.get_by_role_with_options(role, RoleOptions::default())
1359    }
1360
1361    pub fn get_by_role_with_options(
1362        &self,
1363        role: impl Into<String>,
1364        options: RoleOptions,
1365    ) -> AndroidLocator {
1366        let mut spec = serde_json::to_value(options).expect("role options");
1367        spec["kind"] = serde_json::json!("role");
1368        spec["role"] = serde_json::json!(role.into());
1369        self.locator(mobile_semantic_selector(spec))
1370    }
1371
1372    pub fn get_by_text(&self, text: impl Into<TextMatcher>) -> AndroidLocator {
1373        self.get_by_text_with_options(text, TextOptions::default())
1374    }
1375
1376    pub fn get_by_text_with_options(
1377        &self,
1378        text: impl Into<TextMatcher>,
1379        options: TextOptions,
1380    ) -> AndroidLocator {
1381        self.locator(mobile_text_selector("text", text.into(), options))
1382    }
1383
1384    pub fn get_by_label(&self, text: impl Into<TextMatcher>) -> AndroidLocator {
1385        self.get_by_label_with_options(text, TextOptions::default())
1386    }
1387
1388    pub fn get_by_label_with_options(
1389        &self,
1390        text: impl Into<TextMatcher>,
1391        options: TextOptions,
1392    ) -> AndroidLocator {
1393        self.locator(mobile_text_selector("label", text.into(), options))
1394    }
1395
1396    pub fn get_by_test_id(&self, text: impl Into<TextMatcher>) -> AndroidLocator {
1397        self.locator(mobile_semantic_selector(
1398            serde_json::json!({"kind":"testId", "text":text.into()}),
1399        ))
1400    }
1401
1402    pub async fn click(&self, options: CommandOptions) -> Result<ClickResult> {
1403        self.page.click(&self.selector, options).await
1404    }
1405
1406    pub async fn count(&self, options: CommandOptions) -> Result<CountResult> {
1407        self.page.count(&self.selector, options).await
1408    }
1409
1410    pub async fn focus(&self, options: CommandOptions) -> Result<ElementResult> {
1411        self.page.focus(&self.selector, options).await
1412    }
1413
1414    pub async fn fill(&self, value: &str, options: CommandOptions) -> Result<FillResult> {
1415        self.page.fill(&self.selector, value, options).await
1416    }
1417
1418    pub async fn press(&self, key: &str, options: PressOptions) -> Result<PressResult> {
1419        self.page.press(&self.selector, key, options).await
1420    }
1421
1422    pub async fn text_content(&self, options: CommandOptions) -> Result<TextResult> {
1423        self.page.text_content(&self.selector, options).await
1424    }
1425
1426    pub async fn inner_text(&self, options: CommandOptions) -> Result<TextResult> {
1427        self.page.inner_text(&self.selector, options).await
1428    }
1429
1430    pub async fn wait_for(&self, options: WaitForSelectorOptions) -> Result<WaitForSelectorResult> {
1431        self.page.wait_for_selector(&self.selector, options).await
1432    }
1433}
1434
1435fn ensure_android_device_open(state: &AndroidDeviceState, session_id: &str) -> Result<()> {
1436    if state.closed {
1437        return Err(Error::new(format!(
1438            "android device session {} is closed",
1439            session_id
1440        )));
1441    }
1442    Ok(())
1443}
1444
1445fn ensure_android_app_open(handle: &AndroidTabHandle, session_id: &str) -> Result<()> {
1446    if handle.closed {
1447        return Err(Error::new(format!(
1448            "android app session {} is closed",
1449            session_id
1450        )));
1451    }
1452    Ok(())
1453}
1454
1455fn mobile_semantic_selector(spec: serde_json::Value) -> String {
1456    format!(
1457        "aw={}",
1458        serde_json::to_string(&spec.to_string()).expect("JSON string")
1459    )
1460}
1461
1462fn mobile_text_selector(kind: &str, text: TextMatcher, options: TextOptions) -> String {
1463    mobile_semantic_selector(serde_json::json!({"kind":kind, "text":text, "exact":options.exact}))
1464}
1465
1466#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1467enum MobileSelectorFlavor {
1468    Css,
1469    XPath,
1470    UiAutomator,
1471    Semantic,
1472}
1473
1474impl MobileSelectorFlavor {
1475    fn as_str(self) -> &'static str {
1476        match self {
1477            Self::Css => "css",
1478            Self::XPath => "xpath",
1479            Self::UiAutomator => "uia",
1480            Self::Semantic => "aw",
1481        }
1482    }
1483}
1484
1485const UIAUTOMATOR_SELECTOR_KEYS: &[&str] = &[
1486    "text",
1487    "textcontains",
1488    "textmatches",
1489    "textstartswith",
1490    "classname",
1491    "classnamematches",
1492    "description",
1493    "desc",
1494    "descriptioncontains",
1495    "desccontains",
1496    "descriptionmatches",
1497    "descmatches",
1498    "descriptionstartswith",
1499    "descstartswith",
1500    "checkable",
1501    "checked",
1502    "clickable",
1503    "longclickable",
1504    "scrollable",
1505    "enabled",
1506    "focusable",
1507    "focused",
1508    "selected",
1509    "packagename",
1510    "package",
1511    "packagenamematches",
1512    "resourceid",
1513    "resourceidmatches",
1514    "index",
1515    "instance",
1516];
1517
1518fn parse_explicit_mobile_selector_prefix(selector: &str) -> Option<(MobileSelectorFlavor, usize)> {
1519    let lowered = selector.to_ascii_lowercase();
1520    if lowered.starts_with("xpath=") || lowered.starts_with("xpath:") {
1521        return Some((MobileSelectorFlavor::XPath, 6));
1522    }
1523    if lowered.starts_with("uia=") || lowered.starts_with("uia:") {
1524        return Some((MobileSelectorFlavor::UiAutomator, 4));
1525    }
1526    if lowered.starts_with("aw=") || lowered.starts_with("aw:") {
1527        return Some((MobileSelectorFlavor::Semantic, 3));
1528    }
1529    if let Some(prefix_len) = parse_ui_automator_selector_prefix(&lowered) {
1530        return Some((MobileSelectorFlavor::UiAutomator, prefix_len));
1531    }
1532    if lowered.starts_with("text=") || lowered.starts_with("text:") {
1533        return Some((MobileSelectorFlavor::UiAutomator, 5));
1534    }
1535    if lowered.starts_with("id=") || lowered.starts_with("id:") {
1536        return Some((MobileSelectorFlavor::Css, 3));
1537    }
1538    if lowered.starts_with("css=") || lowered.starts_with("css:") {
1539        return Some((MobileSelectorFlavor::Css, 4));
1540    }
1541    None
1542}
1543
1544fn parse_ui_automator_selector_prefix(selector: &str) -> Option<usize> {
1545    UIAUTOMATOR_SELECTOR_KEYS.iter().find_map(|key| {
1546        if selector.starts_with(key) {
1547            let separator = selector.as_bytes().get(key.len()).copied()?;
1548            if separator == b'=' || separator == b':' {
1549                return Some(key.len() + 1);
1550            }
1551        }
1552        None
1553    })
1554}
1555
1556fn find_json_string_end(value: &str) -> Option<usize> {
1557    let bytes = value.as_bytes();
1558    if bytes.first().copied()? != b'"' {
1559        return None;
1560    }
1561
1562    let mut index = 1usize;
1563    let mut escaped = false;
1564    while index < bytes.len() {
1565        let byte = bytes[index];
1566        if escaped {
1567            escaped = false;
1568            index += 1;
1569            continue;
1570        }
1571        match byte {
1572            b'\\' => escaped = true,
1573            b'"' => return Some(index + 1),
1574            _ => {}
1575        }
1576        index += 1;
1577    }
1578    None
1579}
1580
1581fn is_normalized_mobile_transport_selector(selector: &str) -> bool {
1582    let trimmed = selector.trim();
1583    if trimmed.is_empty() {
1584        return false;
1585    }
1586
1587    let mut index = 0usize;
1588    while index < trimmed.len() {
1589        let Some((_, prefix_len)) = parse_explicit_mobile_selector_prefix(&trimmed[index..]) else {
1590            return false;
1591        };
1592        index += prefix_len;
1593
1594        let remainder = &trimmed[index..];
1595        let Some(json_end) = find_json_string_end(remainder) else {
1596            return false;
1597        };
1598        index += json_end;
1599
1600        if index == trimmed.len() {
1601            return true;
1602        }
1603
1604        let whitespace_len = trimmed[index..]
1605            .chars()
1606            .take_while(|char| char.is_ascii_whitespace())
1607            .count();
1608        if whitespace_len == 0 {
1609            return false;
1610        }
1611        index += whitespace_len;
1612
1613        if parse_explicit_mobile_selector_prefix(&trimmed[index..]).is_none() {
1614            return false;
1615        }
1616    }
1617
1618    true
1619}
1620
1621fn decode_selector_body(body: &str) -> String {
1622    let candidate = body.trim();
1623    if candidate.len() >= 2 && candidate.starts_with('"') && candidate.ends_with('"') {
1624        if let Ok(decoded) = serde_json::from_str::<String>(candidate) {
1625            return unescape_shell_escaped_selector(&decoded);
1626        }
1627    }
1628    unescape_shell_escaped_selector(candidate)
1629}
1630
1631fn unescape_shell_escaped_selector(value: &str) -> String {
1632    let mut result = String::with_capacity(value.len());
1633    let mut chars = value.chars().peekable();
1634    while let Some(ch) = chars.next() {
1635        if ch == '\\' {
1636            match chars.peek().copied() {
1637                Some('_' | ' ' | '#' | ':' | '[' | ']' | '(' | ')' | '"' | '\'') => {
1638                    result.push(chars.next().expect("peeked char should exist"));
1639                    continue;
1640                }
1641                _ => {}
1642            }
1643        }
1644        result.push(ch);
1645    }
1646    result
1647}
1648
1649fn parse_mobile_selector_for_transport(selector: &str) -> (MobileSelectorFlavor, String) {
1650    let trimmed = selector.trim();
1651    if let Some((flavor, prefix_len)) = parse_explicit_mobile_selector_prefix(trimmed) {
1652        let body = decode_selector_body(&trimmed[prefix_len..]);
1653        return match flavor {
1654            MobileSelectorFlavor::Css if prefix_len == 3 => {
1655                let normalized = if body.starts_with('#') {
1656                    body
1657                } else {
1658                    format!("#{body}")
1659                };
1660                (MobileSelectorFlavor::Css, normalized)
1661            }
1662            MobileSelectorFlavor::UiAutomator
1663                if prefix_len != 4 && !trimmed[..prefix_len].eq_ignore_ascii_case("text=") =>
1664            {
1665                (
1666                    MobileSelectorFlavor::UiAutomator,
1667                    format!("{}={body}", &trimmed[..prefix_len - 1]),
1668                )
1669            }
1670            MobileSelectorFlavor::UiAutomator if prefix_len == 5 => {
1671                (MobileSelectorFlavor::UiAutomator, format!("text={body}"))
1672            }
1673            _ => (flavor, body),
1674        };
1675    }
1676
1677    if trimmed.starts_with("//")
1678        || trimmed.starts_with(".//")
1679        || trimmed.starts_with("../")
1680        || trimmed.starts_with('/')
1681        || trimmed.starts_with('(')
1682    {
1683        return (MobileSelectorFlavor::XPath, trimmed.to_string());
1684    }
1685
1686    (MobileSelectorFlavor::Css, trimmed.to_string())
1687}
1688
1689fn normalize_mobile_selector_for_transport(selector: &str) -> String {
1690    let trimmed = selector.trim();
1691    if trimmed.is_empty() {
1692        return String::new();
1693    }
1694    if is_normalized_mobile_transport_selector(trimmed) {
1695        return trimmed.to_string();
1696    }
1697    let (flavor, body) = parse_mobile_selector_for_transport(selector);
1698    format!(
1699        "{}={}",
1700        flavor.as_str(),
1701        serde_json::to_string(&body).unwrap_or_else(|_| format!("{body:?}"))
1702    )
1703}
1704
1705fn chain_mobile_selector_for_transport(parent: &str, child: &str) -> String {
1706    let parent = if parent.trim().is_empty() {
1707        String::new()
1708    } else {
1709        normalize_mobile_selector_for_transport(parent)
1710    };
1711    let child = if child.trim().is_empty() {
1712        String::new()
1713    } else {
1714        normalize_mobile_selector_for_transport(child)
1715    };
1716    if parent.is_empty() {
1717        return child;
1718    }
1719    if child.is_empty() {
1720        return parent;
1721    }
1722    format!("{parent} {child}")
1723}
1724
1725impl AndroidApp {
1726    pub async fn accessibility_snapshot(&self) -> Result<String> {
1727        self.accessibility_snapshot_with_options(AccessibilitySnapshotOptions::default())
1728            .await
1729    }
1730
1731    pub async fn accessibility_snapshot_with_options(
1732        &self,
1733        options: AccessibilitySnapshotOptions,
1734    ) -> Result<String> {
1735        let mut state = self.inner.state.lock().await;
1736        let handle = self.ensure_handle(&mut state).await?;
1737        ensure_android_app_open(handle, &self.inner.session_id)?;
1738        handle
1739            .command_tx
1740            .send(ContextSessionCommand {
1741                surface_session_id: self.inner.surface_session_id.clone(),
1742                context_session_id: self.inner.session_id.clone(),
1743                command: Some(ContextCommand::AccessibilitySnapshot(
1744                    AccessibilitySnapshotCommand {
1745                        format: match options.format {
1746                            AccessibilitySnapshotFormat::Json => "json",
1747                            AccessibilitySnapshotFormat::Yaml => "yaml",
1748                        }
1749                        .into(),
1750                        mode: match options.mode {
1751                            AccessibilitySnapshotMode::Default => "default",
1752                            AccessibilitySnapshotMode::Ai => "ai",
1753                            AccessibilitySnapshotMode::Autoexpect => "autoexpect",
1754                            AccessibilitySnapshotMode::Codegen => "codegen",
1755                        }
1756                        .into(),
1757                        retry_options: command_retry_options(options.timeout_ms),
1758                    },
1759                )),
1760            })
1761            .await
1762            .map_err(|_| Error::new("failed to send AccessibilitySnapshotCommand"))?;
1763        loop {
1764            let event = handle.events.message().await?.ok_or_else(|| {
1765                Error::new("android app session closed while capturing accessibility snapshot")
1766            })?;
1767            match event.event {
1768                Some(ContextEvent::AccessibilitySnapshotCaptured(result)) => {
1769                    return Ok(result.snapshot);
1770                }
1771                Some(ContextEvent::Error(error)) => return Err(Error::new(error.message)),
1772                Some(ContextEvent::Closed(_)) => {
1773                    handle.closed = true;
1774                    return Err(Error::new(
1775                        "android app session closed while capturing accessibility snapshot",
1776                    ));
1777                }
1778                _ => {}
1779            }
1780        }
1781    }
1782}