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