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