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