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}