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