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}