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