Skip to main content

allwright/
client_mobile.rs

1use std::sync::{Arc, Mutex};
2
3use crate::proto::context_session_command::Command as ContextCommand;
4use crate::proto::context_session_event::Event as ContextEvent;
5use crate::proto::surface_session_command::Command as SurfaceCommand;
6use crate::proto::surface_session_event::Event as SurfaceEvent;
7use crate::proto::{
8    AccessibilitySnapshotCommand, AppLaunchedEvent, ClickElementCommand, ConnectMobileCommand,
9    ContextSessionCommand, CountElementsCommand, FillElementCommand, FocusElementCommand,
10    GetInnerTextCommand, GetTextContentCommand, LaunchAppCommand, MobileConnectedEvent,
11    MobilePlatform as ProtoMobilePlatform, PressKeyCommand, ScreenshotCommand,
12    SurfaceSessionCommand, WaitForSelectorCommand,
13};
14use tokio::sync::{Mutex as AsyncMutex, mpsc};
15use tokio_stream::wrappers::ReceiverStream;
16
17use super::command::command_retry_options;
18use super::runtime::get_runtime;
19use super::types::{
20    AccessibilitySnapshotFormat, AccessibilitySnapshotMode, AccessibilitySnapshotOptions,
21    ClickResult, CommandOptions, CountResult, ElementResult, Error, FillResult, PressOptions,
22    PressResult, Result, RuntimeClient, ScreenshotOptions, ScreenshotResult, TextResult,
23    WaitForSelectorOptions, WaitForSelectorResult,
24};
25
26#[derive(Debug, Clone, Default)]
27pub struct MobileAndroidConnectOptions {
28    pub device: Option<String>,
29    pub adb_endpoint: Option<String>,
30    pub preserve_app_state: bool,
31    pub timeout_ms: Option<u32>,
32}
33
34#[derive(Debug, Clone, Default)]
35pub struct MobileAndroidLaunchOptions {
36    pub apk_path: Option<String>,
37    pub app_id: Option<String>,
38    pub launch_activity: Option<String>,
39    pub stop_before_launch: bool,
40    pub timeout_ms: Option<u32>,
41}
42
43#[derive(Clone)]
44pub struct AndroidLocator {
45    page: AndroidApp,
46    selector: String,
47}
48
49#[derive(Clone)]
50pub struct AndroidApp {
51    inner: Arc<AndroidAppInner>,
52}
53
54#[derive(Clone)]
55pub struct AndroidDevice {
56    inner: Arc<AndroidDeviceInner>,
57}
58
59struct AndroidDeviceInner {
60    runtime: Arc<RuntimeClient>,
61    state: AsyncMutex<AndroidDeviceState>,
62    session_id: String,
63    initial_app: AndroidApp,
64    current_app: Mutex<AndroidApp>,
65}
66
67struct AndroidDeviceState {
68    command_tx: mpsc::Sender<SurfaceSessionCommand>,
69    events: tonic::Streaming<crate::proto::SurfaceSessionEvent>,
70    closed: bool,
71}
72
73struct AndroidAppInner {
74    runtime: Arc<RuntimeClient>,
75    surface_session_id: String,
76    session_id: String,
77    state: AsyncMutex<AndroidAppState>,
78}
79
80#[derive(Default)]
81struct AndroidAppState {
82    handle: Option<AndroidTabHandle>,
83}
84
85struct AndroidTabHandle {
86    command_tx: mpsc::Sender<crate::proto::ContextSessionCommand>,
87    events: tonic::Streaming<crate::proto::ContextSessionEvent>,
88    closed: bool,
89}
90
91pub mod android {
92    use super::*;
93
94    pub async fn connect(options: MobileAndroidConnectOptions) -> Result<AndroidDevice> {
95        let runtime = get_runtime().await?;
96        let mut engine = runtime.engine.clone();
97        let (command_tx, command_rx) = mpsc::channel(16);
98        let response = engine
99            .surface_session(tonic::Request::new(ReceiverStream::new(command_rx)))
100            .await?;
101        let mut events = response.into_inner();
102
103        command_tx
104            .send(SurfaceSessionCommand {
105                command: Some(SurfaceCommand::ConnectMobile(ConnectMobileCommand {
106                    platform: ProtoMobilePlatform::Android as i32,
107                    device: options.device,
108                    adb_endpoint: options.adb_endpoint,
109                    preserve_app_state: options.preserve_app_state,
110                    retry_options: command_retry_options(options.timeout_ms),
111                })),
112            })
113            .await
114            .map_err(|_| Error::new("failed to send ConnectMobileCommand"))?;
115
116        loop {
117            let event = events.message().await?.ok_or_else(|| {
118                Error::new("surface session closed before mobile connect response")
119            })?;
120
121            match event.event {
122                Some(SurfaceEvent::MobileConnected(MobileConnectedEvent {
123                    initial_app_session_id,
124                    device_session_id,
125                    ..
126                })) => {
127                    let initial_app = AndroidApp {
128                        inner: Arc::new(AndroidAppInner {
129                            runtime: Arc::clone(&runtime),
130                            surface_session_id: event.session_id.clone(),
131                            session_id: initial_app_session_id,
132                            state: AsyncMutex::new(AndroidAppState::default()),
133                        }),
134                    };
135                    return Ok(AndroidDevice {
136                        inner: Arc::new(AndroidDeviceInner {
137                            runtime,
138                            state: AsyncMutex::new(AndroidDeviceState {
139                                command_tx,
140                                events,
141                                closed: false,
142                            }),
143                            session_id: if device_session_id.is_empty() {
144                                event.session_id
145                            } else {
146                                device_session_id
147                            },
148                            initial_app: initial_app.clone(),
149                            current_app: Mutex::new(initial_app),
150                        }),
151                    });
152                }
153                Some(SurfaceEvent::Error(error)) => {
154                    return Err(Error::new(format!(
155                        "surface session error during mobile connect: {}",
156                        error.message
157                    )));
158                }
159                _ => {}
160            }
161        }
162    }
163}
164
165impl AndroidDevice {
166    pub fn session_id(&self) -> &str {
167        &self.inner.session_id
168    }
169
170    pub fn app(&self) -> AndroidApp {
171        self.inner
172            .current_app
173            .lock()
174            .map(|app| app.clone())
175            .unwrap_or_else(|_| self.inner.initial_app.clone())
176    }
177
178    pub fn initial_app(&self) -> AndroidApp {
179        self.inner.initial_app.clone()
180    }
181
182    pub async fn launch(&self, options: MobileAndroidLaunchOptions) -> Result<AndroidApp> {
183        let mut state = self.inner.state.lock().await;
184        ensure_android_device_open(&state, &self.inner.session_id)?;
185
186        state
187            .command_tx
188            .send(SurfaceSessionCommand {
189                command: Some(SurfaceCommand::LaunchApp(LaunchAppCommand {
190                    apk_path: options.apk_path,
191                    app_id: options.app_id,
192                    launch_activity: options.launch_activity,
193                    stop_before_launch: options.stop_before_launch,
194                    retry_options: command_retry_options(options.timeout_ms),
195                })),
196            })
197            .await
198            .map_err(|_| Error::new("failed to send LaunchAppCommand"))?;
199
200        loop {
201            let event =
202                state.events.message().await?.ok_or_else(|| {
203                    Error::new("surface session closed before app launch response")
204                })?;
205
206            match event.event {
207                Some(SurfaceEvent::AppLaunched(AppLaunchedEvent { app_session_id, .. })) => {
208                    let app = AndroidApp {
209                        inner: Arc::new(AndroidAppInner {
210                            runtime: Arc::clone(&self.inner.runtime),
211                            surface_session_id: event.session_id,
212                            session_id: app_session_id,
213                            state: AsyncMutex::new(AndroidAppState::default()),
214                        }),
215                    };
216                    if let Ok(mut current_app) = self.inner.current_app.lock() {
217                        *current_app = app.clone();
218                    }
219                    return Ok(app);
220                }
221                Some(SurfaceEvent::Error(error)) => {
222                    return Err(Error::new(format!(
223                        "surface session error while launching Android app: {}",
224                        error.message
225                    )));
226                }
227                Some(SurfaceEvent::Closed(_)) => {
228                    state.closed = true;
229                    return Err(Error::new(
230                        "surface session closed while waiting for Android app launch",
231                    ));
232                }
233                _ => {}
234            }
235        }
236    }
237}
238
239impl AndroidApp {
240    pub fn session_id(&self) -> &str {
241        &self.inner.session_id
242    }
243
244    pub fn locator(&self, selector: impl Into<String>) -> AndroidLocator {
245        AndroidLocator {
246            page: self.clone(),
247            selector: normalize_mobile_selector_for_transport(&selector.into()),
248        }
249    }
250
251    pub async fn click(&self, selector: &str, options: CommandOptions) -> Result<ClickResult> {
252        let selector = normalize_mobile_selector_for_transport(selector);
253        let mut state = self.inner.state.lock().await;
254        let handle = self.ensure_handle(&mut state).await?;
255        ensure_android_app_open(handle, &self.inner.session_id)?;
256
257        handle
258            .command_tx
259            .send(ContextSessionCommand {
260                surface_session_id: self.inner.surface_session_id.clone(),
261                context_session_id: self.inner.session_id.clone(),
262                command: Some(ContextCommand::ClickElement(ClickElementCommand {
263                    css_selector: selector.clone(),
264                    retry_options: command_retry_options(options.timeout_ms),
265                })),
266            })
267            .await
268            .map_err(|_| Error::new("failed to send ClickElementCommand"))?;
269
270        loop {
271            let event =
272                handle.events.message().await?.ok_or_else(|| {
273                    Error::new("app session closed while waiting for click result")
274                })?;
275
276            match event.event {
277                Some(ContextEvent::Attached(_)) => {}
278                Some(ContextEvent::ElementClicked(clicked)) => {
279                    return Ok(ClickResult {
280                        selector: clicked.css_selector,
281                        note: clicked.note,
282                        bidi_session_id: clicked.bidi_session_id,
283                    });
284                }
285                Some(ContextEvent::Error(error)) => {
286                    return Err(Error::new(format!(
287                        "app session error while clicking Android locator {:?}: {}",
288                        selector, error.message,
289                    )));
290                }
291                Some(ContextEvent::Closed(_)) => {
292                    handle.closed = true;
293                    return Err(Error::new(format!(
294                        "app session {} closed while waiting for click result",
295                        self.inner.session_id
296                    )));
297                }
298                _ => {}
299            }
300        }
301    }
302
303    pub async fn fill(
304        &self,
305        selector: &str,
306        value: &str,
307        options: CommandOptions,
308    ) -> Result<FillResult> {
309        let selector = normalize_mobile_selector_for_transport(selector);
310        let mut state = self.inner.state.lock().await;
311        let handle = self.ensure_handle(&mut state).await?;
312        ensure_android_app_open(handle, &self.inner.session_id)?;
313
314        handle
315            .command_tx
316            .send(ContextSessionCommand {
317                surface_session_id: self.inner.surface_session_id.clone(),
318                context_session_id: self.inner.session_id.clone(),
319                command: Some(ContextCommand::FillElement(FillElementCommand {
320                    css_selector: selector.clone(),
321                    value: value.to_string(),
322                    retry_options: command_retry_options(options.timeout_ms),
323                })),
324            })
325            .await
326            .map_err(|_| Error::new("failed to send FillElementCommand"))?;
327
328        loop {
329            let event =
330                handle.events.message().await?.ok_or_else(|| {
331                    Error::new("app session closed while waiting for fill result")
332                })?;
333
334            match event.event {
335                Some(ContextEvent::Attached(_)) => {}
336                Some(ContextEvent::ElementFilled(filled)) => {
337                    return Ok(FillResult {
338                        selector: filled.css_selector,
339                        value: filled.value,
340                        note: filled.note,
341                    });
342                }
343                Some(ContextEvent::Error(error)) => {
344                    return Err(Error::new(format!(
345                        "app session error while filling Android locator {:?}: {}",
346                        selector, error.message,
347                    )));
348                }
349                Some(ContextEvent::Closed(_)) => {
350                    handle.closed = true;
351                    return Err(Error::new(format!(
352                        "app session {} closed while waiting for fill result",
353                        self.inner.session_id
354                    )));
355                }
356                _ => {}
357            }
358        }
359    }
360
361    pub async fn count(&self, selector: &str, options: CommandOptions) -> Result<CountResult> {
362        let selector = normalize_mobile_selector_for_transport(selector);
363        let mut state = self.inner.state.lock().await;
364        let handle = self.ensure_handle(&mut state).await?;
365        ensure_android_app_open(handle, &self.inner.session_id)?;
366
367        handle
368            .command_tx
369            .send(ContextSessionCommand {
370                surface_session_id: self.inner.surface_session_id.clone(),
371                context_session_id: self.inner.session_id.clone(),
372                command: Some(ContextCommand::CountElements(CountElementsCommand {
373                    css_selector: selector.clone(),
374                    retry_options: command_retry_options(options.timeout_ms),
375                })),
376            })
377            .await
378            .map_err(|_| Error::new("failed to send CountElementsCommand"))?;
379
380        loop {
381            let event =
382                handle.events.message().await?.ok_or_else(|| {
383                    Error::new("app session closed while waiting for count result")
384                })?;
385
386            match event.event {
387                Some(ContextEvent::Attached(_)) => {}
388                Some(ContextEvent::ElementCounted(counted)) => {
389                    return Ok(CountResult {
390                        selector: counted.css_selector,
391                        count: counted.count,
392                        note: counted.note,
393                    });
394                }
395                Some(ContextEvent::Error(error)) => {
396                    return Err(Error::new(format!(
397                        "app session error while counting Android locator {:?}: {}",
398                        selector, error.message,
399                    )));
400                }
401                Some(ContextEvent::Closed(_)) => {
402                    handle.closed = true;
403                    return Err(Error::new(format!(
404                        "app session {} closed while waiting for count result",
405                        self.inner.session_id
406                    )));
407                }
408                _ => {}
409            }
410        }
411    }
412
413    pub async fn focus(&self, selector: &str, options: CommandOptions) -> Result<ElementResult> {
414        let selector = normalize_mobile_selector_for_transport(selector);
415        let mut state = self.inner.state.lock().await;
416        let handle = self.ensure_handle(&mut state).await?;
417        ensure_android_app_open(handle, &self.inner.session_id)?;
418
419        handle
420            .command_tx
421            .send(ContextSessionCommand {
422                surface_session_id: self.inner.surface_session_id.clone(),
423                context_session_id: self.inner.session_id.clone(),
424                command: Some(ContextCommand::FocusElement(FocusElementCommand {
425                    css_selector: selector.clone(),
426                    retry_options: command_retry_options(options.timeout_ms),
427                })),
428            })
429            .await
430            .map_err(|_| Error::new("failed to send FocusElementCommand"))?;
431
432        loop {
433            let event =
434                handle.events.message().await?.ok_or_else(|| {
435                    Error::new("app session closed while waiting for focus result")
436                })?;
437
438            match event.event {
439                Some(ContextEvent::Attached(_)) => {}
440                Some(ContextEvent::ElementFocused(focused)) => {
441                    return Ok(ElementResult {
442                        selector: focused.css_selector,
443                        note: focused.note,
444                    });
445                }
446                Some(ContextEvent::Error(error)) => {
447                    return Err(Error::new(format!(
448                        "app session error while focusing Android locator {:?}: {}",
449                        selector, error.message,
450                    )));
451                }
452                Some(ContextEvent::Closed(_)) => {
453                    handle.closed = true;
454                    return Err(Error::new(format!(
455                        "app session {} closed while waiting for focus result",
456                        self.inner.session_id
457                    )));
458                }
459                _ => {}
460            }
461        }
462    }
463
464    pub async fn press(
465        &self,
466        selector: &str,
467        key: &str,
468        options: PressOptions,
469    ) -> Result<PressResult> {
470        let selector = normalize_mobile_selector_for_transport(selector);
471        let mut state = self.inner.state.lock().await;
472        let handle = self.ensure_handle(&mut state).await?;
473        ensure_android_app_open(handle, &self.inner.session_id)?;
474
475        handle
476            .command_tx
477            .send(ContextSessionCommand {
478                surface_session_id: self.inner.surface_session_id.clone(),
479                context_session_id: self.inner.session_id.clone(),
480                command: Some(ContextCommand::PressKey(PressKeyCommand {
481                    css_selector: selector.clone(),
482                    key: key.to_string(),
483                    text: options.text,
484                    retry_options: command_retry_options(options.timeout_ms),
485                })),
486            })
487            .await
488            .map_err(|_| Error::new("failed to send PressKeyCommand"))?;
489
490        loop {
491            let event =
492                handle.events.message().await?.ok_or_else(|| {
493                    Error::new("app session closed while waiting for press result")
494                })?;
495
496            match event.event {
497                Some(ContextEvent::Attached(_)) => {}
498                Some(ContextEvent::KeyPressed(pressed)) => {
499                    return Ok(PressResult {
500                        selector: pressed.css_selector,
501                        key: pressed.key,
502                        note: pressed.note,
503                    });
504                }
505                Some(ContextEvent::Error(error)) => {
506                    return Err(Error::new(format!(
507                        "app session error while pressing Android key on {:?}: {}",
508                        selector, error.message,
509                    )));
510                }
511                Some(ContextEvent::Closed(_)) => {
512                    handle.closed = true;
513                    return Err(Error::new(format!(
514                        "app session {} closed while waiting for press result",
515                        self.inner.session_id
516                    )));
517                }
518                _ => {}
519            }
520        }
521    }
522
523    pub async fn text_content(
524        &self,
525        selector: &str,
526        options: CommandOptions,
527    ) -> Result<TextResult> {
528        self.read_text(selector, options, true).await
529    }
530
531    pub async fn inner_text(&self, selector: &str, options: CommandOptions) -> Result<TextResult> {
532        self.read_text(selector, options, false).await
533    }
534
535    pub async fn wait_for_selector(
536        &self,
537        selector: &str,
538        options: WaitForSelectorOptions,
539    ) -> Result<WaitForSelectorResult> {
540        let selector = normalize_mobile_selector_for_transport(selector);
541        let mut state = self.inner.state.lock().await;
542        let handle = self.ensure_handle(&mut state).await?;
543        ensure_android_app_open(handle, &self.inner.session_id)?;
544
545        handle
546            .command_tx
547            .send(ContextSessionCommand {
548                surface_session_id: self.inner.surface_session_id.clone(),
549                context_session_id: self.inner.session_id.clone(),
550                command: Some(ContextCommand::WaitForSelector(WaitForSelectorCommand {
551                    css_selector: selector.clone(),
552                    visible: options.visible,
553                    retry_options: command_retry_options(options.timeout_ms),
554                })),
555            })
556            .await
557            .map_err(|_| Error::new("failed to send WaitForSelectorCommand"))?;
558
559        loop {
560            let event = handle.events.message().await?.ok_or_else(|| {
561                Error::new("app session closed while waiting for selector result")
562            })?;
563
564            match event.event {
565                Some(ContextEvent::Attached(_)) => {}
566                Some(ContextEvent::SelectorWaitSatisfied(wait)) => {
567                    return Ok(WaitForSelectorResult {
568                        selector: wait.css_selector,
569                        visible: wait.visible,
570                        note: wait.note,
571                    });
572                }
573                Some(ContextEvent::Error(error)) => {
574                    return Err(Error::new(format!(
575                        "app session error while waiting for Android locator {:?}: {}",
576                        selector, error.message,
577                    )));
578                }
579                Some(ContextEvent::Closed(_)) => {
580                    handle.closed = true;
581                    return Err(Error::new(format!(
582                        "app session {} closed while waiting for selector result",
583                        self.inner.session_id
584                    )));
585                }
586                _ => {}
587            }
588        }
589    }
590
591    pub async fn screenshot(&self) -> Result<ScreenshotResult> {
592        self.screenshot_with_options(ScreenshotOptions::default())
593            .await
594    }
595
596    pub async fn screenshot_with_options(
597        &self,
598        options: ScreenshotOptions,
599    ) -> Result<ScreenshotResult> {
600        let mut state = self.inner.state.lock().await;
601        let handle = self.ensure_handle(&mut state).await?;
602        ensure_android_app_open(handle, &self.inner.session_id)?;
603
604        handle
605            .command_tx
606            .send(ContextSessionCommand {
607                surface_session_id: self.inner.surface_session_id.clone(),
608                context_session_id: self.inner.session_id.clone(),
609                command: Some(ContextCommand::Screenshot(ScreenshotCommand {
610                    retry_options: command_retry_options(options.timeout_ms),
611                    full_page: Some(options.full_page),
612                })),
613            })
614            .await
615            .map_err(|_| Error::new("failed to send ScreenshotCommand"))?;
616
617        loop {
618            let event = handle.events.message().await?.ok_or_else(|| {
619                Error::new("app session closed while waiting for screenshot result")
620            })?;
621
622            match event.event {
623                Some(ContextEvent::Attached(_)) => {}
624                Some(ContextEvent::ScreenshotCaptured(screenshot)) => {
625                    let result = ScreenshotResult {
626                        png_data: screenshot.png_data,
627                        note: screenshot.note,
628                    };
629                    if let Some(path) = options.path.as_ref() {
630                        std::fs::write(path, &result.png_data).map_err(|error| {
631                            Error::new(format!("write screenshot to {}: {error}", path.display()))
632                        })?;
633                    }
634                    return Ok(result);
635                }
636                Some(ContextEvent::Error(error)) => {
637                    return Err(Error::new(format!(
638                        "app session error while capturing Android screenshot: {}",
639                        error.message
640                    )));
641                }
642                Some(ContextEvent::Closed(_)) => {
643                    handle.closed = true;
644                    return Err(Error::new(format!(
645                        "app session {} closed while waiting for screenshot result",
646                        self.inner.session_id
647                    )));
648                }
649                _ => {}
650            }
651        }
652    }
653
654    async fn ensure_handle<'a>(
655        &self,
656        state: &'a mut AndroidAppState,
657    ) -> Result<&'a mut AndroidTabHandle> {
658        if state.handle.is_none() {
659            let mut engine = self.inner.runtime.engine.clone();
660            let (command_tx, command_rx) = mpsc::channel(16);
661            let response = engine
662                .context_session(tonic::Request::new(ReceiverStream::new(command_rx)))
663                .await?;
664            state.handle = Some(AndroidTabHandle {
665                command_tx,
666                events: response.into_inner(),
667                closed: false,
668            });
669        }
670
671        state
672            .handle
673            .as_mut()
674            .ok_or_else(|| Error::new("android app session handle was not initialized"))
675    }
676
677    async fn read_text(
678        &self,
679        selector: &str,
680        options: CommandOptions,
681        text_content: bool,
682    ) -> Result<TextResult> {
683        let selector = normalize_mobile_selector_for_transport(selector);
684        let mut state = self.inner.state.lock().await;
685        let handle = self.ensure_handle(&mut state).await?;
686        ensure_android_app_open(handle, &self.inner.session_id)?;
687
688        let command = if text_content {
689            ContextCommand::GetTextContent(GetTextContentCommand {
690                css_selector: selector.clone(),
691                retry_options: command_retry_options(options.timeout_ms),
692            })
693        } else {
694            ContextCommand::GetInnerText(GetInnerTextCommand {
695                css_selector: selector.clone(),
696                retry_options: command_retry_options(options.timeout_ms),
697            })
698        };
699
700        handle
701            .command_tx
702            .send(ContextSessionCommand {
703                surface_session_id: self.inner.surface_session_id.clone(),
704                context_session_id: self.inner.session_id.clone(),
705                command: Some(command),
706            })
707            .await
708            .map_err(|_| Error::new("failed to send text read command"))?;
709
710        loop {
711            let event =
712                handle.events.message().await?.ok_or_else(|| {
713                    Error::new("app session closed while waiting for text result")
714                })?;
715
716            match event.event {
717                Some(ContextEvent::Attached(_)) => {}
718                Some(ContextEvent::TextContentResolved(text)) => {
719                    return Ok(TextResult {
720                        selector: text.css_selector,
721                        text: text.text,
722                        note: text.note,
723                    });
724                }
725                Some(ContextEvent::InnerTextResolved(text)) => {
726                    return Ok(TextResult {
727                        selector: text.css_selector,
728                        text: text.text,
729                        note: text.note,
730                    });
731                }
732                Some(ContextEvent::Error(error)) => {
733                    return Err(Error::new(format!(
734                        "app session error while reading Android text for {:?}: {}",
735                        selector, error.message,
736                    )));
737                }
738                Some(ContextEvent::Closed(_)) => {
739                    handle.closed = true;
740                    return Err(Error::new(format!(
741                        "app session {} closed while waiting for text result",
742                        self.inner.session_id
743                    )));
744                }
745                _ => {}
746            }
747        }
748    }
749}
750
751impl AndroidLocator {
752    pub fn app(&self) -> &AndroidApp {
753        &self.page
754    }
755
756    pub fn selector(&self) -> &str {
757        &self.selector
758    }
759
760    pub fn locator(&self, selector: impl Into<String>) -> AndroidLocator {
761        AndroidLocator {
762            page: self.page.clone(),
763            selector: chain_mobile_selector_for_transport(&self.selector, &selector.into()),
764        }
765    }
766
767    pub async fn click(&self, options: CommandOptions) -> Result<ClickResult> {
768        self.page.click(&self.selector, options).await
769    }
770
771    pub async fn count(&self, options: CommandOptions) -> Result<CountResult> {
772        self.page.count(&self.selector, options).await
773    }
774
775    pub async fn focus(&self, options: CommandOptions) -> Result<ElementResult> {
776        self.page.focus(&self.selector, options).await
777    }
778
779    pub async fn fill(&self, value: &str, options: CommandOptions) -> Result<FillResult> {
780        self.page.fill(&self.selector, value, options).await
781    }
782
783    pub async fn press(&self, key: &str, options: PressOptions) -> Result<PressResult> {
784        self.page.press(&self.selector, key, options).await
785    }
786
787    pub async fn text_content(&self, options: CommandOptions) -> Result<TextResult> {
788        self.page.text_content(&self.selector, options).await
789    }
790
791    pub async fn inner_text(&self, options: CommandOptions) -> Result<TextResult> {
792        self.page.inner_text(&self.selector, options).await
793    }
794
795    pub async fn wait_for(&self, options: WaitForSelectorOptions) -> Result<WaitForSelectorResult> {
796        self.page.wait_for_selector(&self.selector, options).await
797    }
798}
799
800fn ensure_android_device_open(state: &AndroidDeviceState, session_id: &str) -> Result<()> {
801    if state.closed {
802        return Err(Error::new(format!(
803            "android device session {} is closed",
804            session_id
805        )));
806    }
807    Ok(())
808}
809
810fn ensure_android_app_open(handle: &AndroidTabHandle, session_id: &str) -> Result<()> {
811    if handle.closed {
812        return Err(Error::new(format!(
813            "android app session {} is closed",
814            session_id
815        )));
816    }
817    Ok(())
818}
819
820#[derive(Debug, Clone, Copy, PartialEq, Eq)]
821enum MobileSelectorFlavor {
822    Css,
823    XPath,
824    UiAutomator,
825}
826
827impl MobileSelectorFlavor {
828    fn as_str(self) -> &'static str {
829        match self {
830            Self::Css => "css",
831            Self::XPath => "xpath",
832            Self::UiAutomator => "uia",
833        }
834    }
835}
836
837const UIAUTOMATOR_SELECTOR_KEYS: &[&str] = &[
838    "text",
839    "textcontains",
840    "textmatches",
841    "textstartswith",
842    "classname",
843    "classnamematches",
844    "description",
845    "desc",
846    "descriptioncontains",
847    "desccontains",
848    "descriptionmatches",
849    "descmatches",
850    "descriptionstartswith",
851    "descstartswith",
852    "checkable",
853    "checked",
854    "clickable",
855    "longclickable",
856    "scrollable",
857    "enabled",
858    "focusable",
859    "focused",
860    "selected",
861    "packagename",
862    "package",
863    "packagenamematches",
864    "resourceid",
865    "resourceidmatches",
866    "index",
867    "instance",
868];
869
870fn parse_explicit_mobile_selector_prefix(selector: &str) -> Option<(MobileSelectorFlavor, usize)> {
871    let lowered = selector.to_ascii_lowercase();
872    if lowered.starts_with("xpath=") || lowered.starts_with("xpath:") {
873        return Some((MobileSelectorFlavor::XPath, 6));
874    }
875    if lowered.starts_with("uia=") || lowered.starts_with("uia:") {
876        return Some((MobileSelectorFlavor::UiAutomator, 4));
877    }
878    if let Some(prefix_len) = parse_ui_automator_selector_prefix(&lowered) {
879        return Some((MobileSelectorFlavor::UiAutomator, prefix_len));
880    }
881    if lowered.starts_with("text=") || lowered.starts_with("text:") {
882        return Some((MobileSelectorFlavor::UiAutomator, 5));
883    }
884    if lowered.starts_with("id=") || lowered.starts_with("id:") {
885        return Some((MobileSelectorFlavor::Css, 3));
886    }
887    if lowered.starts_with("css=") || lowered.starts_with("css:") {
888        return Some((MobileSelectorFlavor::Css, 4));
889    }
890    None
891}
892
893fn parse_ui_automator_selector_prefix(selector: &str) -> Option<usize> {
894    UIAUTOMATOR_SELECTOR_KEYS.iter().find_map(|key| {
895        if selector.starts_with(key) {
896            let separator = selector.as_bytes().get(key.len()).copied()?;
897            if separator == b'=' || separator == b':' {
898                return Some(key.len() + 1);
899            }
900        }
901        None
902    })
903}
904
905fn find_json_string_end(value: &str) -> Option<usize> {
906    let bytes = value.as_bytes();
907    if bytes.first().copied()? != b'"' {
908        return None;
909    }
910
911    let mut index = 1usize;
912    let mut escaped = false;
913    while index < bytes.len() {
914        let byte = bytes[index];
915        if escaped {
916            escaped = false;
917            index += 1;
918            continue;
919        }
920        match byte {
921            b'\\' => escaped = true,
922            b'"' => return Some(index + 1),
923            _ => {}
924        }
925        index += 1;
926    }
927    None
928}
929
930fn is_normalized_mobile_transport_selector(selector: &str) -> bool {
931    let trimmed = selector.trim();
932    if trimmed.is_empty() {
933        return false;
934    }
935
936    let mut index = 0usize;
937    while index < trimmed.len() {
938        let Some((_, prefix_len)) = parse_explicit_mobile_selector_prefix(&trimmed[index..]) else {
939            return false;
940        };
941        index += prefix_len;
942
943        let remainder = &trimmed[index..];
944        let Some(json_end) = find_json_string_end(remainder) else {
945            return false;
946        };
947        index += json_end;
948
949        if index == trimmed.len() {
950            return true;
951        }
952
953        let whitespace_len = trimmed[index..]
954            .chars()
955            .take_while(|char| char.is_ascii_whitespace())
956            .count();
957        if whitespace_len == 0 {
958            return false;
959        }
960        index += whitespace_len;
961
962        if parse_explicit_mobile_selector_prefix(&trimmed[index..]).is_none() {
963            return false;
964        }
965    }
966
967    true
968}
969
970fn decode_selector_body(body: &str) -> String {
971    let candidate = body.trim();
972    if candidate.len() >= 2 && candidate.starts_with('"') && candidate.ends_with('"') {
973        if let Ok(decoded) = serde_json::from_str::<String>(candidate) {
974            return unescape_shell_escaped_selector(&decoded);
975        }
976    }
977    unescape_shell_escaped_selector(candidate)
978}
979
980fn unescape_shell_escaped_selector(value: &str) -> String {
981    let mut result = String::with_capacity(value.len());
982    let mut chars = value.chars().peekable();
983    while let Some(ch) = chars.next() {
984        if ch == '\\' {
985            match chars.peek().copied() {
986                Some('_' | ' ' | '#' | ':' | '[' | ']' | '(' | ')' | '"' | '\'') => {
987                    result.push(chars.next().expect("peeked char should exist"));
988                    continue;
989                }
990                _ => {}
991            }
992        }
993        result.push(ch);
994    }
995    result
996}
997
998fn parse_mobile_selector_for_transport(selector: &str) -> (MobileSelectorFlavor, String) {
999    let trimmed = selector.trim();
1000    if let Some((flavor, prefix_len)) = parse_explicit_mobile_selector_prefix(trimmed) {
1001        let body = decode_selector_body(&trimmed[prefix_len..]);
1002        return match flavor {
1003            MobileSelectorFlavor::Css if prefix_len == 3 => {
1004                let normalized = if body.starts_with('#') {
1005                    body
1006                } else {
1007                    format!("#{body}")
1008                };
1009                (MobileSelectorFlavor::Css, normalized)
1010            }
1011            MobileSelectorFlavor::UiAutomator
1012                if prefix_len != 4 && !trimmed[..prefix_len].eq_ignore_ascii_case("text=") =>
1013            {
1014                (
1015                    MobileSelectorFlavor::UiAutomator,
1016                    format!("{}={body}", &trimmed[..prefix_len - 1]),
1017                )
1018            }
1019            MobileSelectorFlavor::UiAutomator if prefix_len == 5 => {
1020                (MobileSelectorFlavor::UiAutomator, format!("text={body}"))
1021            }
1022            _ => (flavor, body),
1023        };
1024    }
1025
1026    if trimmed.starts_with("//")
1027        || trimmed.starts_with(".//")
1028        || trimmed.starts_with("../")
1029        || trimmed.starts_with('/')
1030        || trimmed.starts_with('(')
1031    {
1032        return (MobileSelectorFlavor::XPath, trimmed.to_string());
1033    }
1034
1035    (MobileSelectorFlavor::Css, trimmed.to_string())
1036}
1037
1038fn normalize_mobile_selector_for_transport(selector: &str) -> String {
1039    let trimmed = selector.trim();
1040    if trimmed.is_empty() {
1041        return String::new();
1042    }
1043    if is_normalized_mobile_transport_selector(trimmed) {
1044        return trimmed.to_string();
1045    }
1046    let (flavor, body) = parse_mobile_selector_for_transport(selector);
1047    format!(
1048        "{}={}",
1049        flavor.as_str(),
1050        serde_json::to_string(&body).unwrap_or_else(|_| format!("{body:?}"))
1051    )
1052}
1053
1054fn chain_mobile_selector_for_transport(parent: &str, child: &str) -> String {
1055    let parent = if parent.trim().is_empty() {
1056        String::new()
1057    } else {
1058        normalize_mobile_selector_for_transport(parent)
1059    };
1060    let child = if child.trim().is_empty() {
1061        String::new()
1062    } else {
1063        normalize_mobile_selector_for_transport(child)
1064    };
1065    if parent.is_empty() {
1066        return child;
1067    }
1068    if child.is_empty() {
1069        return parent;
1070    }
1071    format!("{parent} {child}")
1072}
1073
1074impl AndroidApp {
1075    pub async fn accessibility_snapshot(&self) -> Result<String> {
1076        self.accessibility_snapshot_with_options(AccessibilitySnapshotOptions::default())
1077            .await
1078    }
1079
1080    pub async fn accessibility_snapshot_with_options(
1081        &self,
1082        options: AccessibilitySnapshotOptions,
1083    ) -> Result<String> {
1084        let mut state = self.inner.state.lock().await;
1085        let handle = self.ensure_handle(&mut state).await?;
1086        ensure_android_app_open(handle, &self.inner.session_id)?;
1087        handle
1088            .command_tx
1089            .send(ContextSessionCommand {
1090                surface_session_id: self.inner.surface_session_id.clone(),
1091                context_session_id: self.inner.session_id.clone(),
1092                command: Some(ContextCommand::AccessibilitySnapshot(
1093                    AccessibilitySnapshotCommand {
1094                        format: match options.format {
1095                            AccessibilitySnapshotFormat::Json => "json",
1096                            AccessibilitySnapshotFormat::Yaml => "yaml",
1097                        }
1098                        .into(),
1099                        mode: match options.mode {
1100                            AccessibilitySnapshotMode::Default => "default",
1101                            AccessibilitySnapshotMode::Ai => "ai",
1102                            AccessibilitySnapshotMode::Autoexpect => "autoexpect",
1103                            AccessibilitySnapshotMode::Codegen => "codegen",
1104                        }
1105                        .into(),
1106                        retry_options: command_retry_options(options.timeout_ms),
1107                    },
1108                )),
1109            })
1110            .await
1111            .map_err(|_| Error::new("failed to send AccessibilitySnapshotCommand"))?;
1112        loop {
1113            let event = handle.events.message().await?.ok_or_else(|| {
1114                Error::new("android app session closed while capturing accessibility snapshot")
1115            })?;
1116            match event.event {
1117                Some(ContextEvent::AccessibilitySnapshotCaptured(result)) => {
1118                    return Ok(result.snapshot);
1119                }
1120                Some(ContextEvent::Error(error)) => return Err(Error::new(error.message)),
1121                Some(ContextEvent::Closed(_)) => {
1122                    handle.closed = true;
1123                    return Err(Error::new(
1124                        "android app session closed while capturing accessibility snapshot",
1125                    ));
1126                }
1127                _ => {}
1128            }
1129        }
1130    }
1131}