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