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, NavigatePageCommand, PressKeyCommand,
21 ReadFileChunkCommand, RegisterHookCommand, RegisterMobileDownloadHook,
22 RegisterMobileFileChooserHook, SaveMobileDownloadCommand, ScreenshotCommand,
23 SetMobileFileChooserFilesCommand, SurfaceSessionCommand, UploadFileChunkCommand,
24 WaitForHookCommand, WaitForSelectorCommand,
25};
26
27use super::hook::{DownloadHook, FileChooserHook};
28
29static MOBILE_TRANSFER_COUNTER: AtomicU64 = AtomicU64::new(1);
30
31pub trait AndroidHookType: private::Sealed + Clone + Send + Sync + 'static {
32 type Output;
33}
34
35mod private {
36 use super::*;
37 pub trait Sealed {
38 fn name() -> &'static str;
39 fn decode(
40 app: &AndroidApp,
41 result: HookCompletionResult,
42 ) -> Result<<Self as AndroidHookType>::Output>
43 where
44 Self: AndroidHookType;
45 }
46}
47
48impl AndroidHookType for FileChooserHook {
49 type Output = AndroidFileChooser;
50}
51impl private::Sealed for FileChooserHook {
52 fn name() -> &'static str {
53 "file_chooser"
54 }
55 fn decode(app: &AndroidApp, result: HookCompletionResult) -> Result<AndroidFileChooser> {
56 let HookCompletionResult::MobileFileChooser(result) = result else {
57 return Err(Error::new(
58 "Android file chooser hook returned an invalid result",
59 ));
60 };
61 Ok(AndroidFileChooser {
62 app: app.clone(),
63 id: result.file_chooser_id,
64 is_multiple: result.is_multiple,
65 })
66 }
67}
68impl AndroidHookType for DownloadHook {
69 type Output = AndroidDownload;
70}
71impl private::Sealed for DownloadHook {
72 fn name() -> &'static str {
73 "download"
74 }
75 fn decode(app: &AndroidApp, result: HookCompletionResult) -> Result<AndroidDownload> {
76 let HookCompletionResult::MobileDownload(result) = result else {
77 return Err(Error::new(
78 "Android download hook returned an invalid result",
79 ));
80 };
81 Ok(AndroidDownload {
82 app: app.clone(),
83 id: result.download_id,
84 suggested_filename: result.suggested_filename,
85 })
86 }
87}
88
89pub struct AndroidHook<T: AndroidHookType> {
90 app: AndroidApp,
91 id: String,
92 _type: PhantomData<T>,
93}
94
95#[derive(Clone)]
96pub struct AndroidFileChooser {
97 app: AndroidApp,
98 id: String,
99 is_multiple: bool,
100}
101
102#[derive(Clone)]
103pub struct AndroidDownload {
104 app: AndroidApp,
105 id: String,
106 suggested_filename: String,
107}
108use tokio::sync::{Mutex as AsyncMutex, mpsc};
109use tokio_stream::wrappers::ReceiverStream;
110
111use super::command::command_retry_options;
112use super::runtime::get_runtime;
113use super::types::{
114 AccessibilitySnapshotFormat, AccessibilitySnapshotMode, AccessibilitySnapshotOptions,
115 CommandOptions, Error, PressOptions, Result, RuntimeClient, ScreenshotOptions, TextResult,
116 WaitForSelectorOptions,
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 goto(&self, url: &str, options: CommandOptions) -> Result<()> {
558 self.navigate(url, options).await
559 }
560
561 pub async fn navigate(&self, url: &str, options: CommandOptions) -> Result<()> {
562 let mut state = self.inner.state.lock().await;
563 let handle = self.ensure_handle(&mut state).await?;
564 ensure_android_app_open(handle, &self.inner.session_id)?;
565 handle
566 .command_tx
567 .send(ContextSessionCommand {
568 surface_session_id: self.inner.surface_session_id.clone(),
569 context_session_id: self.inner.session_id.clone(),
570 command: Some(ContextCommand::Navigate(NavigatePageCommand {
571 url: url.to_string(),
572 retry_options: command_retry_options(options.timeout_ms),
573 })),
574 })
575 .await
576 .map_err(|_| Error::new("failed to send mobile NavigatePageCommand"))?;
577 loop {
578 let event =
579 handle.events.message().await?.ok_or_else(|| {
580 Error::new("mobile app session closed while opening deep link")
581 })?;
582 match event.event {
583 Some(ContextEvent::Navigated(_)) => return Ok(()),
584 Some(ContextEvent::Error(error)) => return Err(Error::new(error.message)),
585 Some(ContextEvent::Closed(_)) => {
586 handle.closed = true;
587 return Err(Error::new(
588 "mobile app session closed while opening deep link",
589 ));
590 }
591 _ => {}
592 }
593 }
594 }
595
596 pub async fn click(&self, selector: &str, options: CommandOptions) -> Result<()> {
597 let selector = normalize_mobile_selector_for_transport(selector);
598 let mut state = self.inner.state.lock().await;
599 let handle = self.ensure_handle(&mut state).await?;
600 ensure_android_app_open(handle, &self.inner.session_id)?;
601
602 handle
603 .command_tx
604 .send(ContextSessionCommand {
605 surface_session_id: self.inner.surface_session_id.clone(),
606 context_session_id: self.inner.session_id.clone(),
607 command: Some(ContextCommand::ClickElement(ClickElementCommand {
608 css_selector: selector.clone(),
609 retry_options: command_retry_options(options.timeout_ms),
610 })),
611 })
612 .await
613 .map_err(|_| Error::new("failed to send ClickElementCommand"))?;
614
615 loop {
616 let event =
617 handle.events.message().await?.ok_or_else(|| {
618 Error::new("app session closed while waiting for click result")
619 })?;
620
621 match event.event {
622 Some(ContextEvent::Attached(_)) => {}
623 Some(ContextEvent::ElementClicked(_)) => return Ok(()),
624 Some(ContextEvent::Error(error)) => {
625 return Err(Error::new(format!(
626 "app session error while clicking Android locator {:?}: {}",
627 selector, error.message,
628 )));
629 }
630 Some(ContextEvent::Closed(_)) => {
631 handle.closed = true;
632 return Err(Error::new(format!(
633 "app session {} closed while waiting for click result",
634 self.inner.session_id
635 )));
636 }
637 _ => {}
638 }
639 }
640 }
641
642 pub async fn fill(&self, selector: &str, value: &str, options: CommandOptions) -> Result<()> {
643 let selector = normalize_mobile_selector_for_transport(selector);
644 let mut state = self.inner.state.lock().await;
645 let handle = self.ensure_handle(&mut state).await?;
646 ensure_android_app_open(handle, &self.inner.session_id)?;
647
648 handle
649 .command_tx
650 .send(ContextSessionCommand {
651 surface_session_id: self.inner.surface_session_id.clone(),
652 context_session_id: self.inner.session_id.clone(),
653 command: Some(ContextCommand::FillElement(FillElementCommand {
654 css_selector: selector.clone(),
655 value: value.to_string(),
656 retry_options: command_retry_options(options.timeout_ms),
657 })),
658 })
659 .await
660 .map_err(|_| Error::new("failed to send FillElementCommand"))?;
661
662 loop {
663 let event =
664 handle.events.message().await?.ok_or_else(|| {
665 Error::new("app session closed while waiting for fill result")
666 })?;
667
668 match event.event {
669 Some(ContextEvent::Attached(_)) => {}
670 Some(ContextEvent::ElementFilled(_)) => return Ok(()),
671 Some(ContextEvent::Error(error)) => {
672 return Err(Error::new(format!(
673 "app session error while filling Android locator {:?}: {}",
674 selector, error.message,
675 )));
676 }
677 Some(ContextEvent::Closed(_)) => {
678 handle.closed = true;
679 return Err(Error::new(format!(
680 "app session {} closed while waiting for fill result",
681 self.inner.session_id
682 )));
683 }
684 _ => {}
685 }
686 }
687 }
688
689 pub async fn count(&self, selector: &str, options: CommandOptions) -> Result<u32> {
690 let selector = normalize_mobile_selector_for_transport(selector);
691 let mut state = self.inner.state.lock().await;
692 let handle = self.ensure_handle(&mut state).await?;
693 ensure_android_app_open(handle, &self.inner.session_id)?;
694
695 handle
696 .command_tx
697 .send(ContextSessionCommand {
698 surface_session_id: self.inner.surface_session_id.clone(),
699 context_session_id: self.inner.session_id.clone(),
700 command: Some(ContextCommand::CountElements(CountElementsCommand {
701 css_selector: selector.clone(),
702 retry_options: command_retry_options(options.timeout_ms),
703 })),
704 })
705 .await
706 .map_err(|_| Error::new("failed to send CountElementsCommand"))?;
707
708 loop {
709 let event =
710 handle.events.message().await?.ok_or_else(|| {
711 Error::new("app session closed while waiting for count result")
712 })?;
713
714 match event.event {
715 Some(ContextEvent::Attached(_)) => {}
716 Some(ContextEvent::ElementCounted(counted)) => return Ok(counted.count),
717 Some(ContextEvent::Error(error)) => {
718 return Err(Error::new(format!(
719 "app session error while counting Android locator {:?}: {}",
720 selector, error.message,
721 )));
722 }
723 Some(ContextEvent::Closed(_)) => {
724 handle.closed = true;
725 return Err(Error::new(format!(
726 "app session {} closed while waiting for count result",
727 self.inner.session_id
728 )));
729 }
730 _ => {}
731 }
732 }
733 }
734
735 pub async fn focus(&self, selector: &str, options: CommandOptions) -> Result<()> {
736 let selector = normalize_mobile_selector_for_transport(selector);
737 let mut state = self.inner.state.lock().await;
738 let handle = self.ensure_handle(&mut state).await?;
739 ensure_android_app_open(handle, &self.inner.session_id)?;
740
741 handle
742 .command_tx
743 .send(ContextSessionCommand {
744 surface_session_id: self.inner.surface_session_id.clone(),
745 context_session_id: self.inner.session_id.clone(),
746 command: Some(ContextCommand::FocusElement(FocusElementCommand {
747 css_selector: selector.clone(),
748 retry_options: command_retry_options(options.timeout_ms),
749 })),
750 })
751 .await
752 .map_err(|_| Error::new("failed to send FocusElementCommand"))?;
753
754 loop {
755 let event =
756 handle.events.message().await?.ok_or_else(|| {
757 Error::new("app session closed while waiting for focus result")
758 })?;
759
760 match event.event {
761 Some(ContextEvent::Attached(_)) => {}
762 Some(ContextEvent::ElementFocused(_)) => return Ok(()),
763 Some(ContextEvent::Error(error)) => {
764 return Err(Error::new(format!(
765 "app session error while focusing Android locator {:?}: {}",
766 selector, error.message,
767 )));
768 }
769 Some(ContextEvent::Closed(_)) => {
770 handle.closed = true;
771 return Err(Error::new(format!(
772 "app session {} closed while waiting for focus result",
773 self.inner.session_id
774 )));
775 }
776 _ => {}
777 }
778 }
779 }
780
781 pub async fn press(&self, selector: &str, key: &str, options: PressOptions) -> Result<()> {
782 let selector = normalize_mobile_selector_for_transport(selector);
783 let mut state = self.inner.state.lock().await;
784 let handle = self.ensure_handle(&mut state).await?;
785 ensure_android_app_open(handle, &self.inner.session_id)?;
786
787 handle
788 .command_tx
789 .send(ContextSessionCommand {
790 surface_session_id: self.inner.surface_session_id.clone(),
791 context_session_id: self.inner.session_id.clone(),
792 command: Some(ContextCommand::PressKey(PressKeyCommand {
793 css_selector: selector.clone(),
794 key: key.to_string(),
795 text: options.text,
796 retry_options: command_retry_options(options.timeout_ms),
797 })),
798 })
799 .await
800 .map_err(|_| Error::new("failed to send PressKeyCommand"))?;
801
802 loop {
803 let event =
804 handle.events.message().await?.ok_or_else(|| {
805 Error::new("app session closed while waiting for press result")
806 })?;
807
808 match event.event {
809 Some(ContextEvent::Attached(_)) => {}
810 Some(ContextEvent::KeyPressed(_)) => return Ok(()),
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<Option<String>> {
834 self.read_text(selector, options, true)
835 .await
836 .map(|result| Some(result.text))
837 }
838
839 pub async fn inner_text(&self, selector: &str, options: CommandOptions) -> Result<String> {
840 self.read_text(selector, options, false)
841 .await
842 .map(|result| result.text)
843 }
844
845 pub async fn wait_for_selector(
846 &self,
847 selector: &str,
848 options: WaitForSelectorOptions,
849 ) -> Result<()> {
850 let selector = normalize_mobile_selector_for_transport(selector);
851 let mut state = self.inner.state.lock().await;
852 let handle = self.ensure_handle(&mut state).await?;
853 ensure_android_app_open(handle, &self.inner.session_id)?;
854
855 handle
856 .command_tx
857 .send(ContextSessionCommand {
858 surface_session_id: self.inner.surface_session_id.clone(),
859 context_session_id: self.inner.session_id.clone(),
860 command: Some(ContextCommand::WaitForSelector(WaitForSelectorCommand {
861 css_selector: selector.clone(),
862 visible: options.visible,
863 retry_options: command_retry_options(options.timeout_ms),
864 })),
865 })
866 .await
867 .map_err(|_| Error::new("failed to send WaitForSelectorCommand"))?;
868
869 loop {
870 let event = handle.events.message().await?.ok_or_else(|| {
871 Error::new("app session closed while waiting for selector result")
872 })?;
873
874 match event.event {
875 Some(ContextEvent::Attached(_)) => {}
876 Some(ContextEvent::SelectorWaitSatisfied(_)) => return Ok(()),
877 Some(ContextEvent::Error(error)) => {
878 return Err(Error::new(format!(
879 "app session error while waiting for Android locator {:?}: {}",
880 selector, error.message,
881 )));
882 }
883 Some(ContextEvent::Closed(_)) => {
884 handle.closed = true;
885 return Err(Error::new(format!(
886 "app session {} closed while waiting for selector result",
887 self.inner.session_id
888 )));
889 }
890 _ => {}
891 }
892 }
893 }
894
895 pub async fn screenshot(&self) -> Result<Vec<u8>> {
896 self.screenshot_with_options(ScreenshotOptions::default())
897 .await
898 }
899
900 pub async fn screenshot_with_options(&self, options: ScreenshotOptions) -> Result<Vec<u8>> {
901 let mut state = self.inner.state.lock().await;
902 let handle = self.ensure_handle(&mut state).await?;
903 ensure_android_app_open(handle, &self.inner.session_id)?;
904
905 handle
906 .command_tx
907 .send(ContextSessionCommand {
908 surface_session_id: self.inner.surface_session_id.clone(),
909 context_session_id: self.inner.session_id.clone(),
910 command: Some(ContextCommand::Screenshot(ScreenshotCommand {
911 retry_options: command_retry_options(options.timeout_ms),
912 full_page: Some(options.full_page),
913 })),
914 })
915 .await
916 .map_err(|_| Error::new("failed to send ScreenshotCommand"))?;
917
918 loop {
919 let event = handle.events.message().await?.ok_or_else(|| {
920 Error::new("app session closed while waiting for screenshot result")
921 })?;
922
923 match event.event {
924 Some(ContextEvent::Attached(_)) => {}
925 Some(ContextEvent::ScreenshotCaptured(screenshot)) => {
926 if let Some(path) = options.path.as_ref() {
927 std::fs::write(path, &screenshot.png_data).map_err(|error| {
928 Error::new(format!("write screenshot to {}: {error}", path.display()))
929 })?;
930 }
931 return Ok(screenshot.png_data);
932 }
933 Some(ContextEvent::Error(error)) => {
934 return Err(Error::new(format!(
935 "app session error while capturing Android screenshot: {}",
936 error.message
937 )));
938 }
939 Some(ContextEvent::Closed(_)) => {
940 handle.closed = true;
941 return Err(Error::new(format!(
942 "app session {} closed while waiting for screenshot result",
943 self.inner.session_id
944 )));
945 }
946 _ => {}
947 }
948 }
949 }
950
951 async fn ensure_handle<'a>(
952 &self,
953 state: &'a mut AndroidAppState,
954 ) -> Result<&'a mut AndroidTabHandle> {
955 if state.handle.is_none() {
956 let mut engine = self.inner.runtime.engine.clone();
957 let (command_tx, command_rx) = mpsc::channel(16);
958 let response = engine
959 .context_session(tonic::Request::new(ReceiverStream::new(command_rx)))
960 .await?;
961 state.handle = Some(AndroidTabHandle {
962 command_tx,
963 events: response.into_inner(),
964 closed: false,
965 });
966 }
967
968 state
969 .handle
970 .as_mut()
971 .ok_or_else(|| Error::new("android app session handle was not initialized"))
972 }
973
974 async fn read_text(
975 &self,
976 selector: &str,
977 options: CommandOptions,
978 text_content: bool,
979 ) -> Result<TextResult> {
980 let selector = normalize_mobile_selector_for_transport(selector);
981 let mut state = self.inner.state.lock().await;
982 let handle = self.ensure_handle(&mut state).await?;
983 ensure_android_app_open(handle, &self.inner.session_id)?;
984
985 let command = if text_content {
986 ContextCommand::GetTextContent(GetTextContentCommand {
987 css_selector: selector.clone(),
988 retry_options: command_retry_options(options.timeout_ms),
989 })
990 } else {
991 ContextCommand::GetInnerText(GetInnerTextCommand {
992 css_selector: selector.clone(),
993 retry_options: command_retry_options(options.timeout_ms),
994 })
995 };
996
997 handle
998 .command_tx
999 .send(ContextSessionCommand {
1000 surface_session_id: self.inner.surface_session_id.clone(),
1001 context_session_id: self.inner.session_id.clone(),
1002 command: Some(command),
1003 })
1004 .await
1005 .map_err(|_| Error::new("failed to send text read command"))?;
1006
1007 loop {
1008 let event =
1009 handle.events.message().await?.ok_or_else(|| {
1010 Error::new("app session closed while waiting for text result")
1011 })?;
1012
1013 match event.event {
1014 Some(ContextEvent::Attached(_)) => {}
1015 Some(ContextEvent::TextContentResolved(text)) => {
1016 return Ok(TextResult {
1017 selector: text.css_selector,
1018 text: text.text,
1019 note: text.note,
1020 });
1021 }
1022 Some(ContextEvent::InnerTextResolved(text)) => {
1023 return Ok(TextResult {
1024 selector: text.css_selector,
1025 text: text.text,
1026 note: text.note,
1027 });
1028 }
1029 Some(ContextEvent::Error(error)) => {
1030 return Err(Error::new(format!(
1031 "app session error while reading Android text for {:?}: {}",
1032 selector, error.message,
1033 )));
1034 }
1035 Some(ContextEvent::Closed(_)) => {
1036 handle.closed = true;
1037 return Err(Error::new(format!(
1038 "app session {} closed while waiting for text result",
1039 self.inner.session_id
1040 )));
1041 }
1042 _ => {}
1043 }
1044 }
1045 }
1046}
1047
1048impl<T: AndroidHookType> AndroidHook<T> {
1049 pub fn id(&self) -> &str {
1050 &self.id
1051 }
1052
1053 pub async fn wait(&self) -> Result<T::Output> {
1054 self.wait_with_options(CommandOptions::default()).await
1055 }
1056
1057 pub async fn wait_with_options(&self, options: CommandOptions) -> Result<T::Output> {
1058 let mut state = self.app.inner.state.lock().await;
1059 let handle = self.app.ensure_handle(&mut state).await?;
1060 handle
1061 .command_tx
1062 .send(ContextSessionCommand {
1063 surface_session_id: self.app.inner.surface_session_id.clone(),
1064 context_session_id: self.app.inner.session_id.clone(),
1065 command: Some(ContextCommand::WaitForHook(WaitForHookCommand {
1066 hook_id: self.id.clone(),
1067 retry_options: command_retry_options(options.timeout_ms),
1068 })),
1069 })
1070 .await
1071 .map_err(|_| Error::new("failed to send Android WaitForHookCommand"))?;
1072 loop {
1073 let event =
1074 handle.events.message().await?.ok_or_else(|| {
1075 Error::new("Android app session closed while waiting for hook")
1076 })?;
1077 match event.event {
1078 Some(ContextEvent::HookCompleted(event)) if event.hook_id == self.id => {
1079 return T::decode(
1080 &self.app,
1081 event
1082 .result
1083 .ok_or_else(|| Error::new("Android hook completed without a result"))?,
1084 );
1085 }
1086 Some(ContextEvent::Error(error)) => return Err(Error::new(error.message)),
1087 _ => {}
1088 }
1089 }
1090 }
1091}
1092
1093impl AndroidFileChooser {
1094 pub fn id(&self) -> &str {
1095 &self.id
1096 }
1097 pub fn app(&self) -> &AndroidApp {
1098 &self.app
1099 }
1100 pub fn is_multiple(&self) -> bool {
1101 self.is_multiple
1102 }
1103 pub async fn set_file(&self, path: impl AsRef<Path>) -> Result<()> {
1104 self.set_files([path]).await
1105 }
1106 pub async fn set_files<I, P>(&self, paths: I) -> Result<()>
1107 where
1108 I: IntoIterator<Item = P>,
1109 P: AsRef<Path>,
1110 {
1111 let paths = paths
1112 .into_iter()
1113 .map(|path| path.as_ref().to_path_buf())
1114 .collect::<Vec<_>>();
1115 if !self.is_multiple && paths.len() > 1 {
1116 return Err(Error::new(
1117 "Android file chooser does not accept multiple files",
1118 ));
1119 }
1120 let mut state = self.app.inner.state.lock().await;
1121 let handle = self.app.ensure_handle(&mut state).await?;
1122 let mut file_ids = Vec::with_capacity(paths.len());
1123 for path in paths {
1124 file_ids.push(upload_android_client_file(&self.app, handle, &path).await?);
1125 }
1126 handle
1127 .command_tx
1128 .send(ContextSessionCommand {
1129 surface_session_id: self.app.inner.surface_session_id.clone(),
1130 context_session_id: self.app.inner.session_id.clone(),
1131 command: Some(ContextCommand::SetMobileFileChooserFiles(
1132 SetMobileFileChooserFilesCommand {
1133 file_chooser_id: self.id.clone(),
1134 file_ids,
1135 retry_options: None,
1136 },
1137 )),
1138 })
1139 .await
1140 .map_err(|_| Error::new("failed to send SetMobileFileChooserFilesCommand"))?;
1141 loop {
1142 let event = handle.events.message().await?.ok_or_else(|| {
1143 Error::new("Android app session closed while setting chooser files")
1144 })?;
1145 match event.event {
1146 Some(ContextEvent::MobileFileChooserFilesSet(result))
1147 if result.file_chooser_id == self.id =>
1148 {
1149 return Ok(());
1150 }
1151 Some(ContextEvent::Error(error)) => return Err(Error::new(error.message)),
1152 _ => {}
1153 }
1154 }
1155 }
1156}
1157
1158impl AndroidDownload {
1159 pub fn id(&self) -> &str {
1160 &self.id
1161 }
1162 pub fn app(&self) -> &AndroidApp {
1163 &self.app
1164 }
1165 pub fn suggested_filename(&self) -> &str {
1166 &self.suggested_filename
1167 }
1168 pub async fn save_as(&self, path: impl AsRef<Path>) -> Result<()> {
1169 let mut state = self.app.inner.state.lock().await;
1170 let handle = self.app.ensure_handle(&mut state).await?;
1171 handle
1172 .command_tx
1173 .send(ContextSessionCommand {
1174 surface_session_id: self.app.inner.surface_session_id.clone(),
1175 context_session_id: self.app.inner.session_id.clone(),
1176 command: Some(ContextCommand::SaveMobileDownload(
1177 SaveMobileDownloadCommand {
1178 download_id: self.id.clone(),
1179 retry_options: None,
1180 },
1181 )),
1182 })
1183 .await
1184 .map_err(|_| Error::new("failed to send SaveMobileDownloadCommand"))?;
1185 loop {
1186 let event =
1187 handle.events.message().await?.ok_or_else(|| {
1188 Error::new("Android app session closed while saving download")
1189 })?;
1190 match event.event {
1191 Some(ContextEvent::MobileDownloadSaved(result))
1192 if result.download_id == self.id =>
1193 {
1194 return download_android_client_file(
1195 &self.app,
1196 handle,
1197 &result.file_id,
1198 path.as_ref(),
1199 )
1200 .await;
1201 }
1202 Some(ContextEvent::Error(error)) => return Err(Error::new(error.message)),
1203 _ => {}
1204 }
1205 }
1206 }
1207}
1208
1209async fn upload_android_client_file(
1210 app: &AndroidApp,
1211 handle: &mut AndroidTabHandle,
1212 path: &Path,
1213) -> Result<String> {
1214 let mut source = fs::File::open(path)
1215 .map_err(|error| Error::new(format!("open upload {}: {error}", path.display())))?;
1216 let size = source
1217 .metadata()
1218 .map_err(|error| Error::new(error.to_string()))?
1219 .len();
1220 let transfer_id = format!(
1221 "rust-mobile-upload-{}-{}",
1222 std::process::id(),
1223 MOBILE_TRANSFER_COUNTER.fetch_add(1, Ordering::Relaxed)
1224 );
1225 let name = path
1226 .file_name()
1227 .and_then(|name| name.to_str())
1228 .ok_or_else(|| Error::new("upload requires a valid file name"))?
1229 .to_string();
1230 let mut offset = 0_u64;
1231 loop {
1232 let mut data = vec![0; 256 * 1024];
1233 let count = source
1234 .read(&mut data)
1235 .map_err(|error| Error::new(error.to_string()))?;
1236 data.truncate(count);
1237 let last = offset + count as u64 >= size;
1238 handle
1239 .command_tx
1240 .send(ContextSessionCommand {
1241 surface_session_id: app.inner.surface_session_id.clone(),
1242 context_session_id: app.inner.session_id.clone(),
1243 command: Some(ContextCommand::UploadFileChunk(UploadFileChunkCommand {
1244 transfer_id: transfer_id.clone(),
1245 name: name.clone(),
1246 offset,
1247 data,
1248 last,
1249 })),
1250 })
1251 .await
1252 .map_err(|_| Error::new("failed to send UploadFileChunkCommand"))?;
1253 offset += count as u64;
1254 if last {
1255 break;
1256 }
1257 }
1258 loop {
1259 let event = handle
1260 .events
1261 .message()
1262 .await?
1263 .ok_or_else(|| Error::new("Android app session closed while uploading file"))?;
1264 match event.event {
1265 Some(ContextEvent::FileUploaded(result)) if result.transfer_id == transfer_id => {
1266 return Ok(result.file_id);
1267 }
1268 Some(ContextEvent::Error(error)) => return Err(Error::new(error.message)),
1269 _ => {}
1270 }
1271 }
1272}
1273
1274async fn download_android_client_file(
1275 app: &AndroidApp,
1276 handle: &mut AndroidTabHandle,
1277 file_id: &str,
1278 path: &Path,
1279) -> Result<()> {
1280 let temporary = path.with_extension(format!(
1281 "allwright-{}.tmp",
1282 MOBILE_TRANSFER_COUNTER.fetch_add(1, Ordering::Relaxed)
1283 ));
1284 let mut output = fs::OpenOptions::new()
1285 .write(true)
1286 .create_new(true)
1287 .open(&temporary)
1288 .map_err(|error| Error::new(error.to_string()))?;
1289 let mut offset = 0_u64;
1290 loop {
1291 handle
1292 .command_tx
1293 .send(ContextSessionCommand {
1294 surface_session_id: app.inner.surface_session_id.clone(),
1295 context_session_id: app.inner.session_id.clone(),
1296 command: Some(ContextCommand::ReadFileChunk(ReadFileChunkCommand {
1297 file_id: file_id.to_string(),
1298 offset,
1299 max_bytes: 256 * 1024,
1300 })),
1301 })
1302 .await
1303 .map_err(|_| Error::new("failed to send ReadFileChunkCommand"))?;
1304 let event = handle
1305 .events
1306 .message()
1307 .await?
1308 .ok_or_else(|| Error::new("Android app session closed while downloading file"))?;
1309 match event.event {
1310 Some(ContextEvent::FileChunk(chunk))
1311 if chunk.file_id == file_id && chunk.offset == offset =>
1312 {
1313 output
1314 .write_all(&chunk.data)
1315 .map_err(|error| Error::new(error.to_string()))?;
1316 offset += chunk.data.len() as u64;
1317 if chunk.last {
1318 drop(output);
1319 fs::rename(&temporary, path).map_err(|error| Error::new(error.to_string()))?;
1320 return Ok(());
1321 }
1322 }
1323 Some(ContextEvent::Error(error)) => {
1324 let _ = fs::remove_file(&temporary);
1325 return Err(Error::new(error.message));
1326 }
1327 _ => {}
1328 }
1329 }
1330}
1331
1332impl AndroidLocator {
1333 pub fn app(&self) -> &AndroidApp {
1334 &self.page
1335 }
1336
1337 pub fn selector(&self) -> &str {
1338 &self.selector
1339 }
1340
1341 pub fn locator(&self, selector: impl Into<String>) -> AndroidLocator {
1342 AndroidLocator {
1343 page: self.page.clone(),
1344 selector: chain_mobile_selector_for_transport(&self.selector, &selector.into()),
1345 }
1346 }
1347
1348 pub fn get_by_role(&self, role: impl Into<String>) -> AndroidLocator {
1349 self.get_by_role_with_options(role, RoleOptions::default())
1350 }
1351
1352 pub fn get_by_role_with_options(
1353 &self,
1354 role: impl Into<String>,
1355 options: RoleOptions,
1356 ) -> AndroidLocator {
1357 let mut spec = serde_json::to_value(options).expect("role options");
1358 spec["kind"] = serde_json::json!("role");
1359 spec["role"] = serde_json::json!(role.into());
1360 self.locator(mobile_semantic_selector(spec))
1361 }
1362
1363 pub fn get_by_text(&self, text: impl Into<TextMatcher>) -> AndroidLocator {
1364 self.get_by_text_with_options(text, TextOptions::default())
1365 }
1366
1367 pub fn get_by_text_with_options(
1368 &self,
1369 text: impl Into<TextMatcher>,
1370 options: TextOptions,
1371 ) -> AndroidLocator {
1372 self.locator(mobile_text_selector("text", text.into(), options))
1373 }
1374
1375 pub fn get_by_label(&self, text: impl Into<TextMatcher>) -> AndroidLocator {
1376 self.get_by_label_with_options(text, TextOptions::default())
1377 }
1378
1379 pub fn get_by_label_with_options(
1380 &self,
1381 text: impl Into<TextMatcher>,
1382 options: TextOptions,
1383 ) -> AndroidLocator {
1384 self.locator(mobile_text_selector("label", text.into(), options))
1385 }
1386
1387 pub fn get_by_test_id(&self, text: impl Into<TextMatcher>) -> AndroidLocator {
1388 self.locator(mobile_semantic_selector(
1389 serde_json::json!({"kind":"testId", "text":text.into()}),
1390 ))
1391 }
1392
1393 pub async fn click(&self, options: CommandOptions) -> Result<()> {
1394 self.page.click(&self.selector, options).await
1395 }
1396
1397 pub async fn count(&self, options: CommandOptions) -> Result<u32> {
1398 self.page.count(&self.selector, options).await
1399 }
1400
1401 pub async fn focus(&self, options: CommandOptions) -> Result<()> {
1402 self.page.focus(&self.selector, options).await
1403 }
1404
1405 pub async fn fill(&self, value: &str, options: CommandOptions) -> Result<()> {
1406 self.page.fill(&self.selector, value, options).await
1407 }
1408
1409 pub async fn press(&self, key: &str, options: PressOptions) -> Result<()> {
1410 self.page.press(&self.selector, key, options).await
1411 }
1412
1413 pub async fn text_content(&self, options: CommandOptions) -> Result<Option<String>> {
1414 self.page.text_content(&self.selector, options).await
1415 }
1416
1417 pub async fn inner_text(&self, options: CommandOptions) -> Result<String> {
1418 self.page.inner_text(&self.selector, options).await
1419 }
1420
1421 pub async fn wait_for(&self, options: WaitForSelectorOptions) -> Result<()> {
1422 self.page.wait_for_selector(&self.selector, options).await
1423 }
1424}
1425
1426fn ensure_android_device_open(state: &AndroidDeviceState, session_id: &str) -> Result<()> {
1427 if state.closed {
1428 return Err(Error::new(format!(
1429 "android device session {} is closed",
1430 session_id
1431 )));
1432 }
1433 Ok(())
1434}
1435
1436fn ensure_android_app_open(handle: &AndroidTabHandle, session_id: &str) -> Result<()> {
1437 if handle.closed {
1438 return Err(Error::new(format!(
1439 "android app session {} is closed",
1440 session_id
1441 )));
1442 }
1443 Ok(())
1444}
1445
1446fn mobile_semantic_selector(spec: serde_json::Value) -> String {
1447 format!(
1448 "aw={}",
1449 serde_json::to_string(&spec.to_string()).expect("JSON string")
1450 )
1451}
1452
1453fn mobile_text_selector(kind: &str, text: TextMatcher, options: TextOptions) -> String {
1454 mobile_semantic_selector(serde_json::json!({"kind":kind, "text":text, "exact":options.exact}))
1455}
1456
1457#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1458enum MobileSelectorFlavor {
1459 Css,
1460 XPath,
1461 UiAutomator,
1462 Semantic,
1463}
1464
1465impl MobileSelectorFlavor {
1466 fn as_str(self) -> &'static str {
1467 match self {
1468 Self::Css => "css",
1469 Self::XPath => "xpath",
1470 Self::UiAutomator => "uia",
1471 Self::Semantic => "aw",
1472 }
1473 }
1474}
1475
1476const UIAUTOMATOR_SELECTOR_KEYS: &[&str] = &[
1477 "text",
1478 "textcontains",
1479 "textmatches",
1480 "textstartswith",
1481 "classname",
1482 "classnamematches",
1483 "description",
1484 "desc",
1485 "descriptioncontains",
1486 "desccontains",
1487 "descriptionmatches",
1488 "descmatches",
1489 "descriptionstartswith",
1490 "descstartswith",
1491 "checkable",
1492 "checked",
1493 "clickable",
1494 "longclickable",
1495 "scrollable",
1496 "enabled",
1497 "focusable",
1498 "focused",
1499 "selected",
1500 "packagename",
1501 "package",
1502 "packagenamematches",
1503 "resourceid",
1504 "resourceidmatches",
1505 "index",
1506 "instance",
1507];
1508
1509fn parse_explicit_mobile_selector_prefix(selector: &str) -> Option<(MobileSelectorFlavor, usize)> {
1510 let lowered = selector.to_ascii_lowercase();
1511 if lowered.starts_with("xpath=") || lowered.starts_with("xpath:") {
1512 return Some((MobileSelectorFlavor::XPath, 6));
1513 }
1514 if lowered.starts_with("uia=") || lowered.starts_with("uia:") {
1515 return Some((MobileSelectorFlavor::UiAutomator, 4));
1516 }
1517 if lowered.starts_with("aw=") || lowered.starts_with("aw:") {
1518 return Some((MobileSelectorFlavor::Semantic, 3));
1519 }
1520 if let Some(prefix_len) = parse_ui_automator_selector_prefix(&lowered) {
1521 return Some((MobileSelectorFlavor::UiAutomator, prefix_len));
1522 }
1523 if lowered.starts_with("text=") || lowered.starts_with("text:") {
1524 return Some((MobileSelectorFlavor::UiAutomator, 5));
1525 }
1526 if lowered.starts_with("id=") || lowered.starts_with("id:") {
1527 return Some((MobileSelectorFlavor::Css, 3));
1528 }
1529 if lowered.starts_with("css=") || lowered.starts_with("css:") {
1530 return Some((MobileSelectorFlavor::Css, 4));
1531 }
1532 None
1533}
1534
1535fn parse_ui_automator_selector_prefix(selector: &str) -> Option<usize> {
1536 UIAUTOMATOR_SELECTOR_KEYS.iter().find_map(|key| {
1537 if selector.starts_with(key) {
1538 let separator = selector.as_bytes().get(key.len()).copied()?;
1539 if separator == b'=' || separator == b':' {
1540 return Some(key.len() + 1);
1541 }
1542 }
1543 None
1544 })
1545}
1546
1547fn find_json_string_end(value: &str) -> Option<usize> {
1548 let bytes = value.as_bytes();
1549 if bytes.first().copied()? != b'"' {
1550 return None;
1551 }
1552
1553 let mut index = 1usize;
1554 let mut escaped = false;
1555 while index < bytes.len() {
1556 let byte = bytes[index];
1557 if escaped {
1558 escaped = false;
1559 index += 1;
1560 continue;
1561 }
1562 match byte {
1563 b'\\' => escaped = true,
1564 b'"' => return Some(index + 1),
1565 _ => {}
1566 }
1567 index += 1;
1568 }
1569 None
1570}
1571
1572fn is_normalized_mobile_transport_selector(selector: &str) -> bool {
1573 let trimmed = selector.trim();
1574 if trimmed.is_empty() {
1575 return false;
1576 }
1577
1578 let mut index = 0usize;
1579 while index < trimmed.len() {
1580 let Some((_, prefix_len)) = parse_explicit_mobile_selector_prefix(&trimmed[index..]) else {
1581 return false;
1582 };
1583 index += prefix_len;
1584
1585 let remainder = &trimmed[index..];
1586 let Some(json_end) = find_json_string_end(remainder) else {
1587 return false;
1588 };
1589 index += json_end;
1590
1591 if index == trimmed.len() {
1592 return true;
1593 }
1594
1595 let whitespace_len = trimmed[index..]
1596 .chars()
1597 .take_while(|char| char.is_ascii_whitespace())
1598 .count();
1599 if whitespace_len == 0 {
1600 return false;
1601 }
1602 index += whitespace_len;
1603
1604 if parse_explicit_mobile_selector_prefix(&trimmed[index..]).is_none() {
1605 return false;
1606 }
1607 }
1608
1609 true
1610}
1611
1612fn decode_selector_body(body: &str) -> String {
1613 let candidate = body.trim();
1614 if candidate.len() >= 2 && candidate.starts_with('"') && candidate.ends_with('"') {
1615 if let Ok(decoded) = serde_json::from_str::<String>(candidate) {
1616 return unescape_shell_escaped_selector(&decoded);
1617 }
1618 }
1619 unescape_shell_escaped_selector(candidate)
1620}
1621
1622fn unescape_shell_escaped_selector(value: &str) -> String {
1623 let mut result = String::with_capacity(value.len());
1624 let mut chars = value.chars().peekable();
1625 while let Some(ch) = chars.next() {
1626 if ch == '\\' {
1627 match chars.peek().copied() {
1628 Some('_' | ' ' | '#' | ':' | '[' | ']' | '(' | ')' | '"' | '\'') => {
1629 result.push(chars.next().expect("peeked char should exist"));
1630 continue;
1631 }
1632 _ => {}
1633 }
1634 }
1635 result.push(ch);
1636 }
1637 result
1638}
1639
1640fn parse_mobile_selector_for_transport(selector: &str) -> (MobileSelectorFlavor, String) {
1641 let trimmed = selector.trim();
1642 if let Some((flavor, prefix_len)) = parse_explicit_mobile_selector_prefix(trimmed) {
1643 let body = decode_selector_body(&trimmed[prefix_len..]);
1644 return match flavor {
1645 MobileSelectorFlavor::Css if prefix_len == 3 => {
1646 let normalized = if body.starts_with('#') {
1647 body
1648 } else {
1649 format!("#{body}")
1650 };
1651 (MobileSelectorFlavor::Css, normalized)
1652 }
1653 MobileSelectorFlavor::UiAutomator
1654 if prefix_len != 4 && !trimmed[..prefix_len].eq_ignore_ascii_case("text=") =>
1655 {
1656 (
1657 MobileSelectorFlavor::UiAutomator,
1658 format!("{}={body}", &trimmed[..prefix_len - 1]),
1659 )
1660 }
1661 MobileSelectorFlavor::UiAutomator if prefix_len == 5 => {
1662 (MobileSelectorFlavor::UiAutomator, format!("text={body}"))
1663 }
1664 _ => (flavor, body),
1665 };
1666 }
1667
1668 if trimmed.starts_with("//")
1669 || trimmed.starts_with(".//")
1670 || trimmed.starts_with("../")
1671 || trimmed.starts_with('/')
1672 || trimmed.starts_with('(')
1673 {
1674 return (MobileSelectorFlavor::XPath, trimmed.to_string());
1675 }
1676
1677 (MobileSelectorFlavor::Css, trimmed.to_string())
1678}
1679
1680fn normalize_mobile_selector_for_transport(selector: &str) -> String {
1681 let trimmed = selector.trim();
1682 if trimmed.is_empty() {
1683 return String::new();
1684 }
1685 let lowered = trimmed.to_ascii_lowercase();
1686 if lowered.starts_with("ref=") || lowered.starts_with("ref:") {
1687 return format!("ref={}", decode_selector_body(&trimmed[4..]));
1688 }
1689 if is_normalized_mobile_transport_selector(trimmed) {
1690 return trimmed.to_string();
1691 }
1692 let (flavor, body) = parse_mobile_selector_for_transport(selector);
1693 format!(
1694 "{}={}",
1695 flavor.as_str(),
1696 serde_json::to_string(&body).unwrap_or_else(|_| format!("{body:?}"))
1697 )
1698}
1699
1700fn chain_mobile_selector_for_transport(parent: &str, child: &str) -> String {
1701 let parent = if parent.trim().is_empty() {
1702 String::new()
1703 } else {
1704 normalize_mobile_selector_for_transport(parent)
1705 };
1706 let child = if child.trim().is_empty() {
1707 String::new()
1708 } else {
1709 normalize_mobile_selector_for_transport(child)
1710 };
1711 if parent.is_empty() {
1712 return child;
1713 }
1714 if child.is_empty() {
1715 return parent;
1716 }
1717 format!("{parent} {child}")
1718}
1719
1720impl AndroidApp {
1721 pub async fn accessibility_snapshot(&self) -> Result<String> {
1722 self.accessibility_snapshot_with_options(AccessibilitySnapshotOptions::default())
1723 .await
1724 }
1725
1726 pub async fn accessibility_snapshot_with_options(
1727 &self,
1728 options: AccessibilitySnapshotOptions,
1729 ) -> Result<String> {
1730 let mut state = self.inner.state.lock().await;
1731 let handle = self.ensure_handle(&mut state).await?;
1732 ensure_android_app_open(handle, &self.inner.session_id)?;
1733 handle
1734 .command_tx
1735 .send(ContextSessionCommand {
1736 surface_session_id: self.inner.surface_session_id.clone(),
1737 context_session_id: self.inner.session_id.clone(),
1738 command: Some(ContextCommand::AccessibilitySnapshot(
1739 AccessibilitySnapshotCommand {
1740 format: match options.format {
1741 AccessibilitySnapshotFormat::Json => "json",
1742 AccessibilitySnapshotFormat::Yaml => "yaml",
1743 }
1744 .into(),
1745 mode: match options.mode {
1746 AccessibilitySnapshotMode::Default => "default",
1747 AccessibilitySnapshotMode::Ai => "ai",
1748 AccessibilitySnapshotMode::Autoexpect => "autoexpect",
1749 AccessibilitySnapshotMode::Codegen => "codegen",
1750 }
1751 .into(),
1752 retry_options: command_retry_options(options.timeout_ms),
1753 },
1754 )),
1755 })
1756 .await
1757 .map_err(|_| Error::new("failed to send AccessibilitySnapshotCommand"))?;
1758 loop {
1759 let event = handle.events.message().await?.ok_or_else(|| {
1760 Error::new("android app session closed while capturing accessibility snapshot")
1761 })?;
1762 match event.event {
1763 Some(ContextEvent::AccessibilitySnapshotCaptured(result)) => {
1764 return Ok(result.snapshot);
1765 }
1766 Some(ContextEvent::Error(error)) => return Err(Error::new(error.message)),
1767 Some(ContextEvent::Closed(_)) => {
1768 handle.closed = true;
1769 return Err(Error::new(
1770 "android app session closed while capturing accessibility snapshot",
1771 ));
1772 }
1773 _ => {}
1774 }
1775 }
1776 }
1777}