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