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