1mod accessibility;
2use allwright_plugin_sdk::{
3 ALLWRIGHT_PLUGIN_API_VERSION, SurfaceFamily, SurfacePlugin, SurfacePluginDescriptor,
4};
5use allwright_surface_mobile::{
6 ConnectOptions, DeviceConnectionKind, DeviceTarget, LaunchOptions, MobileAppKind,
7 MobileAutomationBackend, MobileAutomationSessionInfo, MobileBrowserSessionHandle,
8 MobileCapabilitySet, MobileClickInfo, MobileCommand, MobileCommandResult, MobileConnectInfo,
9 MobileDownloadInfo, MobileDownloadSavedInfo, MobileElementCountInfo, MobileElementInfo,
10 MobileFileChooserFilesSetInfo, MobileFileChooserInfo, MobileFillInfo, MobileHookRegistration,
11 MobileHookResult, MobileHookType, MobileNavigationInfo, MobilePageInfo,
12 MobilePageSessionHandle, MobilePlatform, MobilePressInfo, MobileRuntimeReadiness,
13 MobileScreenshotInfo, MobileSurfaceProfile, MobileTextInfo, MobileWaitForSelectorInfo,
14 RuntimeMaturity, boot_surface, normalize_selector_for_transport,
15};
16use image::{DynamicImage, ImageFormat, RgbaImage};
17use regex::Regex;
18use std::collections::HashMap;
19use std::env;
20use std::ffi::{CStr, CString, c_char};
21use std::fmt;
22use std::fs;
23use std::path::{Path, PathBuf};
24use std::process::Command;
25use std::sync::{Mutex, OnceLock};
26use std::time::Duration;
27use std::time::{SystemTime, UNIX_EPOCH};
28
29#[derive(Debug, Clone, Copy, Default)]
30pub struct MobileAndroidPlugin;
31
32const ANDROID_BACKENDS: &[MobileAutomationBackend] = &[MobileAutomationBackend::UiAutomator2];
33const ANDROID_APP_KINDS: &[MobileAppKind] = &[
34 MobileAppKind::Native,
35 MobileAppKind::Hybrid,
36 MobileAppKind::BrowserWrapped,
37];
38const ANDROID_MISSING_RUNTIME_ARTIFACTS: &[&str] = &[
39 "server-side mobile session routing in allwright-core",
40 "expanded command coverage beyond connect, launch, and basic element actions",
41];
42const ANDROID_NEXT_MILESTONES: &[&str] = &[
43 "polish Android-native command semantics across focus, key input, and waits",
44 "expand runtime packaging validation across macOS, Linux, and Windows release assets",
45];
46
47static ANDROID_HOOKS: OnceLock<Mutex<HashMap<String, AndroidHookState>>> = OnceLock::new();
48static ANDROID_CHOOSERS: OnceLock<Mutex<HashMap<String, AndroidChooserState>>> = OnceLock::new();
49static ANDROID_DOWNLOADS: OnceLock<Mutex<HashMap<String, AndroidDownloadState>>> = OnceLock::new();
50
51#[derive(Debug, Clone)]
52enum AndroidHookState {
53 FileChooser {
54 device_id: String,
55 source_package: Option<String>,
56 },
57 Download {
58 device_id: String,
59 existing_files: HashMap<String, String>,
60 },
61}
62
63#[derive(Debug, Clone)]
64struct AndroidChooserState {
65 device_id: String,
66}
67
68#[derive(Debug, Clone)]
69struct AndroidDownloadState {
70 device_id: String,
71 device_path: String,
72 suggested_filename: String,
73 observed_size: Option<u64>,
74}
75
76#[derive(Debug, Clone, PartialEq, Eq)]
77pub struct AdbDevice {
78 pub serial: String,
79 pub name: Option<String>,
80 pub connection_kind: DeviceConnectionKind,
81 pub state: String,
82}
83
84#[derive(Debug, Clone, PartialEq, Eq)]
85pub enum ConnectSelectionError {
86 NoDevicesAvailable,
87 RequestedDeviceNotFound { requested: String },
88}
89
90impl fmt::Display for ConnectSelectionError {
91 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
92 match self {
93 Self::NoDevicesAvailable => write!(f, "no Android devices were discovered over ADB"),
94 Self::RequestedDeviceNotFound { requested } => {
95 write!(
96 f,
97 "requested Android device `{requested}` was not found over ADB"
98 )
99 }
100 }
101 }
102}
103
104#[derive(Debug, Clone, PartialEq, Eq)]
105pub struct UiAutomator2ConnectInfo {
106 pub device_name: Option<String>,
107 pub current_package: Option<String>,
108 pub current_activity: Option<String>,
109}
110
111#[derive(Debug, Clone, PartialEq, Eq)]
112pub struct UiAutomator2ClickInfo {
113 pub resolved_selector: String,
114 pub note: String,
115}
116
117#[derive(Debug, Clone, PartialEq, Eq)]
118pub struct UiAutomator2SourceInfo {
119 pub source: String,
120 pub current_package: Option<String>,
121 pub current_activity: Option<String>,
122}
123
124#[derive(Debug, Clone, PartialEq, Eq)]
125pub struct UiAutomator2FillInfo {
126 pub resolved_selector: String,
127 pub value: String,
128 pub note: String,
129}
130
131#[derive(Debug, Clone, PartialEq, Eq)]
132struct ForegroundAppInfo {
133 current_package: Option<String>,
134 current_activity: Option<String>,
135}
136
137#[derive(Debug, Clone, PartialEq, Eq)]
138struct AndroidUiNode {
139 class_name: Option<String>,
140 resource_id: Option<String>,
141 package_name: Option<String>,
142 text: Option<String>,
143 content_desc: Option<String>,
144 checkable: Option<bool>,
145 checked: Option<bool>,
146 clickable: Option<bool>,
147 long_clickable: Option<bool>,
148 scrollable: Option<bool>,
149 enabled: Option<bool>,
150 focusable: Option<bool>,
151 focused: Option<bool>,
152 selected: Option<bool>,
153 password: Option<bool>,
154 index: Option<usize>,
155 bounds: Option<AndroidBounds>,
156 parent_index: Option<usize>,
157}
158
159#[derive(Debug, Clone, Copy, PartialEq, Eq)]
160struct AndroidBounds {
161 left: i32,
162 top: i32,
163 right: i32,
164 bottom: i32,
165}
166
167impl AndroidBounds {
168 fn center(self) -> (i32, i32) {
169 ((self.left + self.right) / 2, (self.top + self.bottom) / 2)
170 }
171}
172
173#[derive(Debug, Clone, PartialEq, Eq)]
174enum SelectorFlavorInternal {
175 Css,
176 XPath,
177 UiAutomator,
178 Semantic,
179}
180
181#[derive(Debug, Clone, PartialEq, Eq)]
182struct SelectorSegment {
183 flavor: SelectorFlavorInternal,
184 value: String,
185}
186
187#[derive(Debug, Clone, Default, PartialEq, Eq)]
188struct NodeCriteria {
189 class_name: Option<String>,
190 class_name_matches: Option<String>,
191 resource_id_exact: Option<String>,
192 resource_id_suffix: Option<String>,
193 resource_id_matches: Option<String>,
194 package_name: Option<String>,
195 package_name_matches: Option<String>,
196 text: Option<String>,
197 text_contains: Option<String>,
198 text_starts_with: Option<String>,
199 text_matches: Option<String>,
200 content_desc: Option<String>,
201 content_desc_contains: Option<String>,
202 content_desc_starts_with: Option<String>,
203 content_desc_matches: Option<String>,
204 checkable: Option<bool>,
205 checked: Option<bool>,
206 clickable: Option<bool>,
207 long_clickable: Option<bool>,
208 scrollable: Option<bool>,
209 enabled: Option<bool>,
210 focusable: Option<bool>,
211 focused: Option<bool>,
212 selected: Option<bool>,
213 index: Option<usize>,
214 instance: Option<usize>,
215 semantic_role: Option<String>,
216 semantic_name: Option<SemanticTextMatcher>,
217 semantic_text: Option<SemanticTextMatcher>,
218 semantic_label: Option<SemanticTextMatcher>,
219 semantic_test_id: Option<SemanticTextMatcher>,
220 semantic_disabled: Option<bool>,
221}
222
223#[derive(Debug, Clone, PartialEq, Eq)]
224struct SemanticTextMatcher {
225 value: String,
226 regex: bool,
227 flags: String,
228 exact: bool,
229}
230
231impl SurfacePlugin for MobileAndroidPlugin {
232 fn descriptor(&self) -> SurfacePluginDescriptor {
233 descriptor()
234 }
235}
236
237pub fn descriptor() -> SurfacePluginDescriptor {
238 SurfacePluginDescriptor {
239 id: "mobile-android",
240 family: SurfaceFamily::Mobile,
241 version: env!("CARGO_PKG_VERSION"),
242 description: "Android mobile surface plugin for the allwright engine.",
243 }
244}
245
246pub fn profile() -> MobileSurfaceProfile {
247 MobileSurfaceProfile {
248 plugin_id: "mobile-android",
249 display_name: "Android",
250 family: SurfaceFamily::Mobile,
251 backends: ANDROID_BACKENDS,
252 default_backend: MobileAutomationBackend::UiAutomator2,
253 supported_app_kinds: ANDROID_APP_KINDS,
254 capabilities: MobileCapabilitySet {
255 supports_native_views: true,
256 supports_webviews: true,
257 supports_deep_links: true,
258 supports_shell_commands: true,
259 supports_device_logs: true,
260 },
261 bootstrap_hint: "Connect a device or emulator over ADB, then bootstrap a UiAutomator2 session before launching and clicking through the app.",
262 }
263}
264
265pub fn runtime_readiness() -> MobileRuntimeReadiness {
266 MobileRuntimeReadiness {
267 maturity: RuntimeMaturity::Scaffolding,
268 missing_runtime_artifacts: ANDROID_MISSING_RUNTIME_ARTIFACTS,
269 next_milestones: ANDROID_NEXT_MILESTONES,
270 }
271}
272
273pub fn connect_command(options: ConnectOptions) -> MobileCommand {
274 MobileCommand::Connect(options)
275}
276
277pub fn launch_command(
278 browser_session: MobileBrowserSessionHandle,
279 options: LaunchOptions,
280) -> MobileCommand {
281 MobileCommand::LaunchApp {
282 browser_session,
283 options,
284 }
285}
286
287pub fn list_adb_devices() -> Result<Vec<AdbDevice>, String> {
288 let output = run_adb(&["devices", "-l"])?;
289 Ok(parse_adb_devices(&output))
290}
291
292pub fn select_device_for_connect(
293 options: &ConnectOptions,
294 devices: &[AdbDevice],
295) -> Result<AdbDevice, ConnectSelectionError> {
296 let available = devices
297 .iter()
298 .filter(|device| device.state == "device")
299 .cloned()
300 .collect::<Vec<_>>();
301 if available.is_empty() {
302 return Err(ConnectSelectionError::NoDevicesAvailable);
303 }
304
305 if let Some(requested) = options.device.as_deref() {
306 return available
307 .iter()
308 .find(|device| {
309 device.serial == requested
310 || device
311 .name
312 .as_deref()
313 .is_some_and(|name| name.eq_ignore_ascii_case(requested))
314 })
315 .cloned()
316 .ok_or_else(|| ConnectSelectionError::RequestedDeviceNotFound {
317 requested: requested.to_string(),
318 });
319 }
320
321 available
322 .first()
323 .cloned()
324 .ok_or(ConnectSelectionError::NoDevicesAvailable)
325}
326
327pub fn connect(options: &ConnectOptions) -> Result<MobileConnectInfo, String> {
328 if options.platform != MobilePlatform::Android {
329 return Err("mobile-android connect only supports the Android platform".to_string());
330 }
331
332 if let Some(endpoint) = options.adb_endpoint.as_deref() {
333 let trimmed = endpoint.trim();
334 if !trimmed.is_empty() {
335 let _ = run_adb(&["connect", trimmed])?;
336 }
337 }
338
339 let devices = list_adb_devices()?;
340 let selected =
341 select_device_for_connect(options, &devices).map_err(|error| error.to_string())?;
342 let foreground = current_foreground_app(&selected.serial)?;
343
344 Ok(MobileConnectInfo {
345 browser: "android".to_string(),
346 note: format!(
347 "connected Android device `{}` over ADB and attached the native Android runtime",
348 selected.name.as_deref().unwrap_or(selected.serial.as_str())
349 ),
350 browser_session: MobileBrowserSessionHandle {
351 platform: MobilePlatform::Android,
352 automation: MobileAutomationSessionInfo {
353 backend: "android-adb".to_string(),
354 session_id: accessibility::unique_id("android-adb"),
355 note: "session established through the Android surface plugin".to_string(),
356 },
357 device: DeviceTarget {
358 platform: MobilePlatform::Android,
359 device_id: selected.serial.clone(),
360 connection_kind: selected.connection_kind,
361 },
362 },
363 initial_page: MobilePageInfo {
364 note: "attached to the device foreground context".to_string(),
365 page_session: MobilePageSessionHandle {
366 page_id: accessibility::unique_id("android-page"),
367 package_name: foreground.current_package,
368 activity_name: foreground.current_activity,
369 webview_context: None,
370 },
371 },
372 })
373}
374
375pub fn launch_app(
376 browser_session: &MobileBrowserSessionHandle,
377 options: &LaunchOptions,
378) -> Result<MobilePageInfo, String> {
379 let device_id = browser_session.device.device_id.as_str();
380 let mut resolved_apk = None;
381
382 if let Some(apk_path) = options.apk_path.as_deref() {
383 let apk = resolve_apk_source(apk_path)?;
384 let apk_owned = apk.path.to_string_lossy().into_owned();
385 let _ = run_adb_for_device(device_id, &["install", "-r", &apk_owned])?;
386 resolved_apk = Some(apk);
387 }
388
389 let package_name = match options.app_id.clone() {
390 Some(package_name) => package_name,
391 None => {
392 if let Some(apk) = resolved_apk.as_ref() {
393 resolve_package_name_from_apk_path(apk.path.as_path())?
394 } else {
395 return Err(
396 "launch_app requires `app_id`, or an APK path whose package name can be resolved"
397 .to_string(),
398 );
399 }
400 }
401 };
402
403 if options.stop_before_launch {
404 let _ = run_adb_for_device(device_id, &["shell", "am", "force-stop", &package_name])?;
405 }
406
407 if let Some(activity) = options.launch_activity.as_deref() {
408 let component = format!("{package_name}/{activity}");
409 let _ = run_adb_for_device(device_id, &["shell", "am", "start", "-W", "-n", &component])?;
410 } else {
411 let _ = run_adb_for_device(
412 device_id,
413 &[
414 "shell",
415 "monkey",
416 "-p",
417 &package_name,
418 "-c",
419 "android.intent.category.LAUNCHER",
420 "1",
421 ],
422 )?;
423 }
424
425 let current = current_foreground_app(device_id)?;
426 let activity_name = current
427 .current_activity
428 .or_else(|| options.launch_activity.clone());
429
430 Ok(MobilePageInfo {
431 note: format!(
432 "launched Android app `{package_name}` on {}",
433 browser_session.device.device_id
434 ),
435 page_session: MobilePageSessionHandle {
436 page_id: accessibility::unique_id("android-page"),
437 package_name: Some(package_name),
438 activity_name,
439 webview_context: None,
440 },
441 })
442}
443
444pub fn click_element(
445 browser_session: &MobileBrowserSessionHandle,
446 page_session: &MobilePageSessionHandle,
447 selector: &str,
448 timeout_ms: Option<u32>,
449) -> Result<MobileClickInfo, String> {
450 let normalized = normalize_selector_for_transport(selector);
451 accessibility::validate_scope(browser_session, page_session, &normalized)?;
452 if normalized.trim().is_empty() {
453 return Err("click_element requires a non-empty selector".to_string());
454 }
455
456 let clicked = adb_click(&browser_session.device.device_id, &normalized, timeout_ms)?;
457 Ok(MobileClickInfo {
458 selector: clicked.resolved_selector,
459 note: format!("{}; page={}", clicked.note, page_session.page_id),
460 session_id: browser_session.automation.session_id.clone(),
461 })
462}
463
464pub fn fill_element(
465 browser_session: &MobileBrowserSessionHandle,
466 page_session: &MobilePageSessionHandle,
467 selector: &str,
468 value: &str,
469 timeout_ms: Option<u32>,
470) -> Result<MobileFillInfo, String> {
471 let normalized = normalize_selector_for_transport(selector);
472 accessibility::validate_scope(browser_session, page_session, &normalized)?;
473 if normalized.trim().is_empty() {
474 return Err("fill_element requires a non-empty selector".to_string());
475 }
476
477 let filled = adb_fill(
478 &browser_session.device.device_id,
479 &normalized,
480 value,
481 timeout_ms,
482 )?;
483 Ok(MobileFillInfo {
484 selector: filled.resolved_selector,
485 value: filled.value,
486 note: format!("{}; page={}", filled.note, page_session.page_id),
487 })
488}
489
490pub fn count_elements(
491 browser_session: &MobileBrowserSessionHandle,
492 page_session: &MobilePageSessionHandle,
493 selector: &str,
494 timeout_ms: Option<u32>,
495) -> Result<MobileElementCountInfo, String> {
496 let normalized = normalize_selector_for_transport(selector);
497 accessibility::validate_scope(browser_session, page_session, &normalized)?;
498 if normalized.trim().is_empty() {
499 return Err("count_elements requires a non-empty selector".to_string());
500 }
501
502 let count = adb_count_elements(&browser_session.device.device_id, &normalized, timeout_ms)?;
503 Ok(MobileElementCountInfo {
504 selector: normalized,
505 count,
506 note: format!(
507 "counted Android elements via UiAutomator source matching; page={}",
508 page_session.page_id
509 ),
510 })
511}
512
513pub fn focus_element(
514 browser_session: &MobileBrowserSessionHandle,
515 page_session: &MobilePageSessionHandle,
516 selector: &str,
517 timeout_ms: Option<u32>,
518) -> Result<MobileElementInfo, String> {
519 let normalized = normalize_selector_for_transport(selector);
520 accessibility::validate_scope(browser_session, page_session, &normalized)?;
521 if normalized.trim().is_empty() {
522 return Err("focus_element requires a non-empty selector".to_string());
523 }
524
525 let focused = adb_focus(&browser_session.device.device_id, &normalized, timeout_ms)?;
526 Ok(MobileElementInfo {
527 selector: focused.resolved_selector,
528 note: format!("{}; page={}", focused.note, page_session.page_id),
529 })
530}
531
532pub fn press_key(
533 browser_session: &MobileBrowserSessionHandle,
534 page_session: &MobilePageSessionHandle,
535 selector: &str,
536 key: &str,
537 text: Option<&str>,
538 timeout_ms: Option<u32>,
539) -> Result<MobilePressInfo, String> {
540 let normalized = normalize_selector_for_transport(selector);
541 accessibility::validate_scope(browser_session, page_session, &normalized)?;
542 if normalized.trim().is_empty() {
543 return Err("press_key requires a non-empty selector".to_string());
544 }
545
546 let note = adb_press_key(
547 &browser_session.device.device_id,
548 &normalized,
549 key,
550 text,
551 timeout_ms,
552 )?;
553 Ok(MobilePressInfo {
554 selector: normalized,
555 key: key.to_string(),
556 note: format!("{note}; page={}", page_session.page_id),
557 })
558}
559
560pub fn get_text(
561 browser_session: &MobileBrowserSessionHandle,
562 page_session: &MobilePageSessionHandle,
563 selector: &str,
564 timeout_ms: Option<u32>,
565) -> Result<MobileTextInfo, String> {
566 get_text_like(
567 browser_session,
568 page_session,
569 selector,
570 timeout_ms,
571 "text_content",
572 )
573}
574
575pub fn get_inner_text(
576 browser_session: &MobileBrowserSessionHandle,
577 page_session: &MobilePageSessionHandle,
578 selector: &str,
579 timeout_ms: Option<u32>,
580) -> Result<MobileTextInfo, String> {
581 get_text_like(
582 browser_session,
583 page_session,
584 selector,
585 timeout_ms,
586 "inner_text",
587 )
588}
589
590fn get_text_like(
591 browser_session: &MobileBrowserSessionHandle,
592 page_session: &MobilePageSessionHandle,
593 selector: &str,
594 timeout_ms: Option<u32>,
595 mode: &str,
596) -> Result<MobileTextInfo, String> {
597 let normalized = normalize_selector_for_transport(selector);
598 accessibility::validate_scope(browser_session, page_session, &normalized)?;
599 if normalized.trim().is_empty() {
600 return Err(format!("{mode} requires a non-empty selector"));
601 }
602
603 let text = adb_get_text(&browser_session.device.device_id, &normalized, timeout_ms)?;
604 Ok(MobileTextInfo {
605 selector: normalized,
606 text,
607 note: format!(
608 "read Android element text via UiAutomator source; page={}",
609 page_session.page_id
610 ),
611 })
612}
613
614pub fn wait_for_selector(
615 browser_session: &MobileBrowserSessionHandle,
616 page_session: &MobilePageSessionHandle,
617 selector: &str,
618 visible: bool,
619 timeout_ms: Option<u32>,
620) -> Result<MobileWaitForSelectorInfo, String> {
621 let normalized = normalize_selector_for_transport(selector);
622 accessibility::validate_scope(browser_session, page_session, &normalized)?;
623 if normalized.trim().is_empty() {
624 return Err("wait_for_selector requires a non-empty selector".to_string());
625 }
626
627 adb_wait_for_selector(
628 &browser_session.device.device_id,
629 &normalized,
630 visible,
631 timeout_ms,
632 )?;
633 Ok(MobileWaitForSelectorInfo {
634 selector: normalized,
635 visible,
636 note: format!(
637 "resolved Android selector via UiAutomator source wait; page={}",
638 page_session.page_id
639 ),
640 })
641}
642
643pub fn dump_source(
644 browser_session: &MobileBrowserSessionHandle,
645 _page_session: &MobilePageSessionHandle,
646) -> Result<UiAutomator2SourceInfo, String> {
647 let source = adb_dump_source(&browser_session.device.device_id)?;
648 Ok(UiAutomator2SourceInfo {
649 source: source.source,
650 current_package: source.current_package,
651 current_activity: source.current_activity,
652 })
653}
654
655pub fn connect_result_example(options: &ConnectOptions) -> Result<MobileCommandResult, String> {
656 Ok(MobileCommandResult::Connect(connect(options)?))
657}
658
659pub async fn boot() -> String {
660 boot_surface("android", 10).await
661}
662
663#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
664struct MobilePluginEnvelope {
665 ok: bool,
666 result: Option<MobileCommandResult>,
667 error: Option<String>,
668}
669
670fn plugin_response(result: Result<MobileCommandResult, String>) -> *mut c_char {
671 let payload = match result {
672 Ok(result) => MobilePluginEnvelope {
673 ok: true,
674 result: Some(result),
675 error: None,
676 },
677 Err(error) => MobilePluginEnvelope {
678 ok: false,
679 result: None,
680 error: Some(error),
681 },
682 };
683
684 match serde_json::to_string(&payload) {
685 Ok(json) => CString::new(json)
686 .map(CString::into_raw)
687 .unwrap_or_else(|_| CString::new("{\"ok\":false,\"result\":null,\"error\":\"failed to encode plugin response\"}").expect("static JSON").into_raw()),
688 Err(error) => CString::new(format!(
689 "{{\"ok\":false,\"result\":null,\"error\":\"failed to serialize plugin response: {error}\"}}"
690 ))
691 .map(CString::into_raw)
692 .unwrap_or_else(|_| CString::new("{\"ok\":false,\"result\":null,\"error\":\"failed to serialize plugin response\"}").expect("static JSON").into_raw()),
693 }
694}
695
696fn handle_plugin_command(command: MobileCommand) -> Result<MobileCommandResult, String> {
697 match command {
698 MobileCommand::RegisterHook {
699 browser_session,
700 page_session,
701 hook_type,
702 } => register_android_hook(&browser_session, &page_session, hook_type)
703 .map(MobileCommandResult::RegisterHook),
704 MobileCommand::PollHook {
705 browser_session,
706 registration,
707 } => poll_android_hook(&browser_session, ®istration).map(MobileCommandResult::PollHook),
708 MobileCommand::SetFileChooserFiles {
709 browser_session,
710 page_session,
711 file_chooser_id,
712 files,
713 } => set_android_file_chooser_files(
714 &browser_session,
715 &page_session,
716 &file_chooser_id,
717 &files,
718 )
719 .map(MobileCommandResult::SetFileChooserFiles),
720 MobileCommand::SaveDownload {
721 browser_session,
722 page_session,
723 download_id,
724 path,
725 } => save_android_download(&browser_session, &page_session, &download_id, &path)
726 .map(MobileCommandResult::SaveDownload),
727 MobileCommand::Connect(options) => connect(&options).map(MobileCommandResult::Connect),
728 MobileCommand::LaunchApp {
729 browser_session,
730 options,
731 } => launch_app(&browser_session, &options).map(MobileCommandResult::LaunchApp),
732 MobileCommand::OpenPage { .. } => {
733 let info = MobilePageInfo {
734 note: "attached to the Android foreground app context".to_string(),
735 page_session: MobilePageSessionHandle {
736 page_id: accessibility::unique_id("android-page"),
737 package_name: None,
738 activity_name: None,
739 webview_context: None,
740 },
741 };
742 Ok(MobileCommandResult::OpenPage(info))
743 }
744 MobileCommand::ClosePage {
745 browser_session,
746 page_session,
747 } => {
748 accessibility::clear(&browser_session, &page_session)?;
749 Ok(MobileCommandResult::ClosePage)
750 }
751 MobileCommand::NavigateApp {
752 browser_session,
753 page_session,
754 url,
755 ..
756 } => {
757 accessibility::validate_scope(&browser_session, &page_session, "deep link")?;
758 run_adb_for_device(
759 &browser_session.device.device_id,
760 &[
761 "shell",
762 "am",
763 "start",
764 "-a",
765 "android.intent.action.VIEW",
766 "-d",
767 &url,
768 ],
769 )?;
770 Ok(MobileCommandResult::NavigateApp(MobileNavigationInfo {
771 url,
772 note: "opened Android deep link through the platform intent router".to_string(),
773 }))
774 }
775 MobileCommand::AccessibilitySnapshot {
776 browser_session,
777 page_session,
778 format,
779 mode,
780 } => accessibility::snapshot(&browser_session, &page_session, &format, &mode)
781 .map(MobileCommandResult::AccessibilitySnapshot),
782 MobileCommand::ClickElement {
783 browser_session,
784 page_session,
785 selector,
786 timeout_ms,
787 } => click_element(&browser_session, &page_session, &selector, timeout_ms)
788 .map(MobileCommandResult::ClickElement),
789 MobileCommand::CountElements {
790 browser_session,
791 page_session,
792 selector,
793 timeout_ms,
794 } => count_elements(&browser_session, &page_session, &selector, timeout_ms)
795 .map(MobileCommandResult::CountElements),
796 MobileCommand::FocusElement {
797 browser_session,
798 page_session,
799 selector,
800 timeout_ms,
801 } => focus_element(&browser_session, &page_session, &selector, timeout_ms)
802 .map(MobileCommandResult::FocusElement),
803 MobileCommand::FillElement {
804 browser_session,
805 page_session,
806 selector,
807 value,
808 timeout_ms,
809 } => fill_element(
810 &browser_session,
811 &page_session,
812 &selector,
813 &value,
814 timeout_ms,
815 )
816 .map(MobileCommandResult::FillElement),
817 MobileCommand::PressKey {
818 browser_session,
819 page_session,
820 selector,
821 key,
822 text,
823 timeout_ms,
824 } => press_key(
825 &browser_session,
826 &page_session,
827 &selector,
828 &key,
829 text.as_deref(),
830 timeout_ms,
831 )
832 .map(MobileCommandResult::PressKey),
833 MobileCommand::GetText {
834 browser_session,
835 page_session,
836 selector,
837 timeout_ms,
838 } => get_text(&browser_session, &page_session, &selector, timeout_ms)
839 .map(MobileCommandResult::GetText),
840 MobileCommand::GetInnerText {
841 browser_session,
842 page_session,
843 selector,
844 timeout_ms,
845 } => get_inner_text(&browser_session, &page_session, &selector, timeout_ms)
846 .map(MobileCommandResult::GetInnerText),
847 MobileCommand::WaitForSelector {
848 browser_session,
849 page_session,
850 selector,
851 visible,
852 timeout_ms,
853 } => wait_for_selector(
854 &browser_session,
855 &page_session,
856 &selector,
857 visible,
858 timeout_ms,
859 )
860 .map(MobileCommandResult::WaitForSelector),
861 MobileCommand::Screenshot {
862 browser_session,
863 page_session,
864 timeout_ms,
865 full_page,
866 } => screenshot(&browser_session, &page_session, timeout_ms, full_page)
867 .map(MobileCommandResult::Screenshot),
868 }
869}
870
871#[unsafe(no_mangle)]
872pub extern "C" fn allwright_plugin_api_version() -> u32 {
873 ALLWRIGHT_PLUGIN_API_VERSION
874}
875
876#[unsafe(no_mangle)]
877pub extern "C" fn allwright_plugin_id() -> *const c_char {
878 c"mobile-android".as_ptr()
879}
880
881#[unsafe(no_mangle)]
882pub unsafe extern "C" fn allwright_plugin_invoke(request_json: *const c_char) -> *mut c_char {
883 if request_json.is_null() {
884 return plugin_response(Err("plugin request pointer is null".to_string()));
885 }
886
887 let request = match unsafe { CStr::from_ptr(request_json) }.to_str() {
888 Ok(request) => request,
889 Err(error) => {
890 return plugin_response(Err(format!("plugin request is not valid UTF-8: {error}")));
891 }
892 };
893
894 let command: MobileCommand = match serde_json::from_str(request) {
895 Ok(command) => command,
896 Err(error) => {
897 return plugin_response(Err(format!(
898 "failed to parse mobile plugin request JSON: {error}"
899 )));
900 }
901 };
902
903 plugin_response(handle_plugin_command(command))
904}
905
906#[unsafe(no_mangle)]
907pub unsafe extern "C" fn allwright_plugin_free_string(value: *mut c_char) {
908 if !value.is_null() {
909 unsafe {
910 let _ = CString::from_raw(value);
911 }
912 }
913}
914
915fn parse_adb_devices(output: &str) -> Vec<AdbDevice> {
916 output
917 .lines()
918 .map(str::trim)
919 .filter(|line| !line.is_empty() && !line.starts_with("List of devices attached"))
920 .filter_map(|line| {
921 let mut parts = line.split_whitespace();
922 let serial = parts.next()?.to_string();
923 let state = parts.next()?.to_string();
924 let metadata = parts.collect::<Vec<_>>();
925 let mut name = None;
926 for item in metadata {
927 if let Some(model) = item.strip_prefix("model:") {
928 name = Some(model.replace('_', " "));
929 break;
930 }
931 if let Some(device_name) = item.strip_prefix("device:") {
932 name = Some(device_name.replace('_', " "));
933 }
934 }
935
936 Some(AdbDevice {
937 connection_kind: classify_device_connection_kind(&serial),
938 serial,
939 name,
940 state,
941 })
942 })
943 .collect()
944}
945
946fn classify_device_connection_kind(serial: &str) -> DeviceConnectionKind {
947 if serial.starts_with("emulator-") {
948 return DeviceConnectionKind::Emulator;
949 }
950 if serial.contains(':') {
951 return DeviceConnectionKind::RemoteAdb;
952 }
953 DeviceConnectionKind::Usb
954}
955
956fn run_adb(args: &[&str]) -> Result<String, String> {
957 run_command(&adb_command_path(), args)
958}
959
960fn run_adb_for_device(device_id: &str, args: &[&str]) -> Result<String, String> {
961 let mut all_args = vec!["-s", device_id];
962 all_args.extend_from_slice(args);
963 run_command(&adb_command_path(), &all_args)
964}
965
966fn run_adb_bytes_for_device(device_id: &str, args: &[&str]) -> Result<Vec<u8>, String> {
967 let mut all_args = vec!["-s", device_id];
968 all_args.extend_from_slice(args);
969 run_command_bytes(&adb_command_path(), &all_args)
970}
971
972fn adb_command_path() -> String {
973 env::var("ALLWRIGHT_ANDROID_ADB").unwrap_or_else(|_| "adb".to_string())
974}
975
976fn run_command(command: &str, args: &[&str]) -> Result<String, String> {
977 let output = run_command_output(command, args)?;
978 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
979}
980
981fn run_command_bytes(command: &str, args: &[&str]) -> Result<Vec<u8>, String> {
982 Ok(run_command_output(command, args)?.stdout)
983}
984
985fn run_command_output(command: &str, args: &[&str]) -> Result<std::process::Output, String> {
986 let output = Command::new(command)
987 .args(args)
988 .output()
989 .map_err(|error| format!("failed to run `{command}`: {error}"))?;
990 if output.status.success() {
991 return Ok(output);
992 }
993
994 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
995 let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
996 let detail = if !stderr.is_empty() { stderr } else { stdout };
997 Err(format!(
998 "`{command} {}` failed with status {}: {}",
999 args.join(" "),
1000 output
1001 .status
1002 .code()
1003 .map_or_else(|| "signal".to_string(), |code| code.to_string()),
1004 detail
1005 ))
1006}
1007
1008#[derive(Debug)]
1009struct ResolvedApkSource {
1010 path: PathBuf,
1011 _downloaded_file: Option<PathBuf>,
1012}
1013
1014fn resolve_apk_source(apk_path_or_url: &str) -> Result<ResolvedApkSource, String> {
1015 if is_remote_apk_source(apk_path_or_url) {
1016 let downloaded_path = download_apk_to_temp(apk_path_or_url)?;
1017 return Ok(ResolvedApkSource {
1018 path: downloaded_path.clone(),
1019 _downloaded_file: Some(downloaded_path),
1020 });
1021 }
1022
1023 let apk = Path::new(apk_path_or_url);
1024 if !apk.is_file() {
1025 return Err(format!("APK path does not exist: {}", apk.display()));
1026 }
1027 Ok(ResolvedApkSource {
1028 path: apk.to_path_buf(),
1029 _downloaded_file: None,
1030 })
1031}
1032
1033fn is_remote_apk_source(apk_path_or_url: &str) -> bool {
1034 let lowered = apk_path_or_url.trim().to_ascii_lowercase();
1035 lowered.starts_with("http://") || lowered.starts_with("https://")
1036}
1037
1038fn download_apk_to_temp(url: &str) -> Result<PathBuf, String> {
1039 let response = reqwest::blocking::get(url)
1040 .map_err(|error| format!("failed to download APK from `{url}`: {error}"))?;
1041 if !response.status().is_success() {
1042 return Err(format!(
1043 "failed to download APK from `{url}`: HTTP {}",
1044 response.status()
1045 ));
1046 }
1047
1048 let bytes = response
1049 .bytes()
1050 .map_err(|error| format!("failed to read APK download from `{url}`: {error}"))?;
1051 let timestamp = SystemTime::now()
1052 .duration_since(UNIX_EPOCH)
1053 .map_err(|error| format!("failed to resolve system time for APK download: {error}"))?
1054 .as_millis();
1055 let path = env::temp_dir().join(format!("allwright-mobile-android-{timestamp}.apk"));
1056 fs::write(&path, bytes.as_ref()).map_err(|error| {
1057 format!(
1058 "failed to write downloaded APK to {}: {error}",
1059 path.display()
1060 )
1061 })?;
1062 Ok(path)
1063}
1064
1065fn resolve_package_name_from_apk_path(apk_path: &Path) -> Result<String, String> {
1066 let apk_path = apk_path.to_string_lossy().into_owned();
1067 if let Ok(output) = run_command("aapt", &["dump", "badging", &apk_path]) {
1068 if let Some(package_name) = parse_package_name_from_aapt_badging(&output) {
1069 return Ok(package_name);
1070 }
1071 }
1072
1073 if let Ok(output) = run_command("apkanalyzer", &["manifest", "application-id", &apk_path]) {
1074 let package_name = output.trim();
1075 if !package_name.is_empty() {
1076 return Ok(package_name.to_string());
1077 }
1078 }
1079
1080 Err(
1081 "could not resolve the app package name from the APK. Provide `--app-id`, or install `aapt`/`apkanalyzer`."
1082 .to_string(),
1083 )
1084}
1085
1086fn parse_package_name_from_aapt_badging(output: &str) -> Option<String> {
1087 output.lines().find_map(|line| {
1088 let package_line = line.trim();
1089 if !package_line.starts_with("package: ") {
1090 return None;
1091 }
1092 let needle = "name='";
1093 let start = package_line.find(needle)? + needle.len();
1094 let remainder = &package_line[start..];
1095 let end = remainder.find('\'')?;
1096 Some(remainder[..end].to_string())
1097 })
1098}
1099
1100fn current_foreground_app(device_id: &str) -> Result<ForegroundAppInfo, String> {
1101 let window_dump = run_adb_for_device(device_id, &["shell", "dumpsys", "window", "windows"])
1102 .or_else(|_| run_adb_for_device(device_id, &["shell", "dumpsys", "window"]))?;
1103 Ok(parse_foreground_app_from_dumpsys(&window_dump))
1104}
1105
1106fn android_hooks() -> &'static Mutex<HashMap<String, AndroidHookState>> {
1107 ANDROID_HOOKS.get_or_init(|| Mutex::new(HashMap::new()))
1108}
1109
1110fn android_choosers() -> &'static Mutex<HashMap<String, AndroidChooserState>> {
1111 ANDROID_CHOOSERS.get_or_init(|| Mutex::new(HashMap::new()))
1112}
1113
1114fn android_downloads() -> &'static Mutex<HashMap<String, AndroidDownloadState>> {
1115 ANDROID_DOWNLOADS.get_or_init(|| Mutex::new(HashMap::new()))
1116}
1117
1118fn list_public_downloads(device_id: &str) -> Result<HashMap<String, String>, String> {
1119 let output = run_adb_for_device(
1120 device_id,
1121 &["shell", "find", "/sdcard/Download", "-type", "f"],
1122 )?;
1123 output
1124 .lines()
1125 .map(str::trim)
1126 .filter(|path| !path.is_empty())
1127 .map(|path| {
1128 let signature = run_adb_for_device(device_id, &["shell", "stat", "-c", "%s:%Y", path])?;
1129 Ok((path.to_string(), signature))
1130 })
1131 .collect()
1132}
1133
1134fn changed_public_download(
1135 before: &HashMap<String, String>,
1136 after: &HashMap<String, String>,
1137) -> Option<String> {
1138 after
1139 .iter()
1140 .find(|(path, signature)| before.get(*path) != Some(*signature))
1141 .map(|(path, _)| path.clone())
1142}
1143
1144fn android_file_size(device_id: &str, path: &str) -> Result<u64, String> {
1145 run_adb_for_device(device_id, &["shell", "stat", "-c", "%s", path])?
1146 .trim()
1147 .parse::<u64>()
1148 .map_err(|error| format!("Android file size is invalid for {path}: {error}"))
1149}
1150
1151fn is_android_picker(package: Option<&str>) -> bool {
1152 package.is_some_and(|package| {
1153 let package = package.to_ascii_lowercase();
1154 package.contains("documentsui")
1155 || package.contains("filepicker")
1156 || package.ends_with(".files")
1157 })
1158}
1159
1160fn register_android_hook(
1161 browser_session: &MobileBrowserSessionHandle,
1162 page_session: &MobilePageSessionHandle,
1163 hook_type: MobileHookType,
1164) -> Result<MobileHookRegistration, String> {
1165 accessibility::validate_scope(browser_session, page_session, "hook")?;
1166 let registration_id = accessibility::unique_id("android-hook");
1167 let device_id = browser_session.device.device_id.clone();
1168 let state = match hook_type {
1169 MobileHookType::FileChooser => AndroidHookState::FileChooser {
1170 device_id: device_id.clone(),
1171 source_package: current_foreground_app(&device_id)?.current_package,
1172 },
1173 MobileHookType::Download => AndroidHookState::Download {
1174 device_id: device_id.clone(),
1175 existing_files: list_public_downloads(&device_id)?,
1176 },
1177 };
1178 android_hooks()
1179 .lock()
1180 .map_err(|_| "Android hook registry is unavailable".to_string())?
1181 .insert(registration_id.clone(), state);
1182 Ok(MobileHookRegistration {
1183 opaque_state: registration_id,
1184 })
1185}
1186
1187fn poll_android_hook(
1188 browser_session: &MobileBrowserSessionHandle,
1189 registration: &MobileHookRegistration,
1190) -> Result<MobileHookResult, String> {
1191 let registration_id = ®istration.opaque_state;
1192 let state = android_hooks()
1193 .lock()
1194 .map_err(|_| "Android hook registry is unavailable".to_string())?
1195 .get(registration_id)
1196 .cloned()
1197 .ok_or_else(|| "Android hook registration is no longer available".to_string())?;
1198 match state {
1199 AndroidHookState::FileChooser {
1200 device_id,
1201 source_package,
1202 } => {
1203 if device_id != browser_session.device.device_id {
1204 return Err("Android hook belongs to a different device".to_string());
1205 }
1206 let foreground = current_foreground_app(&device_id)?;
1207 if foreground.current_package == source_package
1208 || !is_android_picker(foreground.current_package.as_deref())
1209 {
1210 return Err("Android file chooser hook is still waiting for a picker".to_string());
1211 }
1212 let chooser_id = accessibility::unique_id("android-chooser");
1213 android_choosers()
1214 .lock()
1215 .map_err(|_| "Android chooser registry is unavailable".to_string())?
1216 .insert(chooser_id.clone(), AndroidChooserState { device_id });
1217 android_hooks()
1218 .lock()
1219 .map_err(|_| "Android hook registry is unavailable".to_string())?
1220 .remove(registration_id);
1221 Ok(MobileHookResult::FileChooser(MobileFileChooserInfo {
1222 file_chooser_id: chooser_id,
1223 is_multiple: false,
1224 note: "Android system document picker opened".to_string(),
1225 }))
1226 }
1227 AndroidHookState::Download {
1228 device_id,
1229 existing_files,
1230 } => {
1231 if device_id != browser_session.device.device_id {
1232 return Err("Android hook belongs to a different device".to_string());
1233 }
1234 let current = list_public_downloads(&device_id)?;
1235 let Some(device_path) = changed_public_download(&existing_files, ¤t) else {
1236 return Err(
1237 "Android download hook is still waiting for a public download".to_string(),
1238 );
1239 };
1240 let suggested_filename = Path::new(&device_path)
1241 .file_name()
1242 .and_then(|name| name.to_str())
1243 .unwrap_or("download")
1244 .to_string();
1245 let download_id = accessibility::unique_id("android-download");
1246 android_downloads()
1247 .lock()
1248 .map_err(|_| "Android download registry is unavailable".to_string())?
1249 .insert(
1250 download_id.clone(),
1251 AndroidDownloadState {
1252 device_id,
1253 device_path,
1254 suggested_filename: suggested_filename.clone(),
1255 observed_size: None,
1256 },
1257 );
1258 android_hooks()
1259 .lock()
1260 .map_err(|_| "Android hook registry is unavailable".to_string())?
1261 .remove(registration_id);
1262 Ok(MobileHookResult::Download(MobileDownloadInfo {
1263 download_id,
1264 suggested_filename,
1265 note: "Android public download started".to_string(),
1266 }))
1267 }
1268 }
1269}
1270
1271fn set_android_file_chooser_files(
1272 browser_session: &MobileBrowserSessionHandle,
1273 page_session: &MobilePageSessionHandle,
1274 file_chooser_id: &str,
1275 files: &[String],
1276) -> Result<MobileFileChooserFilesSetInfo, String> {
1277 accessibility::validate_scope(browser_session, page_session, "file chooser")?;
1278 if files.len() != 1 {
1279 return Err("Android file chooser currently supports exactly one file".to_string());
1280 }
1281 let chooser = android_choosers()
1282 .lock()
1283 .map_err(|_| "Android chooser registry is unavailable".to_string())?
1284 .get(file_chooser_id)
1285 .cloned()
1286 .ok_or_else(|| format!("unknown Android file chooser {file_chooser_id}"))?;
1287 if chooser.device_id != browser_session.device.device_id {
1288 return Err("Android file chooser belongs to a different device".to_string());
1289 }
1290 let source = fs::canonicalize(&files[0])
1291 .map_err(|error| format!("resolve staged upload {}: {error}", files[0]))?;
1292 let filename = source
1293 .file_name()
1294 .and_then(|name| name.to_str())
1295 .ok_or_else(|| "staged upload requires a valid file name".to_string())?;
1296 let remote_path = format!("/sdcard/Download/{filename}");
1297 run_adb_for_device(
1298 &chooser.device_id,
1299 &["push", &source.to_string_lossy(), &remote_path],
1300 )?;
1301 let _ = run_adb_for_device(
1302 &chooser.device_id,
1303 &[
1304 "shell",
1305 "am",
1306 "broadcast",
1307 "-a",
1308 "android.intent.action.MEDIA_SCANNER_SCAN_FILE",
1309 "-d",
1310 &format!("file://{remote_path}"),
1311 ],
1312 );
1313 let source_snapshot = adb_dump_source(&chooser.device_id)?;
1314 let nodes = parse_android_ui_nodes(&source_snapshot.source)?;
1315 let node = nodes
1316 .iter()
1317 .find(|node| {
1318 node.text.as_deref() == Some(filename) || node.content_desc.as_deref() == Some(filename)
1319 })
1320 .ok_or_else(|| format!("Android picker does not show staged file {filename}"))?;
1321 let (x, y) = node
1322 .bounds
1323 .ok_or_else(|| format!("Android picker file {filename} has no selectable bounds"))?
1324 .center();
1325 run_adb_for_device(
1326 &chooser.device_id,
1327 &["shell", "input", "tap", &x.to_string(), &y.to_string()],
1328 )?;
1329 android_choosers()
1330 .lock()
1331 .map_err(|_| "Android chooser registry is unavailable".to_string())?
1332 .remove(file_chooser_id);
1333 Ok(MobileFileChooserFilesSetInfo {
1334 file_chooser_id: file_chooser_id.to_string(),
1335 files: files.to_vec(),
1336 note: format!("staged and selected {filename} in the Android document picker"),
1337 })
1338}
1339
1340fn save_android_download(
1341 browser_session: &MobileBrowserSessionHandle,
1342 page_session: &MobilePageSessionHandle,
1343 download_id: &str,
1344 path: &str,
1345) -> Result<MobileDownloadSavedInfo, String> {
1346 accessibility::validate_scope(browser_session, page_session, "download")?;
1347 let mut downloads = android_downloads()
1348 .lock()
1349 .map_err(|_| "Android download registry is unavailable".to_string())?;
1350 let download = downloads
1351 .get_mut(download_id)
1352 .ok_or_else(|| format!("unknown Android download {download_id}"))?;
1353 if download.device_id != browser_session.device.device_id {
1354 return Err("Android download belongs to a different device".to_string());
1355 }
1356 let size = android_file_size(&download.device_id, &download.device_path)?;
1357 if download.observed_size != Some(size) {
1358 download.observed_size = Some(size);
1359 return Err("Android download is still in progress".to_string());
1360 }
1361 if let Some(parent) = Path::new(path).parent() {
1362 fs::create_dir_all(parent)
1363 .map_err(|error| format!("create Android download staging directory: {error}"))?;
1364 }
1365 run_adb_for_device(&download.device_id, &["pull", &download.device_path, path])?;
1366 let saved = fs::canonicalize(path)
1367 .map_err(|error| format!("resolve pulled Android download {path}: {error}"))?;
1368 let result = MobileDownloadSavedInfo {
1369 download_id: download_id.to_string(),
1370 path: saved.to_string_lossy().to_string(),
1371 suggested_filename: download.suggested_filename.clone(),
1372 size,
1373 note: "pulled completed Android download to server staging".to_string(),
1374 };
1375 downloads.remove(download_id);
1376 Ok(result)
1377}
1378
1379fn adb_click(
1380 device_id: &str,
1381 selector: &str,
1382 timeout_ms: Option<u32>,
1383) -> Result<UiAutomator2ClickInfo, String> {
1384 let snapshot = resolve_selector_snapshot(device_id, selector, timeout_ms)?;
1385 let bounds = snapshot
1386 .node
1387 .bounds
1388 .ok_or_else(|| format!("selector resolved without bounds: {}", snapshot.selector))?;
1389 let (x, y) = bounds.center();
1390 let _ = run_adb_for_device(
1391 device_id,
1392 &["shell", "input", "tap", &x.to_string(), &y.to_string()],
1393 )?;
1394 Ok(UiAutomator2ClickInfo {
1395 resolved_selector: snapshot.selector,
1396 note: format!(
1397 "clicked Android element via native ADB input on {}/{}",
1398 snapshot
1399 .foreground
1400 .current_package
1401 .as_deref()
1402 .unwrap_or("<unknown>"),
1403 snapshot
1404 .foreground
1405 .current_activity
1406 .as_deref()
1407 .unwrap_or("<unknown>")
1408 ),
1409 })
1410}
1411
1412fn adb_fill(
1413 device_id: &str,
1414 selector: &str,
1415 fill_value: &str,
1416 timeout_ms: Option<u32>,
1417) -> Result<UiAutomator2FillInfo, String> {
1418 let snapshot = resolve_selector_snapshot(device_id, selector, timeout_ms)?;
1419 let bounds = snapshot
1420 .node
1421 .bounds
1422 .ok_or_else(|| format!("selector resolved without bounds: {}", snapshot.selector))?;
1423 let (x, y) = bounds.center();
1424 let _ = run_adb_for_device(
1425 device_id,
1426 &["shell", "input", "tap", &x.to_string(), &y.to_string()],
1427 )?;
1428 let _ = run_adb_for_device(
1429 device_id,
1430 &["shell", "input", "keyevent", "KEYCODE_MOVE_END"],
1431 )?;
1432 let existing_text = snapshot.node.text.as_deref().unwrap_or("");
1433 for _ in existing_text.chars() {
1434 let _ = run_adb_for_device(device_id, &["shell", "input", "keyevent", "KEYCODE_DEL"])?;
1435 }
1436 let encoded = encode_adb_text(fill_value);
1437 if !encoded.is_empty() {
1438 let _ = run_adb_for_device(device_id, &["shell", "input", "text", &encoded])?;
1439 }
1440 Ok(UiAutomator2FillInfo {
1441 resolved_selector: snapshot.selector,
1442 value: fill_value.to_string(),
1443 note: format!(
1444 "filled Android element via native ADB input on {}/{}",
1445 snapshot
1446 .foreground
1447 .current_package
1448 .as_deref()
1449 .unwrap_or("<unknown>"),
1450 snapshot
1451 .foreground
1452 .current_activity
1453 .as_deref()
1454 .unwrap_or("<unknown>")
1455 ),
1456 })
1457}
1458
1459fn adb_count_elements(
1460 device_id: &str,
1461 selector: &str,
1462 _timeout_ms: Option<u32>,
1463) -> Result<u32, String> {
1464 let source = adb_dump_source(device_id)?;
1465 let nodes = parse_android_ui_nodes(&source.source)?;
1466 let count =
1467 accessibility::matching_source_nodes(device_id, &source, &nodes, selector)?.len() as u32;
1468 Ok(count)
1469}
1470
1471fn adb_focus(
1472 device_id: &str,
1473 selector: &str,
1474 timeout_ms: Option<u32>,
1475) -> Result<UiAutomator2ClickInfo, String> {
1476 let snapshot = resolve_selector_snapshot(device_id, selector, timeout_ms)?;
1477 let bounds = snapshot
1478 .node
1479 .bounds
1480 .ok_or_else(|| format!("selector resolved without bounds: {}", snapshot.selector))?;
1481 let (x, y) = bounds.center();
1482 let _ = run_adb_for_device(
1483 device_id,
1484 &["shell", "input", "tap", &x.to_string(), &y.to_string()],
1485 )?;
1486 Ok(UiAutomator2ClickInfo {
1487 resolved_selector: snapshot.selector,
1488 note: format!(
1489 "focused Android element via native ADB tap on {}/{}",
1490 snapshot
1491 .foreground
1492 .current_package
1493 .as_deref()
1494 .unwrap_or("<unknown>"),
1495 snapshot
1496 .foreground
1497 .current_activity
1498 .as_deref()
1499 .unwrap_or("<unknown>")
1500 ),
1501 })
1502}
1503
1504fn adb_press_key(
1505 device_id: &str,
1506 selector: &str,
1507 key: &str,
1508 text: Option<&str>,
1509 timeout_ms: Option<u32>,
1510) -> Result<String, String> {
1511 let focused = adb_focus(device_id, selector, timeout_ms)?;
1512 if let Some(text) = text.filter(|value| !value.is_empty()) {
1513 let encoded = encode_adb_text(text);
1514 if !encoded.is_empty() {
1515 let _ = run_adb_for_device(device_id, &["shell", "input", "text", &encoded])?;
1516 }
1517 return Ok(format!("{} and inserted Android text input", focused.note));
1518 }
1519
1520 if let Some(keyevent) = android_keyevent_for(key) {
1521 let _ = run_adb_for_device(device_id, &["shell", "input", "keyevent", keyevent])?;
1522 return Ok(format!("{} and pressed Android key `{key}`", focused.note));
1523 }
1524
1525 if key.chars().count() == 1 {
1526 let encoded = encode_adb_text(key);
1527 if !encoded.is_empty() {
1528 let _ = run_adb_for_device(device_id, &["shell", "input", "text", &encoded])?;
1529 }
1530 return Ok(format!(
1531 "{} and inserted Android character input `{key}`",
1532 focused.note
1533 ));
1534 }
1535
1536 Err(format!(
1537 "unsupported Android key `{key}`; provide a single character, `text`, or a supported special key"
1538 ))
1539}
1540
1541fn adb_get_text(
1542 device_id: &str,
1543 selector: &str,
1544 timeout_ms: Option<u32>,
1545) -> Result<String, String> {
1546 let snapshot = resolve_selector_snapshot(device_id, selector, timeout_ms)?;
1547 Ok(snapshot
1548 .node
1549 .text
1550 .clone()
1551 .or_else(|| snapshot.node.content_desc.clone())
1552 .unwrap_or_default())
1553}
1554
1555fn adb_wait_for_selector(
1556 device_id: &str,
1557 selector: &str,
1558 visible: bool,
1559 _timeout_ms: Option<u32>,
1560) -> Result<(), String> {
1561 let snapshot = resolve_selector_snapshot(device_id, selector, None)?;
1562 if visible && !node_is_visible(&snapshot.node) {
1563 return Err(format!("selector found but not visible: {selector}"));
1564 }
1565 Ok(())
1566}
1567
1568fn adb_screenshot(
1569 device_id: &str,
1570 _timeout_ms: Option<u32>,
1571) -> Result<MobileScreenshotInfo, String> {
1572 let png_data = run_adb_bytes_for_device(device_id, &["exec-out", "screencap", "-p"])?;
1573 if png_data.is_empty() {
1574 return Err("adb returned an empty screenshot payload".to_string());
1575 }
1576 let foreground = current_foreground_app(device_id)?;
1577 Ok(MobileScreenshotInfo {
1578 png_data,
1579 note: format!(
1580 "captured Android screenshot via adb exec-out screencap on {}/{}",
1581 foreground.current_package.as_deref().unwrap_or("<unknown>"),
1582 foreground
1583 .current_activity
1584 .as_deref()
1585 .unwrap_or("<unknown>")
1586 ),
1587 })
1588}
1589
1590fn adb_dump_source(device_id: &str) -> Result<UiAutomator2SourceInfo, String> {
1591 let remote_path = "/data/local/tmp/allwright-window.xml";
1592 let _ = run_adb_for_device(device_id, &["shell", "uiautomator", "dump", remote_path])?;
1593 let source = run_adb_for_device(device_id, &["shell", "cat", remote_path])?;
1594 let foreground = current_foreground_app(device_id)?;
1595 Ok(UiAutomator2SourceInfo {
1596 source,
1597 current_package: foreground.current_package,
1598 current_activity: foreground.current_activity,
1599 })
1600}
1601
1602fn screenshot(
1603 browser_session: &MobileBrowserSessionHandle,
1604 _page_session: &MobilePageSessionHandle,
1605 timeout_ms: Option<u32>,
1606 full_page: bool,
1607) -> Result<MobileScreenshotInfo, String> {
1608 if full_page {
1609 adb_full_page_screenshot(&browser_session.device.device_id, timeout_ms)
1610 } else {
1611 adb_screenshot(&browser_session.device.device_id, timeout_ms)
1612 }
1613}
1614
1615const MAX_FULL_PAGE_SCREENSHOTS: usize = 20;
1616
1617fn adb_full_page_screenshot(
1618 device_id: &str,
1619 timeout_ms: Option<u32>,
1620) -> Result<MobileScreenshotInfo, String> {
1621 let first = decode_android_screenshot(adb_screenshot(device_id, timeout_ms)?.png_data)?;
1622 let width = first.width();
1623 let height = first.height();
1624 if width == 0 || height < 4 {
1625 return Err("Android returned an invalid screenshot size".to_string());
1626 }
1627
1628 let mut previous = first.clone();
1629 let mut stitched = first;
1630 let swipe_start = (height * 3 / 4) as i32;
1631 let swipe_end = (height / 4) as i32;
1632 for _ in 1..MAX_FULL_PAGE_SCREENSHOTS {
1633 run_adb_for_device(
1634 device_id,
1635 &[
1636 "shell",
1637 "input",
1638 "swipe",
1639 &(width as i32 / 2).to_string(),
1640 &swipe_start.to_string(),
1641 &(width as i32 / 2).to_string(),
1642 &swipe_end.to_string(),
1643 "250",
1644 ],
1645 )?;
1646 std::thread::sleep(Duration::from_millis(300));
1647
1648 let next = decode_android_screenshot(adb_screenshot(device_id, timeout_ms)?.png_data)?;
1649 if next.dimensions() != (width, height) {
1650 return Err(
1651 "Android screenshot dimensions changed while capturing a full page".to_string(),
1652 );
1653 }
1654 let overlap = find_screenshot_overlap(&previous, &next)?;
1655 if overlap == height {
1656 break;
1657 }
1658 append_screenshot(&mut stitched, &next, overlap)?;
1659 previous = next;
1660 }
1661
1662 let mut png_data = Vec::new();
1663 DynamicImage::ImageRgba8(stitched)
1664 .write_to(&mut std::io::Cursor::new(&mut png_data), ImageFormat::Png)
1665 .map_err(|error| format!("encode Android full-page screenshot: {error}"))?;
1666 let foreground = current_foreground_app(device_id)?;
1667 Ok(MobileScreenshotInfo {
1668 png_data,
1669 note: format!(
1670 "captured full-page Android screenshot via ADB scrolling on {}/{}",
1671 foreground.current_package.as_deref().unwrap_or("<unknown>"),
1672 foreground
1673 .current_activity
1674 .as_deref()
1675 .unwrap_or("<unknown>")
1676 ),
1677 })
1678}
1679
1680fn decode_android_screenshot(png_data: Vec<u8>) -> Result<RgbaImage, String> {
1681 image::load_from_memory_with_format(&png_data, ImageFormat::Png)
1682 .map_err(|error| format!("decode Android screenshot: {error}"))
1683 .map(DynamicImage::into_rgba8)
1684}
1685
1686fn find_screenshot_overlap(previous: &RgbaImage, next: &RgbaImage) -> Result<u32, String> {
1687 let (width, height) = previous.dimensions();
1688 if next.dimensions() != (width, height) {
1689 return Err("cannot stitch Android screenshots with different dimensions".to_string());
1690 }
1691 for overlap in ((height / 4)..=height).rev() {
1692 if screenshots_match_at_overlap(previous, next, overlap) {
1693 return Ok(overlap);
1694 }
1695 }
1696 Err(
1697 "could not align Android screenshots after scrolling; the foreground content changed"
1698 .to_string(),
1699 )
1700}
1701
1702fn screenshots_match_at_overlap(previous: &RgbaImage, next: &RgbaImage, overlap: u32) -> bool {
1703 let (width, height) = previous.dimensions();
1704 let row_step = (overlap / 24).max(1);
1705 let column_step = (width / 24).max(1);
1706 let mut matched = 0_u32;
1707 let mut sampled = 0_u32;
1708 for row in (0..overlap).step_by(row_step as usize) {
1709 for column in (0..width).step_by(column_step as usize) {
1710 let before = previous.get_pixel(column, height - overlap + row);
1711 let after = next.get_pixel(column, row);
1712 sampled += 1;
1713 if before == after {
1714 matched += 1;
1715 }
1716 }
1717 }
1718 sampled > 0 && matched * 100 >= sampled * 98
1719}
1720
1721fn append_screenshot(
1722 stitched: &mut RgbaImage,
1723 next: &RgbaImage,
1724 overlap: u32,
1725) -> Result<(), String> {
1726 let (width, height) = stitched.dimensions();
1727 let appended_height = next.height() - overlap;
1728 let combined_height = height
1729 .checked_add(appended_height)
1730 .ok_or_else(|| "Android full-page screenshot is too tall".to_string())?;
1731 let mut combined = RgbaImage::new(width, combined_height);
1732 image::imageops::replace(&mut combined, stitched, 0, 0);
1733 image::imageops::replace(&mut combined, next, 0, i64::from(height - overlap));
1734 *stitched = combined;
1735 Ok(())
1736}
1737
1738struct ResolvedSelectorSnapshot {
1739 selector: String,
1740 node: AndroidUiNode,
1741 foreground: ForegroundAppInfo,
1742}
1743
1744fn resolve_selector_snapshot(
1745 device_id: &str,
1746 selector: &str,
1747 _timeout_ms: Option<u32>,
1748) -> Result<ResolvedSelectorSnapshot, String> {
1749 let source = adb_dump_source(device_id)?;
1750 let nodes = parse_android_ui_nodes(&source.source)?;
1751 let node = accessibility::matching_source_nodes(device_id, &source, &nodes, selector)?
1752 .into_iter()
1753 .next()
1754 .ok_or_else(|| {
1755 let package_name = source.current_package.as_deref().unwrap_or("<unknown>");
1756 let activity_name = source.current_activity.as_deref().unwrap_or("<unknown>");
1757 format!("selector not found: {selector} (current app: {package_name}/{activity_name})")
1758 })?;
1759 Ok(ResolvedSelectorSnapshot {
1760 selector: selector.to_string(),
1761 node: node.clone(),
1762 foreground: ForegroundAppInfo {
1763 current_package: source.current_package,
1764 current_activity: source.current_activity,
1765 },
1766 })
1767}
1768
1769fn parse_foreground_app_from_dumpsys(output: &str) -> ForegroundAppInfo {
1770 for line in output.lines() {
1771 if !(line.contains("mCurrentFocus") || line.contains("mFocusedApp")) {
1772 continue;
1773 }
1774 if let Some((package_name, activity_name)) = extract_component_from_line(line) {
1775 return ForegroundAppInfo {
1776 current_package: Some(package_name),
1777 current_activity: Some(activity_name),
1778 };
1779 }
1780 }
1781 ForegroundAppInfo {
1782 current_package: None,
1783 current_activity: None,
1784 }
1785}
1786
1787fn extract_component_from_line(line: &str) -> Option<(String, String)> {
1788 for token in line.split_whitespace() {
1789 let cleaned = token.trim_matches(|ch: char| matches!(ch, '{' | '}' | ',' | ';'));
1790 let Some(slash_index) = cleaned.find('/') else {
1791 continue;
1792 };
1793 let package_name = cleaned[..slash_index].trim();
1794 let activity_name = cleaned[slash_index + 1..].trim();
1795 if package_name.is_empty()
1796 || activity_name.is_empty()
1797 || !package_name.contains('.')
1798 || package_name.contains('=')
1799 {
1800 continue;
1801 }
1802 return Some((package_name.to_string(), activity_name.to_string()));
1803 }
1804 None
1805}
1806
1807fn parse_android_ui_nodes(xml: &str) -> Result<Vec<AndroidUiNode>, String> {
1808 let mut nodes = Vec::new();
1809 let mut stack: Vec<usize> = Vec::new();
1810 let mut cursor = 0usize;
1811 while let Some(relative_start) = xml[cursor..].find('<') {
1812 let start = cursor + relative_start;
1813 let Some(relative_end) = xml[start..].find('>') else {
1814 break;
1815 };
1816 let end = start + relative_end;
1817 let tag = &xml[start + 1..end];
1818 let trimmed = tag.trim();
1819 if trimmed.starts_with("/node") {
1820 let _ = stack.pop();
1821 } else if trimmed.starts_with("node") {
1822 let self_closing = trimmed.ends_with('/');
1823 let attributes = parse_xml_attributes(trimmed);
1824 let node = AndroidUiNode {
1825 class_name: attributes.get("class").cloned(),
1826 resource_id: attributes.get("resource-id").cloned(),
1827 package_name: attributes.get("package").cloned(),
1828 text: attributes.get("text").cloned(),
1829 content_desc: attributes.get("content-desc").cloned(),
1830 checkable: attributes
1831 .get("checkable")
1832 .and_then(|value| parse_bool(value)),
1833 checked: attributes
1834 .get("checked")
1835 .and_then(|value| parse_bool(value)),
1836 clickable: attributes
1837 .get("clickable")
1838 .and_then(|value| parse_bool(value)),
1839 long_clickable: attributes
1840 .get("long-clickable")
1841 .and_then(|value| parse_bool(value)),
1842 scrollable: attributes
1843 .get("scrollable")
1844 .and_then(|value| parse_bool(value)),
1845 enabled: attributes
1846 .get("enabled")
1847 .and_then(|value| parse_bool(value)),
1848 focusable: attributes
1849 .get("focusable")
1850 .and_then(|value| parse_bool(value)),
1851 focused: attributes
1852 .get("focused")
1853 .and_then(|value| parse_bool(value)),
1854 selected: attributes
1855 .get("selected")
1856 .and_then(|value| parse_bool(value)),
1857 password: attributes
1858 .get("password")
1859 .and_then(|value| parse_bool(value)),
1860 index: attributes.get("index").and_then(|value| value.parse().ok()),
1861 bounds: attributes
1862 .get("bounds")
1863 .and_then(|value| parse_bounds(value)),
1864 parent_index: stack.last().copied(),
1865 };
1866 let index = nodes.len();
1867 nodes.push(node);
1868 if !self_closing {
1869 stack.push(index);
1870 }
1871 }
1872 cursor = end + 1;
1873 }
1874 Ok(nodes)
1875}
1876
1877fn parse_xml_attributes(tag: &str) -> std::collections::BTreeMap<String, String> {
1878 let mut attributes = std::collections::BTreeMap::new();
1879 let bytes = tag.as_bytes();
1880 let mut index = 0usize;
1881 while index < bytes.len() {
1882 while index < bytes.len() && !bytes[index].is_ascii_alphabetic() {
1883 index += 1;
1884 }
1885 let key_start = index;
1886 while index < bytes.len()
1887 && (bytes[index].is_ascii_alphanumeric() || matches!(bytes[index], b'-' | b'_'))
1888 {
1889 index += 1;
1890 }
1891 if key_start == index {
1892 continue;
1893 }
1894 let key = &tag[key_start..index];
1895 while index < bytes.len() && bytes[index].is_ascii_whitespace() {
1896 index += 1;
1897 }
1898 if index >= bytes.len() || bytes[index] != b'=' {
1899 continue;
1900 }
1901 index += 1;
1902 while index < bytes.len() && bytes[index].is_ascii_whitespace() {
1903 index += 1;
1904 }
1905 if index >= bytes.len() || bytes[index] != b'"' {
1906 continue;
1907 }
1908 index += 1;
1909 let value_start = index;
1910 while index < bytes.len() && bytes[index] != b'"' {
1911 index += 1;
1912 }
1913 if index > value_start {
1914 attributes.insert(
1915 key.to_string(),
1916 decode_xml_entities(&tag[value_start..index]),
1917 );
1918 }
1919 if index < bytes.len() {
1920 index += 1;
1921 }
1922 }
1923 attributes
1924}
1925
1926fn decode_xml_entities(value: &str) -> String {
1927 value
1928 .replace(""", "\"")
1929 .replace("'", "'")
1930 .replace("<", "<")
1931 .replace(">", ">")
1932 .replace("&", "&")
1933}
1934
1935fn parse_bool(value: &str) -> Option<bool> {
1936 match value.trim().to_ascii_lowercase().as_str() {
1937 "true" => Some(true),
1938 "false" => Some(false),
1939 _ => None,
1940 }
1941}
1942
1943fn parse_bounds(value: &str) -> Option<AndroidBounds> {
1944 let trimmed = value.trim();
1945 let trimmed = trimmed.strip_prefix('[')?;
1946 let (left_top, right_bottom) = trimmed.split_once("][")?;
1947 let right_bottom = right_bottom.strip_suffix(']')?;
1948 let (left, top) = left_top.split_once(',')?;
1949 let (right, bottom) = right_bottom.split_once(',')?;
1950 Some(AndroidBounds {
1951 left: left.parse().ok()?,
1952 top: top.parse().ok()?,
1953 right: right.parse().ok()?,
1954 bottom: bottom.parse().ok()?,
1955 })
1956}
1957
1958fn matching_nodes_by_selector<'a>(
1959 nodes: &'a [AndroidUiNode],
1960 selector: &str,
1961) -> Result<Vec<&'a AndroidUiNode>, String> {
1962 let segments = parse_selector_segments(selector)?;
1963 if segments.len() == 1 && segments[0].value.starts_with("/hierarchy/") {
1964 return accessibility::resolve_absolute_path(nodes, &segments[0].value)
1965 .map(|index| index.map(|i| vec![&nodes[i]]).unwrap_or_default());
1966 }
1967 let criteria = segments
1968 .iter()
1969 .map(selector_segment_to_criteria)
1970 .collect::<Result<Vec<_>, _>>()?;
1971 let Some(last) = criteria.last() else {
1972 return Ok(Vec::new());
1973 };
1974 let mut matches = Vec::new();
1975 for (index, node) in nodes.iter().enumerate() {
1976 if !node_matches_criteria(node, last) {
1977 continue;
1978 }
1979 if ancestor_chain_matches(nodes, index, &criteria[..criteria.len().saturating_sub(1)]) {
1980 matches.push(node);
1981 }
1982 }
1983 if let Some(instance) = last.instance {
1984 return Ok(matches.get(instance).copied().into_iter().collect());
1985 }
1986 Ok(matches)
1987}
1988
1989#[cfg(test)]
1990fn find_node_by_selector<'a>(
1991 nodes: &'a [AndroidUiNode],
1992 selector: &str,
1993) -> Result<&'a AndroidUiNode, String> {
1994 matching_nodes_by_selector(nodes, selector)?
1995 .into_iter()
1996 .next()
1997 .ok_or_else(|| format!("no Android element matched selector {selector:?}"))
1998}
1999
2000fn node_is_visible(node: &AndroidUiNode) -> bool {
2001 if node.enabled == Some(false) {
2002 return false;
2003 }
2004 let Some(bounds) = node.bounds else {
2005 return false;
2006 };
2007 bounds.right > bounds.left && bounds.bottom > bounds.top
2008}
2009
2010fn android_keyevent_for(key: &str) -> Option<&'static str> {
2011 match key.trim().to_ascii_lowercase().as_str() {
2012 "enter" => Some("KEYCODE_ENTER"),
2013 "tab" => Some("KEYCODE_TAB"),
2014 "backspace" | "delete" => Some("KEYCODE_DEL"),
2015 "escape" | "esc" => Some("KEYCODE_ESCAPE"),
2016 "space" => Some("KEYCODE_SPACE"),
2017 "back" => Some("KEYCODE_BACK"),
2018 "home" => Some("KEYCODE_HOME"),
2019 "arrowleft" | "left" => Some("KEYCODE_DPAD_LEFT"),
2020 "arrowright" | "right" => Some("KEYCODE_DPAD_RIGHT"),
2021 "arrowup" | "up" => Some("KEYCODE_DPAD_UP"),
2022 "arrowdown" | "down" => Some("KEYCODE_DPAD_DOWN"),
2023 "center" => Some("KEYCODE_DPAD_CENTER"),
2024 _ => None,
2025 }
2026}
2027
2028fn ancestor_chain_matches(
2029 nodes: &[AndroidUiNode],
2030 node_index: usize,
2031 criteria_chain: &[NodeCriteria],
2032) -> bool {
2033 if criteria_chain.is_empty() {
2034 return true;
2035 }
2036 let mut current_parent = nodes[node_index].parent_index;
2037 for criteria in criteria_chain.iter().rev() {
2038 let mut matched_parent = None;
2039 let mut probe = current_parent;
2040 while let Some(index) = probe {
2041 if node_matches_criteria(&nodes[index], criteria) {
2042 matched_parent = Some(index);
2043 break;
2044 }
2045 probe = nodes[index].parent_index;
2046 }
2047 let Some(index) = matched_parent else {
2048 return false;
2049 };
2050 current_parent = nodes[index].parent_index;
2051 }
2052 true
2053}
2054
2055fn node_matches_criteria(node: &AndroidUiNode, criteria: &NodeCriteria) -> bool {
2056 if let Some(role) = criteria.semantic_role.as_deref()
2057 && android_semantic_role(node) != role
2058 {
2059 return false;
2060 }
2061 if let Some(matcher) = criteria.semantic_name.as_ref()
2062 && !semantic_matches_any(
2063 matcher,
2064 [node.content_desc.as_deref(), node.text.as_deref()],
2065 )
2066 {
2067 return false;
2068 }
2069 if let Some(matcher) = criteria.semantic_text.as_ref()
2070 && !semantic_text_matches(node.text.as_deref(), matcher)
2071 {
2072 return false;
2073 }
2074 if let Some(matcher) = criteria.semantic_label.as_ref()
2075 && !semantic_matches_any(
2076 matcher,
2077 [node.content_desc.as_deref(), node.text.as_deref()],
2078 )
2079 {
2080 return false;
2081 }
2082 if let Some(matcher) = criteria.semantic_test_id.as_ref()
2083 && !semantic_matches_any(
2084 matcher,
2085 [
2086 node.resource_id.as_deref(),
2087 node.resource_id
2088 .as_deref()
2089 .and_then(|value| value.rsplit('/').next()),
2090 ],
2091 )
2092 {
2093 return false;
2094 }
2095 if let Some(disabled) = criteria.semantic_disabled
2096 && node.enabled != Some(!disabled)
2097 {
2098 return false;
2099 }
2100 if let Some(expected) = criteria.class_name.as_deref()
2101 && node.class_name.as_deref() != Some(expected)
2102 {
2103 return false;
2104 }
2105 if let Some(pattern) = criteria.class_name_matches.as_deref()
2106 && !matches_regex(node.class_name.as_deref(), pattern)
2107 {
2108 return false;
2109 }
2110 if let Some(expected) = criteria.resource_id_exact.as_deref()
2111 && node.resource_id.as_deref() != Some(expected)
2112 {
2113 return false;
2114 }
2115 if let Some(expected_suffix) = criteria.resource_id_suffix.as_deref() {
2116 let Some(resource_id) = node.resource_id.as_deref() else {
2117 return false;
2118 };
2119 if !resource_id_matches_suffix(resource_id, expected_suffix) {
2120 return false;
2121 }
2122 }
2123 if let Some(pattern) = criteria.resource_id_matches.as_deref()
2124 && !matches_regex(node.resource_id.as_deref(), pattern)
2125 {
2126 return false;
2127 }
2128 if let Some(expected) = criteria.package_name.as_deref()
2129 && node.package_name.as_deref() != Some(expected)
2130 {
2131 return false;
2132 }
2133 if let Some(pattern) = criteria.package_name_matches.as_deref()
2134 && !matches_regex(node.package_name.as_deref(), pattern)
2135 {
2136 return false;
2137 }
2138 if let Some(expected) = criteria.text.as_deref()
2139 && node.text.as_deref() != Some(expected)
2140 {
2141 return false;
2142 }
2143 if let Some(expected) = criteria.text_contains.as_deref()
2144 && !contains_text(node.text.as_deref(), expected)
2145 {
2146 return false;
2147 }
2148 if let Some(expected) = criteria.text_starts_with.as_deref()
2149 && !starts_with_text(node.text.as_deref(), expected)
2150 {
2151 return false;
2152 }
2153 if let Some(pattern) = criteria.text_matches.as_deref()
2154 && !matches_regex(node.text.as_deref(), pattern)
2155 {
2156 return false;
2157 }
2158 if let Some(expected) = criteria.content_desc.as_deref()
2159 && node.content_desc.as_deref() != Some(expected)
2160 {
2161 return false;
2162 }
2163 if let Some(expected) = criteria.content_desc_contains.as_deref()
2164 && !contains_text(node.content_desc.as_deref(), expected)
2165 {
2166 return false;
2167 }
2168 if let Some(expected) = criteria.content_desc_starts_with.as_deref()
2169 && !starts_with_text(node.content_desc.as_deref(), expected)
2170 {
2171 return false;
2172 }
2173 if let Some(pattern) = criteria.content_desc_matches.as_deref()
2174 && !matches_regex(node.content_desc.as_deref(), pattern)
2175 {
2176 return false;
2177 }
2178 if let Some(expected) = criteria.checkable
2179 && node.checkable != Some(expected)
2180 {
2181 return false;
2182 }
2183 if let Some(expected) = criteria.checked
2184 && node.checked != Some(expected)
2185 {
2186 return false;
2187 }
2188 if let Some(expected) = criteria.clickable
2189 && node.clickable != Some(expected)
2190 {
2191 return false;
2192 }
2193 if let Some(expected) = criteria.long_clickable
2194 && node.long_clickable != Some(expected)
2195 {
2196 return false;
2197 }
2198 if let Some(expected) = criteria.scrollable
2199 && node.scrollable != Some(expected)
2200 {
2201 return false;
2202 }
2203 if let Some(expected) = criteria.enabled
2204 && node.enabled != Some(expected)
2205 {
2206 return false;
2207 }
2208 if let Some(expected) = criteria.focusable
2209 && node.focusable != Some(expected)
2210 {
2211 return false;
2212 }
2213 if let Some(expected) = criteria.focused
2214 && node.focused != Some(expected)
2215 {
2216 return false;
2217 }
2218 if let Some(expected) = criteria.selected
2219 && node.selected != Some(expected)
2220 {
2221 return false;
2222 }
2223 if let Some(expected) = criteria.index
2224 && node.index != Some(expected)
2225 {
2226 return false;
2227 }
2228 true
2229}
2230
2231fn android_semantic_role(node: &AndroidUiNode) -> &'static str {
2232 match node
2233 .class_name
2234 .as_deref()
2235 .unwrap_or("")
2236 .rsplit('.')
2237 .next()
2238 .unwrap_or("")
2239 {
2240 "Button" | "ImageButton" => "button",
2241 "EditText" => "textbox",
2242 "CheckBox" => "checkbox",
2243 "RadioButton" => "radio",
2244 "Switch" | "SwitchCompat" | "ToggleButton" => "switch",
2245 "SeekBar" => "slider",
2246 "ProgressBar" => "progressbar",
2247 "Spinner" => "combobox",
2248 "ListView" | "RecyclerView" => "list",
2249 "ImageView" => "img",
2250 "TextView" => "text",
2251 _ => "generic",
2252 }
2253}
2254
2255fn semantic_matches_any<'a>(
2256 matcher: &SemanticTextMatcher,
2257 values: impl IntoIterator<Item = Option<&'a str>>,
2258) -> bool {
2259 values
2260 .into_iter()
2261 .any(|value| semantic_text_matches(value, matcher))
2262}
2263
2264fn semantic_text_matches(actual: Option<&str>, matcher: &SemanticTextMatcher) -> bool {
2265 let Some(actual) = actual else { return false };
2266 if matcher.regex {
2267 let flags: String = matcher
2268 .flags
2269 .chars()
2270 .filter(|flag| matches!(flag, 'i' | 'm' | 's'))
2271 .collect();
2272 let pattern = if flags.is_empty() {
2273 matcher.value.clone()
2274 } else {
2275 format!("(?{flags}:{})", matcher.value)
2276 };
2277 return Regex::new(&pattern).is_ok_and(|regex| regex.is_match(actual));
2278 }
2279 if matcher.exact {
2280 actual == matcher.value
2281 } else {
2282 actual
2283 .to_lowercase()
2284 .contains(&matcher.value.to_lowercase())
2285 }
2286}
2287
2288fn parse_selector_segments(selector: &str) -> Result<Vec<SelectorSegment>, String> {
2289 let mut segments = Vec::new();
2290 let normalized = normalize_selector_for_transport(selector);
2291 let trimmed = normalized.trim();
2292 let mut index = 0usize;
2293 while index < trimmed.len() {
2294 let remainder = &trimmed[index..];
2295 let (flavor, prefix_len) = if remainder.starts_with("css=") {
2296 (SelectorFlavorInternal::Css, 4usize)
2297 } else if remainder.starts_with("xpath=") {
2298 (SelectorFlavorInternal::XPath, 6usize)
2299 } else if remainder.starts_with("uia=") {
2300 (SelectorFlavorInternal::UiAutomator, 4usize)
2301 } else if remainder.starts_with("aw=") {
2302 (SelectorFlavorInternal::Semantic, 3usize)
2303 } else {
2304 return Err(format!("unsupported selector segment in `{trimmed}`"));
2305 };
2306 index += prefix_len;
2307 let bytes = trimmed.as_bytes();
2308 if bytes.get(index).copied() != Some(b'"') {
2309 return Err(format!(
2310 "selector segment must use JSON string syntax: `{trimmed}`"
2311 ));
2312 }
2313 let start = index;
2314 index += 1;
2315 let mut escaped = false;
2316 while index < trimmed.len() {
2317 let byte = bytes[index];
2318 if escaped {
2319 escaped = false;
2320 } else if byte == b'\\' {
2321 escaped = true;
2322 } else if byte == b'"' {
2323 index += 1;
2324 break;
2325 }
2326 index += 1;
2327 }
2328 let json_body = &trimmed[start..index];
2329 let value = serde_json::from_str::<String>(json_body)
2330 .map_err(|error| format!("failed to decode selector segment {json_body}: {error}"))?;
2331 segments.push(SelectorSegment { flavor, value });
2332 while index < trimmed.len() && trimmed.as_bytes()[index].is_ascii_whitespace() {
2333 index += 1;
2334 }
2335 }
2336 if segments.is_empty() {
2337 return Err("selector must not be empty".to_string());
2338 }
2339 Ok(segments)
2340}
2341
2342fn selector_segment_to_criteria(segment: &SelectorSegment) -> Result<NodeCriteria, String> {
2343 match segment.flavor {
2344 SelectorFlavorInternal::Css => parse_css_criteria(&segment.value),
2345 SelectorFlavorInternal::XPath => parse_xpath_criteria(&segment.value),
2346 SelectorFlavorInternal::UiAutomator => parse_uiautomator_criteria(&segment.value),
2347 SelectorFlavorInternal::Semantic => parse_semantic_criteria(&segment.value),
2348 }
2349}
2350
2351fn parse_semantic_criteria(selector: &str) -> Result<NodeCriteria, String> {
2352 let spec: serde_json::Value = serde_json::from_str(selector)
2353 .map_err(|error| format!("invalid semantic mobile selector: {error}"))?;
2354 let kind = spec["kind"]
2355 .as_str()
2356 .ok_or("semantic mobile selector requires `kind`")?;
2357 let exact = spec["exact"].as_bool().unwrap_or(false);
2358 let matcher = |key: &str| parse_semantic_matcher(&spec[key], exact);
2359 let mut criteria = NodeCriteria::default();
2360 match kind {
2361 "role" => {
2362 criteria.semantic_role = Some(
2363 spec["role"]
2364 .as_str()
2365 .ok_or("getByRole requires `role`")?
2366 .to_ascii_lowercase(),
2367 );
2368 if !spec["name"].is_null() {
2369 criteria.semantic_name = Some(matcher("name")?);
2370 }
2371 criteria.checked = spec["checked"].as_bool();
2372 criteria.selected = spec["selected"].as_bool();
2373 criteria.semantic_disabled = spec["disabled"].as_bool();
2374 }
2375 "text" => criteria.semantic_text = Some(matcher("text")?),
2376 "label" => criteria.semantic_label = Some(matcher("text")?),
2377 "testId" => criteria.semantic_test_id = Some(parse_semantic_matcher(&spec["text"], true)?),
2378 other => {
2379 return Err(format!(
2380 "unsupported semantic mobile selector kind `{other}`"
2381 ));
2382 }
2383 }
2384 Ok(criteria)
2385}
2386
2387fn parse_semantic_matcher(
2388 value: &serde_json::Value,
2389 exact: bool,
2390) -> Result<SemanticTextMatcher, String> {
2391 if let Some(value) = value.as_str() {
2392 return Ok(SemanticTextMatcher {
2393 value: value.to_string(),
2394 regex: false,
2395 flags: String::new(),
2396 exact,
2397 });
2398 }
2399 let pattern = value["regex"]
2400 .as_str()
2401 .ok_or("semantic text matcher must be a string or regular expression")?;
2402 let flags = value["flags"].as_str().unwrap_or("");
2403 Ok(SemanticTextMatcher {
2404 value: pattern.to_string(),
2405 regex: true,
2406 flags: flags.to_string(),
2407 exact: false,
2408 })
2409}
2410
2411fn parse_uiautomator_criteria(selector: &str) -> Result<NodeCriteria, String> {
2412 let mut criteria = NodeCriteria::default();
2413 for token in split_uiautomator_tokens(selector) {
2414 let (raw_key, raw_value) = split_uiautomator_token(&token)?;
2415 let key = raw_key.to_ascii_lowercase();
2416 match key.as_str() {
2417 "text" => criteria.text = Some(raw_value.to_string()),
2418 "textcontains" => criteria.text_contains = Some(raw_value.to_string()),
2419 "textstartswith" => criteria.text_starts_with = Some(raw_value.to_string()),
2420 "textmatches" => criteria.text_matches = Some(raw_value.to_string()),
2421 "classname" => criteria.class_name = Some(raw_value.to_string()),
2422 "classnamematches" => criteria.class_name_matches = Some(raw_value.to_string()),
2423 "description" | "desc" => criteria.content_desc = Some(raw_value.to_string()),
2424 "descriptioncontains" | "desccontains" => {
2425 criteria.content_desc_contains = Some(raw_value.to_string())
2426 }
2427 "descriptionstartswith" | "descstartswith" => {
2428 criteria.content_desc_starts_with = Some(raw_value.to_string())
2429 }
2430 "descriptionmatches" | "descmatches" => {
2431 criteria.content_desc_matches = Some(raw_value.to_string())
2432 }
2433 "packagename" | "package" => criteria.package_name = Some(raw_value.to_string()),
2434 "packagenamematches" => criteria.package_name_matches = Some(raw_value.to_string()),
2435 "resourceid" => criteria.resource_id_exact = Some(raw_value.to_string()),
2436 "resourceidmatches" => criteria.resource_id_matches = Some(raw_value.to_string()),
2437 "checkable" => criteria.checkable = Some(parse_selector_bool(raw_value, &token)?),
2438 "checked" => criteria.checked = Some(parse_selector_bool(raw_value, &token)?),
2439 "clickable" => criteria.clickable = Some(parse_selector_bool(raw_value, &token)?),
2440 "longclickable" => {
2441 criteria.long_clickable = Some(parse_selector_bool(raw_value, &token)?)
2442 }
2443 "scrollable" => criteria.scrollable = Some(parse_selector_bool(raw_value, &token)?),
2444 "enabled" => criteria.enabled = Some(parse_selector_bool(raw_value, &token)?),
2445 "focusable" => criteria.focusable = Some(parse_selector_bool(raw_value, &token)?),
2446 "focused" => criteria.focused = Some(parse_selector_bool(raw_value, &token)?),
2447 "selected" => criteria.selected = Some(parse_selector_bool(raw_value, &token)?),
2448 "index" => criteria.index = Some(parse_selector_usize(raw_value, &token)?),
2449 "instance" => criteria.instance = Some(parse_selector_usize(raw_value, &token)?),
2450 other => return Err(format!("unsupported UiAutomator selector key `{other}`")),
2451 }
2452 }
2453 Ok(criteria)
2454}
2455
2456fn split_uiautomator_tokens(selector: &str) -> Vec<String> {
2457 let mut tokens = Vec::new();
2458 let mut current = String::new();
2459 let mut in_single = false;
2460 let mut in_double = false;
2461 for ch in selector.chars() {
2462 match ch {
2463 '\'' if !in_double => {
2464 in_single = !in_single;
2465 current.push(ch);
2466 }
2467 '"' if !in_single => {
2468 in_double = !in_double;
2469 current.push(ch);
2470 }
2471 ',' | ';' if !in_single && !in_double => {
2472 let trimmed = current.trim();
2473 if !trimmed.is_empty() {
2474 tokens.push(trimmed.to_string());
2475 }
2476 current.clear();
2477 }
2478 _ => current.push(ch),
2479 }
2480 }
2481 let trimmed = current.trim();
2482 if !trimmed.is_empty() {
2483 tokens.push(trimmed.to_string());
2484 }
2485 tokens
2486}
2487
2488fn split_uiautomator_token(token: &str) -> Result<(&str, &str), String> {
2489 let Some((key, value)) = token.split_once(['=', ':']) else {
2490 return Err(format!("invalid UiAutomator selector token `{token}`"));
2491 };
2492 Ok((key.trim(), value.trim()))
2493}
2494
2495fn parse_selector_bool(value: &str, token: &str) -> Result<bool, String> {
2496 parse_bool(value).ok_or_else(|| format!("invalid boolean value in `{token}`"))
2497}
2498
2499fn parse_selector_usize(value: &str, token: &str) -> Result<usize, String> {
2500 value
2501 .parse()
2502 .map_err(|_| format!("invalid integer value in `{token}`"))
2503}
2504
2505fn parse_css_criteria(selector: &str) -> Result<NodeCriteria, String> {
2506 let mut criteria = NodeCriteria::default();
2507 let mut remainder = selector.trim();
2508 while let Some(start) = remainder.find('[') {
2509 let end = remainder[start + 1..]
2510 .find(']')
2511 .map(|index| start + 1 + index)
2512 .ok_or_else(|| format!("unterminated CSS attribute selector in `{selector}`"))?;
2513 let attribute = &remainder[start + 1..end];
2514 let (name, value) = parse_attribute_filter(attribute)?;
2515 match name {
2516 "text" => criteria.text = Some(value),
2517 "content-desc" => criteria.content_desc = Some(value),
2518 "resource-id" => criteria.resource_id_exact = Some(value),
2519 _ => {
2520 return Err(format!(
2521 "unsupported CSS attribute selector `{name}` in `{selector}`"
2522 ));
2523 }
2524 }
2525 let mut next = String::with_capacity(remainder.len());
2526 next.push_str(remainder[..start].trim_end());
2527 next.push(' ');
2528 next.push_str(remainder[end + 1..].trim_start());
2529 let owned = next.trim().to_string();
2530 remainder = Box::leak(owned.into_boxed_str());
2531 }
2532
2533 let class_or_id = remainder.trim();
2534 if let Some(resource_id) = class_or_id.strip_prefix('#') {
2535 if resource_id.contains(':') {
2536 criteria.resource_id_exact = Some(resource_id.to_string());
2537 } else {
2538 criteria.resource_id_suffix = Some(format!(":id/{resource_id}"));
2539 }
2540 } else if let Some(class_name) = class_or_id.strip_prefix('.') {
2541 if !class_name.is_empty() {
2542 criteria.class_name = Some(class_name.to_string());
2543 }
2544 } else if !class_or_id.is_empty() {
2545 criteria.class_name = Some(class_or_id.to_string());
2546 }
2547 Ok(criteria)
2548}
2549
2550fn parse_xpath_criteria(selector: &str) -> Result<NodeCriteria, String> {
2551 let trimmed = selector.trim();
2552 let mut criteria = NodeCriteria::default();
2553 let mut path = trimmed;
2554 if let Some(stripped) = path.strip_prefix(".//") {
2555 path = stripped;
2556 } else if let Some(stripped) = path.strip_prefix("//") {
2557 path = stripped;
2558 }
2559
2560 let path = path.trim();
2561 let (node_pattern, predicates) = if let Some(start) = path.find('[') {
2562 let end = path
2563 .rfind(']')
2564 .ok_or_else(|| format!("unterminated XPath in `{selector}`"))?;
2565 (&path[..start], Some(&path[start + 1..end]))
2566 } else {
2567 (path, None)
2568 };
2569
2570 let node_pattern = node_pattern.trim();
2571 if !node_pattern.is_empty() && node_pattern != "*" {
2572 criteria.class_name = Some(node_pattern.to_string());
2573 }
2574
2575 if let Some(predicates) = predicates {
2576 for predicate in predicates.split(" and ") {
2577 let predicate = predicate.trim().trim_matches(|ch| ch == '(' || ch == ')');
2578 if let Some(value) = parse_xpath_attr_equals(predicate, "@text") {
2579 criteria.text = Some(value);
2580 continue;
2581 }
2582 if let Some(value) = parse_xpath_attr_equals(predicate, "@content-desc") {
2583 criteria.content_desc = Some(value);
2584 continue;
2585 }
2586 if let Some(value) = parse_xpath_attr_equals(predicate, "@class") {
2587 criteria.class_name = Some(value);
2588 continue;
2589 }
2590 if let Some(value) = parse_xpath_attr_equals(predicate, "@resource-id") {
2591 if value.contains(':') {
2592 criteria.resource_id_exact = Some(value);
2593 } else {
2594 criteria.resource_id_suffix = Some(format!(":id/{value}"));
2595 }
2596 continue;
2597 }
2598 if let Some(value) = parse_xpath_resource_suffix(predicate) {
2599 criteria.resource_id_suffix = Some(value);
2600 continue;
2601 }
2602 }
2603 }
2604
2605 Ok(criteria)
2606}
2607
2608fn parse_attribute_filter(attribute: &str) -> Result<(&str, String), String> {
2609 let (name, value) = attribute
2610 .split_once('=')
2611 .ok_or_else(|| format!("unsupported CSS attribute selector `{attribute}`"))?;
2612 Ok((name.trim(), parse_quoted_value(value.trim())?))
2613}
2614
2615fn parse_xpath_attr_equals(predicate: &str, attr_name: &str) -> Option<String> {
2616 let remainder = predicate.strip_prefix(attr_name)?;
2617 let remainder = remainder.trim_start();
2618 let remainder = remainder.strip_prefix('=')?.trim_start();
2619 parse_quoted_value(remainder).ok()
2620}
2621
2622fn parse_xpath_resource_suffix(predicate: &str) -> Option<String> {
2623 let suffix_marker = ":id/";
2624 let start = predicate.find(suffix_marker)?;
2625 let candidate = &predicate[start - 1..];
2626 parse_quoted_value(candidate).ok()
2627}
2628
2629fn resource_id_matches_suffix(resource_id: &str, expected_suffix: &str) -> bool {
2630 if resource_id.ends_with(expected_suffix) {
2631 return true;
2632 }
2633
2634 let bare_expected = expected_suffix
2635 .rsplit('/')
2636 .next()
2637 .filter(|value| !value.is_empty());
2638 matches!(bare_expected, Some(expected) if resource_id == expected)
2639}
2640
2641fn contains_text(actual: Option<&str>, expected: &str) -> bool {
2642 actual.is_some_and(|value| value.contains(expected))
2643}
2644
2645fn starts_with_text(actual: Option<&str>, expected: &str) -> bool {
2646 actual.is_some_and(|value| value.starts_with(expected))
2647}
2648
2649fn matches_regex(actual: Option<&str>, pattern: &str) -> bool {
2650 let Some(actual) = actual else {
2651 return false;
2652 };
2653 Regex::new(pattern).is_ok_and(|regex| regex.is_match(actual))
2654}
2655
2656fn parse_quoted_value(input: &str) -> Result<String, String> {
2657 let trimmed = input.trim();
2658 let quote = trimmed
2659 .chars()
2660 .next()
2661 .filter(|ch| *ch == '\'' || *ch == '"')
2662 .ok_or_else(|| format!("expected quoted value in `{input}`"))?;
2663 let end = trimmed[1..]
2664 .find(quote)
2665 .ok_or_else(|| format!("unterminated quoted value in `{input}`"))?;
2666 Ok(trimmed[1..1 + end].to_string())
2667}
2668
2669fn encode_adb_text(value: &str) -> String {
2670 let mut encoded = String::new();
2671 for ch in value.chars() {
2672 match ch {
2673 ' ' => encoded.push_str("%s"),
2674 '"' => encoded.push_str("\\\""),
2675 '\'' => encoded.push_str("\\'"),
2676 '&' | '|' | ';' | '<' | '>' | '(' | ')' | '$' | '\\' => {
2677 encoded.push('\\');
2678 encoded.push(ch);
2679 }
2680 _ => encoded.push(ch),
2681 }
2682 }
2683 encoded
2684}
2685
2686#[cfg(test)]
2687mod tests {
2688 use super::*;
2689
2690 #[test]
2691 fn recognizes_standard_android_picker_packages() {
2692 assert!(is_android_picker(Some("com.google.android.documentsui")));
2693 assert!(is_android_picker(Some("com.vendor.filepicker")));
2694 assert!(is_android_picker(Some("com.android.files")));
2695 assert!(!is_android_picker(Some("com.example.app")));
2696 assert!(!is_android_picker(None));
2697 }
2698
2699 #[test]
2700 fn detects_new_and_replaced_public_downloads() {
2701 let before = HashMap::from([
2702 (
2703 "/sdcard/Download/existing.pdf".to_string(),
2704 "10:1".to_string(),
2705 ),
2706 (
2707 "/sdcard/Download/replaced.pdf".to_string(),
2708 "20:1".to_string(),
2709 ),
2710 ]);
2711 let unchanged = before.clone();
2712 assert_eq!(changed_public_download(&before, &unchanged), None);
2713
2714 let mut replaced = before.clone();
2715 replaced.insert(
2716 "/sdcard/Download/replaced.pdf".to_string(),
2717 "21:2".to_string(),
2718 );
2719 assert_eq!(
2720 changed_public_download(&before, &replaced).as_deref(),
2721 Some("/sdcard/Download/replaced.pdf")
2722 );
2723
2724 let mut added = before.clone();
2725 added.insert("/sdcard/Download/new.pdf".to_string(), "1:2".to_string());
2726 assert!(changed_public_download(&before, &added).is_some());
2727 }
2728
2729 #[tokio::test]
2730 async fn boots_android_runtime() {
2731 assert_eq!(boot().await, "android ready");
2732 }
2733
2734 #[test]
2735 fn defaults_to_uiautomator2() {
2736 let android = profile();
2737 assert_eq!(
2738 android.default_backend,
2739 MobileAutomationBackend::UiAutomator2
2740 );
2741 assert_eq!(android.backends, ANDROID_BACKENDS);
2742 assert!(android.capabilities.supports_native_views);
2743 assert!(android.capabilities.supports_webviews);
2744 }
2745
2746 #[test]
2747 fn exposes_android_runtime_milestones() {
2748 let readiness = runtime_readiness();
2749 assert_eq!(readiness.maturity, RuntimeMaturity::Scaffolding);
2750 assert!(
2751 readiness
2752 .missing_runtime_artifacts
2753 .iter()
2754 .any(|item| item.contains("server-side mobile session routing"))
2755 );
2756 }
2757
2758 #[test]
2759 fn connect_flow_matches_web_shape() {
2760 let options = ConnectOptions {
2761 platform: MobilePlatform::Android,
2762 device: Some("emulator-5554".to_string()),
2763 adb_endpoint: None,
2764 agent_endpoint: None,
2765 preserve_app_state: true,
2766 timeout_ms: Some(5_000),
2767 };
2768
2769 let command = connect_command(options.clone());
2770
2771 match command {
2772 MobileCommand::Connect(connect) => {
2773 assert_eq!(connect.platform, MobilePlatform::Android);
2774 assert_eq!(connect.device.as_deref(), Some("emulator-5554"));
2775 }
2776 _ => panic!("expected mobile connect command"),
2777 }
2778 }
2779
2780 #[test]
2781 fn launch_flow_keeps_apk_work_separate() {
2782 let browser_session = MobileBrowserSessionHandle {
2783 platform: MobilePlatform::Android,
2784 automation: MobileAutomationSessionInfo {
2785 backend: "uiautomator2".to_string(),
2786 session_id: "uiautomator2:emulator-5554".to_string(),
2787 note: "ready".to_string(),
2788 },
2789 device: DeviceTarget {
2790 platform: MobilePlatform::Android,
2791 device_id: "emulator-5554".to_string(),
2792 connection_kind: DeviceConnectionKind::Emulator,
2793 },
2794 };
2795
2796 let command = launch_command(
2797 browser_session,
2798 LaunchOptions {
2799 apk_path: Some("/tmp/app.apk".to_string()),
2800 app_id: Some("dev.allwright.sample".to_string()),
2801 launch_activity: Some(".MainActivity".to_string()),
2802 stop_before_launch: true,
2803 timeout_ms: Some(15_000),
2804 },
2805 );
2806
2807 match command {
2808 MobileCommand::LaunchApp { options, .. } => {
2809 assert_eq!(options.apk_path.as_deref(), Some("/tmp/app.apk"));
2810 assert_eq!(options.app_id.as_deref(), Some("dev.allwright.sample"));
2811 assert!(options.stop_before_launch);
2812 }
2813 _ => panic!("expected launch command"),
2814 }
2815 }
2816
2817 #[test]
2818 fn parse_adb_devices_extracts_friendly_names() {
2819 let devices = parse_adb_devices(
2820 "List of devices attached\nemulator-5554 device product:sdk_gphone model:Pixel_8_API_35 device:emu64xa transport_id:5\nR5CX123 device usb:1-1 product:e3q model:QA_Galaxy_S24 device:e3q transport_id:8\n",
2821 );
2822
2823 assert_eq!(devices.len(), 2);
2824 assert_eq!(devices[0].name.as_deref(), Some("Pixel 8 API 35"));
2825 assert_eq!(devices[1].name.as_deref(), Some("QA Galaxy S24"));
2826 assert_eq!(devices[0].connection_kind, DeviceConnectionKind::Emulator);
2827 assert_eq!(devices[1].connection_kind, DeviceConnectionKind::Usb);
2828 }
2829
2830 #[test]
2831 fn connect_uses_requested_device_when_provided() {
2832 let options = ConnectOptions {
2833 platform: MobilePlatform::Android,
2834 device: Some("R5CX123".to_string()),
2835 adb_endpoint: None,
2836 agent_endpoint: None,
2837 preserve_app_state: false,
2838 timeout_ms: None,
2839 };
2840 let devices = vec![
2841 AdbDevice {
2842 serial: "emulator-5554".to_string(),
2843 name: Some("Pixel 8 API 35".to_string()),
2844 connection_kind: DeviceConnectionKind::Emulator,
2845 state: "device".to_string(),
2846 },
2847 AdbDevice {
2848 serial: "R5CX123".to_string(),
2849 name: Some("QA Galaxy S24".to_string()),
2850 connection_kind: DeviceConnectionKind::Usb,
2851 state: "device".to_string(),
2852 },
2853 ];
2854
2855 let selected = select_device_for_connect(&options, &devices).expect("device should match");
2856 assert_eq!(selected.serial, "R5CX123");
2857 assert_eq!(selected.connection_kind, DeviceConnectionKind::Usb);
2858 }
2859
2860 #[test]
2861 fn connect_defaults_to_first_available_device() {
2862 let options = ConnectOptions {
2863 platform: MobilePlatform::Android,
2864 device: None,
2865 adb_endpoint: None,
2866 agent_endpoint: None,
2867 preserve_app_state: false,
2868 timeout_ms: None,
2869 };
2870 let devices = vec![
2871 AdbDevice {
2872 serial: "emulator-5554".to_string(),
2873 name: Some("Pixel 8 API 35".to_string()),
2874 connection_kind: DeviceConnectionKind::Emulator,
2875 state: "device".to_string(),
2876 },
2877 AdbDevice {
2878 serial: "R5CX123".to_string(),
2879 name: Some("QA Galaxy S24".to_string()),
2880 connection_kind: DeviceConnectionKind::Usb,
2881 state: "device".to_string(),
2882 },
2883 ];
2884
2885 let selected = select_device_for_connect(&options, &devices).expect("first device");
2886 assert_eq!(selected.serial, "emulator-5554");
2887 }
2888
2889 #[test]
2890 fn connect_matches_friendly_device_name() {
2891 let options = ConnectOptions {
2892 platform: MobilePlatform::Android,
2893 device: Some("qa galaxy s24".to_string()),
2894 adb_endpoint: None,
2895 agent_endpoint: None,
2896 preserve_app_state: false,
2897 timeout_ms: None,
2898 };
2899 let devices = vec![
2900 AdbDevice {
2901 serial: "emulator-5554".to_string(),
2902 name: Some("Pixel 8 API 35".to_string()),
2903 connection_kind: DeviceConnectionKind::Emulator,
2904 state: "device".to_string(),
2905 },
2906 AdbDevice {
2907 serial: "R5CX123".to_string(),
2908 name: Some("QA Galaxy S24".to_string()),
2909 connection_kind: DeviceConnectionKind::Usb,
2910 state: "device".to_string(),
2911 },
2912 ];
2913
2914 let selected = select_device_for_connect(&options, &devices).expect("friendly name match");
2915 assert_eq!(selected.serial, "R5CX123");
2916 }
2917
2918 #[test]
2919 fn connect_fails_when_requested_device_is_missing() {
2920 let options = ConnectOptions {
2921 platform: MobilePlatform::Android,
2922 device: Some("missing-device".to_string()),
2923 adb_endpoint: None,
2924 agent_endpoint: None,
2925 preserve_app_state: false,
2926 timeout_ms: None,
2927 };
2928 let devices = vec![AdbDevice {
2929 serial: "emulator-5554".to_string(),
2930 name: Some("Pixel 8 API 35".to_string()),
2931 connection_kind: DeviceConnectionKind::Emulator,
2932 state: "device".to_string(),
2933 }];
2934
2935 let error = select_device_for_connect(&options, &devices).expect_err("missing device");
2936 assert_eq!(
2937 error,
2938 ConnectSelectionError::RequestedDeviceNotFound {
2939 requested: "missing-device".to_string()
2940 }
2941 );
2942 }
2943
2944 #[test]
2945 fn parse_package_name_from_aapt_output_extracts_package() {
2946 let package_name = parse_package_name_from_aapt_badging(
2947 "package: name='dev.allwright.sample' versionCode='1' versionName='1.0'\n",
2948 )
2949 .expect("package name should parse");
2950 assert_eq!(package_name, "dev.allwright.sample");
2951 }
2952
2953 #[test]
2954 fn detects_remote_apk_sources() {
2955 assert!(is_remote_apk_source("https://example.com/app.apk"));
2956 assert!(is_remote_apk_source("http://example.com/app.apk"));
2957 assert!(!is_remote_apk_source("/tmp/app.apk"));
2958 }
2959
2960 #[test]
2961 fn resolves_local_apk_source() {
2962 let apk_path = env::temp_dir().join("allwright-mobile-android-local-test.apk");
2963 fs::write(&apk_path, b"apk").expect("write local apk fixture");
2964
2965 let resolved =
2966 resolve_apk_source(apk_path.to_string_lossy().as_ref()).expect("resolve local apk");
2967 assert_eq!(resolved.path, apk_path);
2968
2969 fs::remove_file(&apk_path).expect("remove local apk fixture");
2970 }
2971
2972 #[test]
2973 fn foreground_app_parses_component_after_window_prefix() {
2974 let app = parse_foreground_app_from_dumpsys("mCurrentFocus=Window{42 u0 test.app/.Main}");
2975 assert_eq!(app.current_package.as_deref(), Some("test.app"));
2976 assert_eq!(app.current_activity.as_deref(), Some(".Main"));
2977 }
2978
2979 #[test]
2980 fn dump_source_keeps_page_context_separate() {
2981 let page_session = MobilePageSessionHandle {
2982 page_id: "emulator-5554:dev.allwright.sample".to_string(),
2983 package_name: Some("dev.allwright.sample".to_string()),
2984 activity_name: Some(".MainActivity".to_string()),
2985 webview_context: None,
2986 };
2987 assert_eq!(page_session.page_id, "emulator-5554:dev.allwright.sample");
2988 }
2989
2990 #[test]
2991 fn parses_uiautomator_selector_strategies() {
2992 let criteria = parse_uiautomator_criteria(
2993 "textContains=Account,descriptionStartsWith=Open,classNameMatches=android\\.widget\\..*,clickable=true,index=2,instance=1,resourceIdMatches=.*bottom_nav.*,packageName=com.example.airticket",
2994 )
2995 .expect("uia criteria should parse");
2996
2997 assert_eq!(criteria.text_contains.as_deref(), Some("Account"));
2998 assert_eq!(criteria.content_desc_starts_with.as_deref(), Some("Open"));
2999 assert_eq!(
3000 criteria.class_name_matches.as_deref(),
3001 Some("android\\.widget\\..*")
3002 );
3003 assert_eq!(criteria.clickable, Some(true));
3004 assert_eq!(criteria.index, Some(2));
3005 assert_eq!(criteria.instance, Some(1));
3006 assert_eq!(
3007 criteria.resource_id_matches.as_deref(),
3008 Some(".*bottom_nav.*")
3009 );
3010 assert_eq!(
3011 criteria.package_name.as_deref(),
3012 Some("com.example.airticket")
3013 );
3014 }
3015
3016 #[test]
3017 fn matches_uiautomator_selectors_against_dumped_nodes() {
3018 let nodes = parse_android_ui_nodes(sample_ui_hierarchy()).expect("xml should parse");
3019
3020 let account = find_node_by_selector(&nodes, "text=Account").expect("text selector");
3021 assert_eq!(
3022 account.resource_id.as_deref(),
3023 Some("com.example.airticket:id/bottom_nav_account")
3024 );
3025
3026 let by_desc =
3027 find_node_by_selector(&nodes, "descriptionContains=Account").expect("desc selector");
3028 assert_eq!(
3029 by_desc.resource_id.as_deref(),
3030 Some("com.example.airticket:id/bottom_nav_account")
3031 );
3032
3033 let by_resource =
3034 find_node_by_selector(&nodes, "resourceIdMatches=.*bottom_nav_.*,selected=true")
3035 .expect("resource regex selector");
3036 assert_eq!(
3037 by_resource.resource_id.as_deref(),
3038 Some("com.example.airticket:id/bottom_nav_account")
3039 );
3040
3041 let by_class = find_node_by_selector(
3042 &nodes,
3043 "classNameMatches=android\\.widget\\..*,packageNameMatches=com\\.example\\..*,clickable=true,instance=1",
3044 )
3045 .expect("class/package/instance selector");
3046 assert_eq!(
3047 by_class.resource_id.as_deref(),
3048 Some("com.example.airticket:id/bottom_nav_account")
3049 );
3050
3051 let by_css_id = find_node_by_selector(&nodes, r##"css="#bottom_nav_account""##)
3052 .expect("css id selector");
3053 assert_eq!(
3054 by_css_id.resource_id.as_deref(),
3055 Some("com.example.airticket:id/bottom_nav_account")
3056 );
3057
3058 let bare_nodes =
3059 parse_android_ui_nodes(sample_ui_hierarchy_with_bare_resource_ids()).expect("xml");
3060 let bare_by_css_id = find_node_by_selector(&bare_nodes, r##"css="#signup_first_name""##)
3061 .expect("bare css id");
3062 assert_eq!(
3063 bare_by_css_id.resource_id.as_deref(),
3064 Some("signup_first_name")
3065 );
3066 }
3067
3068 #[test]
3069 fn matches_playwright_style_semantic_mobile_selectors() {
3070 let nodes = parse_android_ui_nodes(sample_ui_hierarchy()).expect("xml should parse");
3071 let selector = |spec: serde_json::Value| {
3072 format!("aw={}", serde_json::to_string(&spec.to_string()).unwrap())
3073 };
3074
3075 let account = find_node_by_selector(
3076 &nodes,
3077 &selector(serde_json::json!({"kind":"role", "role":"text", "name":"account", "selected":true})),
3078 )
3079 .expect("semantic role selector");
3080 assert_eq!(account.text.as_deref(), Some("Account"));
3081
3082 let by_text = find_node_by_selector(
3083 &nodes,
3084 &selector(serde_json::json!({"kind":"text", "text":{"regex":"^Acc", "flags":"i"}})),
3085 )
3086 .expect("semantic text selector");
3087 assert_eq!(by_text.text.as_deref(), Some("Account"));
3088
3089 let by_test_id = find_node_by_selector(
3090 &nodes,
3091 &selector(serde_json::json!({"kind":"testId", "text":"bottom_nav_account"})),
3092 )
3093 .expect("semantic test id selector");
3094 assert_eq!(
3095 by_test_id.resource_id.as_deref(),
3096 Some("com.example.airticket:id/bottom_nav_account")
3097 );
3098 }
3099
3100 #[test]
3101 fn stitches_scrolled_screenshots_without_duplicate_overlap() {
3102 let first =
3103 RgbaImage::from_fn(4, 10, |x, y| image::Rgba([(x * 11) as u8, y as u8, 0, 255]));
3104 let second = RgbaImage::from_fn(4, 10, |x, y| {
3105 image::Rgba([(x * 11) as u8, (y + 4) as u8, 0, 255])
3106 });
3107
3108 let overlap = find_screenshot_overlap(&first, &second).expect("screenshots align");
3109 assert_eq!(overlap, 6);
3110
3111 let mut stitched = first;
3112 append_screenshot(&mut stitched, &second, overlap).expect("screenshots stitch");
3113 assert_eq!(stitched.dimensions(), (4, 14));
3114 assert_eq!(stitched.get_pixel(0, 13), second.get_pixel(0, 9));
3115 }
3116
3117 fn sample_ui_hierarchy() -> &'static str {
3118 r#"<?xml version='1.0' encoding='UTF-8' standalone='yes' ?>
3119<hierarchy rotation="0">
3120 <node index="0" text="" resource-id="" class="android.widget.FrameLayout" package="com.example.airticket" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][1080,2400]">
3121 <node index="0" text="Home" resource-id="com.example.airticket:id/bottom_nav_home" class="android.widget.TextView" package="com.example.airticket" content-desc="Home" checkable="false" checked="false" clickable="true" enabled="true" focusable="true" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,2100][360,2400]" />
3122 <node index="1" text="Account" resource-id="com.example.airticket:id/bottom_nav_account" class="android.widget.TextView" package="com.example.airticket" content-desc="Open Account" checkable="false" checked="false" clickable="true" enabled="true" focusable="true" focused="false" scrollable="false" long-clickable="false" password="false" selected="true" bounds="[360,2100][720,2400]" />
3123 <node index="2" text="Email" resource-id="com.example.airticket:id/email" class="android.widget.EditText" package="com.example.airticket" content-desc="" checkable="false" checked="false" clickable="true" enabled="true" focusable="true" focused="true" scrollable="false" long-clickable="true" password="false" selected="false" bounds="[80,600][1000,720]" />
3124 </node>
3125</hierarchy>"#
3126 }
3127
3128 fn sample_ui_hierarchy_with_bare_resource_ids() -> &'static str {
3129 r#"<?xml version='1.0' encoding='UTF-8' standalone='yes' ?>
3130<hierarchy rotation="0">
3131 <node index="0" text="" resource-id="" class="android.widget.FrameLayout" package="com.example.airticket" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][1080,2400]">
3132 <node index="0" text="First Name" resource-id="signup_first_name" class="android.widget.EditText" package="com.example.airticket" content-desc="" checkable="false" checked="false" clickable="true" enabled="true" focusable="true" focused="false" scrollable="false" long-clickable="true" password="false" selected="false" bounds="[80,600][1000,720]" />
3133 </node>
3134</hierarchy>"#
3135 }
3136}