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