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::ClickElement {
800 browser_session,
801 page_session,
802 selector,
803 timeout_ms,
804 } => click_element(&browser_session, &page_session, &selector, timeout_ms)
805 .map(MobileCommandResult::ClickElement),
806 MobileCommand::CountElements {
807 browser_session,
808 page_session,
809 selector,
810 timeout_ms,
811 } => count_elements(&browser_session, &page_session, &selector, timeout_ms)
812 .map(MobileCommandResult::CountElements),
813 MobileCommand::FocusElement {
814 browser_session,
815 page_session,
816 selector,
817 timeout_ms,
818 } => focus_element(&browser_session, &page_session, &selector, timeout_ms)
819 .map(MobileCommandResult::FocusElement),
820 MobileCommand::FillElement {
821 browser_session,
822 page_session,
823 selector,
824 value,
825 timeout_ms,
826 } => fill_element(
827 &browser_session,
828 &page_session,
829 &selector,
830 &value,
831 timeout_ms,
832 )
833 .map(MobileCommandResult::FillElement),
834 MobileCommand::PressKey {
835 browser_session,
836 page_session,
837 selector,
838 key,
839 text,
840 timeout_ms,
841 } => press_key(
842 &browser_session,
843 &page_session,
844 &selector,
845 &key,
846 text.as_deref(),
847 timeout_ms,
848 )
849 .map(MobileCommandResult::PressKey),
850 MobileCommand::GetText {
851 browser_session,
852 page_session,
853 selector,
854 timeout_ms,
855 } => get_text(&browser_session, &page_session, &selector, timeout_ms)
856 .map(MobileCommandResult::GetText),
857 MobileCommand::GetInnerText {
858 browser_session,
859 page_session,
860 selector,
861 timeout_ms,
862 } => get_inner_text(&browser_session, &page_session, &selector, timeout_ms)
863 .map(MobileCommandResult::GetInnerText),
864 MobileCommand::WaitForSelector {
865 browser_session,
866 page_session,
867 selector,
868 visible,
869 timeout_ms,
870 } => wait_for_selector(
871 &browser_session,
872 &page_session,
873 &selector,
874 visible,
875 timeout_ms,
876 )
877 .map(MobileCommandResult::WaitForSelector),
878 MobileCommand::Screenshot {
879 browser_session,
880 page_session,
881 timeout_ms,
882 full_page,
883 } => screenshot(&browser_session, &page_session, timeout_ms, full_page)
884 .map(MobileCommandResult::Screenshot),
885 }
886}
887
888#[unsafe(no_mangle)]
889pub extern "C" fn allwright_plugin_api_version() -> u32 {
890 ALLWRIGHT_PLUGIN_API_VERSION
891}
892
893#[unsafe(no_mangle)]
894pub extern "C" fn allwright_plugin_id() -> *const c_char {
895 c"mobile-android".as_ptr()
896}
897
898#[unsafe(no_mangle)]
899pub unsafe extern "C" fn allwright_plugin_invoke(request_json: *const c_char) -> *mut c_char {
900 if request_json.is_null() {
901 return plugin_response(Err("plugin request pointer is null".to_string()));
902 }
903
904 let request = match unsafe { CStr::from_ptr(request_json) }.to_str() {
905 Ok(request) => request,
906 Err(error) => {
907 return plugin_response(Err(format!("plugin request is not valid UTF-8: {error}")));
908 }
909 };
910
911 let command: MobileCommand = match serde_json::from_str(request) {
912 Ok(command) => command,
913 Err(error) => {
914 return plugin_response(Err(format!(
915 "failed to parse mobile plugin request JSON: {error}"
916 )));
917 }
918 };
919
920 plugin_response(handle_plugin_command(command))
921}
922
923#[unsafe(no_mangle)]
924pub unsafe extern "C" fn allwright_plugin_free_string(value: *mut c_char) {
925 if !value.is_null() {
926 unsafe {
927 let _ = CString::from_raw(value);
928 }
929 }
930}
931
932fn parse_adb_devices(output: &str) -> Vec<AdbDevice> {
933 output
934 .lines()
935 .map(str::trim)
936 .filter(|line| !line.is_empty() && !line.starts_with("List of devices attached"))
937 .filter_map(|line| {
938 let mut parts = line.split_whitespace();
939 let serial = parts.next()?.to_string();
940 let state = parts.next()?.to_string();
941 let metadata = parts.collect::<Vec<_>>();
942 let mut name = None;
943 for item in metadata {
944 if let Some(model) = item.strip_prefix("model:") {
945 name = Some(model.replace('_', " "));
946 break;
947 }
948 if let Some(device_name) = item.strip_prefix("device:") {
949 name = Some(device_name.replace('_', " "));
950 }
951 }
952
953 Some(AdbDevice {
954 connection_kind: classify_device_connection_kind(&serial),
955 serial,
956 name,
957 state,
958 })
959 })
960 .collect()
961}
962
963fn classify_device_connection_kind(serial: &str) -> DeviceConnectionKind {
964 if serial.starts_with("emulator-") {
965 return DeviceConnectionKind::Emulator;
966 }
967 if serial.contains(':') {
968 return DeviceConnectionKind::RemoteAdb;
969 }
970 DeviceConnectionKind::Usb
971}
972
973fn run_adb(args: &[&str]) -> Result<String, String> {
974 run_command(&adb_command_path(), args)
975}
976
977fn run_adb_for_device(device_id: &str, args: &[&str]) -> Result<String, String> {
978 let mut all_args = vec!["-s", device_id];
979 all_args.extend_from_slice(args);
980 run_command(&adb_command_path(), &all_args)
981}
982
983fn run_adb_bytes_for_device(device_id: &str, args: &[&str]) -> Result<Vec<u8>, String> {
984 let mut all_args = vec!["-s", device_id];
985 all_args.extend_from_slice(args);
986 run_command_bytes(&adb_command_path(), &all_args)
987}
988
989fn adb_command_path() -> String {
990 env::var("ALLWRIGHT_ANDROID_ADB").unwrap_or_else(|_| "adb".to_string())
991}
992
993fn run_command(command: &str, args: &[&str]) -> Result<String, String> {
994 let output = run_command_output(command, args)?;
995 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
996}
997
998fn run_command_bytes(command: &str, args: &[&str]) -> Result<Vec<u8>, String> {
999 Ok(run_command_output(command, args)?.stdout)
1000}
1001
1002fn run_command_output(command: &str, args: &[&str]) -> Result<std::process::Output, String> {
1003 let output = Command::new(command)
1004 .args(args)
1005 .output()
1006 .map_err(|error| format!("failed to run `{command}`: {error}"))?;
1007 if output.status.success() {
1008 return Ok(output);
1009 }
1010
1011 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
1012 let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
1013 let detail = if !stderr.is_empty() { stderr } else { stdout };
1014 Err(format!(
1015 "`{command} {}` failed with status {}: {}",
1016 args.join(" "),
1017 output
1018 .status
1019 .code()
1020 .map_or_else(|| "signal".to_string(), |code| code.to_string()),
1021 detail
1022 ))
1023}
1024
1025#[derive(Debug)]
1026struct ResolvedApkSource {
1027 path: PathBuf,
1028 _downloaded_file: Option<PathBuf>,
1029}
1030
1031fn resolve_apk_source(apk_path_or_url: &str) -> Result<ResolvedApkSource, String> {
1032 if is_remote_apk_source(apk_path_or_url) {
1033 let downloaded_path = download_apk_to_temp(apk_path_or_url)?;
1034 return Ok(ResolvedApkSource {
1035 path: downloaded_path.clone(),
1036 _downloaded_file: Some(downloaded_path),
1037 });
1038 }
1039
1040 let apk = Path::new(apk_path_or_url);
1041 if !apk.is_file() {
1042 return Err(format!("APK path does not exist: {}", apk.display()));
1043 }
1044 Ok(ResolvedApkSource {
1045 path: apk.to_path_buf(),
1046 _downloaded_file: None,
1047 })
1048}
1049
1050fn is_remote_apk_source(apk_path_or_url: &str) -> bool {
1051 let lowered = apk_path_or_url.trim().to_ascii_lowercase();
1052 lowered.starts_with("http://") || lowered.starts_with("https://")
1053}
1054
1055fn download_apk_to_temp(url: &str) -> Result<PathBuf, String> {
1056 let response = reqwest::blocking::get(url)
1057 .map_err(|error| format!("failed to download APK from `{url}`: {error}"))?;
1058 if !response.status().is_success() {
1059 return Err(format!(
1060 "failed to download APK from `{url}`: HTTP {}",
1061 response.status()
1062 ));
1063 }
1064
1065 let bytes = response
1066 .bytes()
1067 .map_err(|error| format!("failed to read APK download from `{url}`: {error}"))?;
1068 let timestamp = SystemTime::now()
1069 .duration_since(UNIX_EPOCH)
1070 .map_err(|error| format!("failed to resolve system time for APK download: {error}"))?
1071 .as_millis();
1072 let path = env::temp_dir().join(format!("allwright-mobile-android-{timestamp}.apk"));
1073 fs::write(&path, bytes.as_ref()).map_err(|error| {
1074 format!(
1075 "failed to write downloaded APK to {}: {error}",
1076 path.display()
1077 )
1078 })?;
1079 Ok(path)
1080}
1081
1082fn resolve_package_name_from_apk_path(apk_path: &Path) -> Result<String, String> {
1083 let apk_path = apk_path.to_string_lossy().into_owned();
1084 if let Ok(output) = run_command("aapt", &["dump", "badging", &apk_path]) {
1085 if let Some(package_name) = parse_package_name_from_aapt_badging(&output) {
1086 return Ok(package_name);
1087 }
1088 }
1089
1090 if let Ok(output) = run_command("apkanalyzer", &["manifest", "application-id", &apk_path]) {
1091 let package_name = output.trim();
1092 if !package_name.is_empty() {
1093 return Ok(package_name.to_string());
1094 }
1095 }
1096
1097 Err(
1098 "could not resolve the app package name from the APK. Provide `--app-id`, or install `aapt`/`apkanalyzer`."
1099 .to_string(),
1100 )
1101}
1102
1103fn parse_package_name_from_aapt_badging(output: &str) -> Option<String> {
1104 output.lines().find_map(|line| {
1105 let package_line = line.trim();
1106 if !package_line.starts_with("package: ") {
1107 return None;
1108 }
1109 let needle = "name='";
1110 let start = package_line.find(needle)? + needle.len();
1111 let remainder = &package_line[start..];
1112 let end = remainder.find('\'')?;
1113 Some(remainder[..end].to_string())
1114 })
1115}
1116
1117fn current_foreground_app(device_id: &str) -> Result<ForegroundAppInfo, String> {
1118 let window_dump = run_adb_for_device(device_id, &["shell", "dumpsys", "window", "windows"])
1119 .or_else(|_| run_adb_for_device(device_id, &["shell", "dumpsys", "window"]))?;
1120 Ok(parse_foreground_app_from_dumpsys(&window_dump))
1121}
1122
1123fn android_hooks() -> &'static Mutex<HashMap<String, AndroidHookState>> {
1124 ANDROID_HOOKS.get_or_init(|| Mutex::new(HashMap::new()))
1125}
1126
1127fn android_choosers() -> &'static Mutex<HashMap<String, AndroidChooserState>> {
1128 ANDROID_CHOOSERS.get_or_init(|| Mutex::new(HashMap::new()))
1129}
1130
1131fn android_downloads() -> &'static Mutex<HashMap<String, AndroidDownloadState>> {
1132 ANDROID_DOWNLOADS.get_or_init(|| Mutex::new(HashMap::new()))
1133}
1134
1135fn list_public_downloads(device_id: &str) -> Result<HashMap<String, String>, String> {
1136 let output = run_adb_for_device(
1137 device_id,
1138 &["shell", "find", "/sdcard/Download", "-type", "f"],
1139 )?;
1140 output
1141 .lines()
1142 .map(str::trim)
1143 .filter(|path| !path.is_empty())
1144 .map(|path| {
1145 let signature = run_adb_for_device(device_id, &["shell", "stat", "-c", "%s:%Y", path])?;
1146 Ok((path.to_string(), signature))
1147 })
1148 .collect()
1149}
1150
1151fn changed_public_download(
1152 before: &HashMap<String, String>,
1153 after: &HashMap<String, String>,
1154) -> Option<String> {
1155 after
1156 .iter()
1157 .find(|(path, signature)| before.get(*path) != Some(*signature))
1158 .map(|(path, _)| path.clone())
1159}
1160
1161fn android_file_size(device_id: &str, path: &str) -> Result<u64, String> {
1162 run_adb_for_device(device_id, &["shell", "stat", "-c", "%s", path])?
1163 .trim()
1164 .parse::<u64>()
1165 .map_err(|error| format!("Android file size is invalid for {path}: {error}"))
1166}
1167
1168fn is_android_picker(package: Option<&str>) -> bool {
1169 package.is_some_and(|package| {
1170 let package = package.to_ascii_lowercase();
1171 package.contains("documentsui")
1172 || package.contains("filepicker")
1173 || package.ends_with(".files")
1174 })
1175}
1176
1177fn register_android_hook(
1178 browser_session: &MobileBrowserSessionHandle,
1179 page_session: &MobilePageSessionHandle,
1180 hook_type: MobileHookType,
1181) -> Result<MobileHookRegistration, String> {
1182 accessibility::validate_scope(browser_session, page_session, "hook")?;
1183 let registration_id = accessibility::unique_id("android-hook");
1184 let device_id = browser_session.device.device_id.clone();
1185 let state = match hook_type {
1186 MobileHookType::FileChooser => AndroidHookState::FileChooser {
1187 device_id: device_id.clone(),
1188 source_package: current_foreground_app(&device_id)?.current_package,
1189 },
1190 MobileHookType::Download => AndroidHookState::Download {
1191 device_id: device_id.clone(),
1192 existing_files: list_public_downloads(&device_id)?,
1193 },
1194 };
1195 android_hooks()
1196 .lock()
1197 .map_err(|_| "Android hook registry is unavailable".to_string())?
1198 .insert(registration_id.clone(), state);
1199 Ok(MobileHookRegistration {
1200 opaque_state: registration_id,
1201 })
1202}
1203
1204fn poll_android_hook(
1205 browser_session: &MobileBrowserSessionHandle,
1206 registration: &MobileHookRegistration,
1207) -> Result<MobileHookResult, String> {
1208 let registration_id = ®istration.opaque_state;
1209 let state = android_hooks()
1210 .lock()
1211 .map_err(|_| "Android hook registry is unavailable".to_string())?
1212 .get(registration_id)
1213 .cloned()
1214 .ok_or_else(|| "Android hook registration is no longer available".to_string())?;
1215 match state {
1216 AndroidHookState::FileChooser {
1217 device_id,
1218 source_package,
1219 } => {
1220 if device_id != browser_session.device.device_id {
1221 return Err("Android hook belongs to a different device".to_string());
1222 }
1223 let foreground = current_foreground_app(&device_id)?;
1224 if foreground.current_package == source_package
1225 || !is_android_picker(foreground.current_package.as_deref())
1226 {
1227 return Err("Android file chooser hook is still waiting for a picker".to_string());
1228 }
1229 let chooser_id = accessibility::unique_id("android-chooser");
1230 android_choosers()
1231 .lock()
1232 .map_err(|_| "Android chooser registry is unavailable".to_string())?
1233 .insert(chooser_id.clone(), AndroidChooserState { device_id });
1234 android_hooks()
1235 .lock()
1236 .map_err(|_| "Android hook registry is unavailable".to_string())?
1237 .remove(registration_id);
1238 Ok(MobileHookResult::FileChooser(MobileFileChooserInfo {
1239 file_chooser_id: chooser_id,
1240 is_multiple: false,
1241 note: "Android system document picker opened".to_string(),
1242 }))
1243 }
1244 AndroidHookState::Download {
1245 device_id,
1246 existing_files,
1247 } => {
1248 if device_id != browser_session.device.device_id {
1249 return Err("Android hook belongs to a different device".to_string());
1250 }
1251 let current = list_public_downloads(&device_id)?;
1252 let Some(device_path) = changed_public_download(&existing_files, ¤t) else {
1253 return Err(
1254 "Android download hook is still waiting for a public download".to_string(),
1255 );
1256 };
1257 let suggested_filename = Path::new(&device_path)
1258 .file_name()
1259 .and_then(|name| name.to_str())
1260 .unwrap_or("download")
1261 .to_string();
1262 let download_id = accessibility::unique_id("android-download");
1263 android_downloads()
1264 .lock()
1265 .map_err(|_| "Android download registry is unavailable".to_string())?
1266 .insert(
1267 download_id.clone(),
1268 AndroidDownloadState {
1269 device_id,
1270 device_path,
1271 suggested_filename: suggested_filename.clone(),
1272 observed_size: None,
1273 },
1274 );
1275 android_hooks()
1276 .lock()
1277 .map_err(|_| "Android hook registry is unavailable".to_string())?
1278 .remove(registration_id);
1279 Ok(MobileHookResult::Download(MobileDownloadInfo {
1280 download_id,
1281 suggested_filename,
1282 note: "Android public download started".to_string(),
1283 }))
1284 }
1285 }
1286}
1287
1288fn set_android_file_chooser_files(
1289 browser_session: &MobileBrowserSessionHandle,
1290 page_session: &MobilePageSessionHandle,
1291 file_chooser_id: &str,
1292 files: &[String],
1293) -> Result<MobileFileChooserFilesSetInfo, String> {
1294 accessibility::validate_scope(browser_session, page_session, "file chooser")?;
1295 if files.len() != 1 {
1296 return Err("Android file chooser currently supports exactly one file".to_string());
1297 }
1298 let chooser = android_choosers()
1299 .lock()
1300 .map_err(|_| "Android chooser registry is unavailable".to_string())?
1301 .get(file_chooser_id)
1302 .cloned()
1303 .ok_or_else(|| format!("unknown Android file chooser {file_chooser_id}"))?;
1304 if chooser.device_id != browser_session.device.device_id {
1305 return Err("Android file chooser belongs to a different device".to_string());
1306 }
1307 let source = fs::canonicalize(&files[0])
1308 .map_err(|error| format!("resolve staged upload {}: {error}", files[0]))?;
1309 let filename = source
1310 .file_name()
1311 .and_then(|name| name.to_str())
1312 .ok_or_else(|| "staged upload requires a valid file name".to_string())?;
1313 let remote_path = format!("/sdcard/Download/{filename}");
1314 run_adb_for_device(
1315 &chooser.device_id,
1316 &["push", &source.to_string_lossy(), &remote_path],
1317 )?;
1318 let _ = run_adb_for_device(
1319 &chooser.device_id,
1320 &[
1321 "shell",
1322 "am",
1323 "broadcast",
1324 "-a",
1325 "android.intent.action.MEDIA_SCANNER_SCAN_FILE",
1326 "-d",
1327 &format!("file://{remote_path}"),
1328 ],
1329 );
1330 let source_snapshot = adb_dump_source(&chooser.device_id)?;
1331 let nodes = parse_android_ui_nodes(&source_snapshot.source)?;
1332 let node = nodes
1333 .iter()
1334 .find(|node| {
1335 node.text.as_deref() == Some(filename) || node.content_desc.as_deref() == Some(filename)
1336 })
1337 .ok_or_else(|| format!("Android picker does not show staged file {filename}"))?;
1338 let (x, y) = node
1339 .bounds
1340 .ok_or_else(|| format!("Android picker file {filename} has no selectable bounds"))?
1341 .center();
1342 run_adb_for_device(
1343 &chooser.device_id,
1344 &["shell", "input", "tap", &x.to_string(), &y.to_string()],
1345 )?;
1346 android_choosers()
1347 .lock()
1348 .map_err(|_| "Android chooser registry is unavailable".to_string())?
1349 .remove(file_chooser_id);
1350 Ok(MobileFileChooserFilesSetInfo {
1351 file_chooser_id: file_chooser_id.to_string(),
1352 files: files.to_vec(),
1353 note: format!("staged and selected {filename} in the Android document picker"),
1354 })
1355}
1356
1357fn save_android_download(
1358 browser_session: &MobileBrowserSessionHandle,
1359 page_session: &MobilePageSessionHandle,
1360 download_id: &str,
1361 path: &str,
1362) -> Result<MobileDownloadSavedInfo, String> {
1363 accessibility::validate_scope(browser_session, page_session, "download")?;
1364 let mut downloads = android_downloads()
1365 .lock()
1366 .map_err(|_| "Android download registry is unavailable".to_string())?;
1367 let download = downloads
1368 .get_mut(download_id)
1369 .ok_or_else(|| format!("unknown Android download {download_id}"))?;
1370 if download.device_id != browser_session.device.device_id {
1371 return Err("Android download belongs to a different device".to_string());
1372 }
1373 let size = android_file_size(&download.device_id, &download.device_path)?;
1374 if download.observed_size != Some(size) {
1375 download.observed_size = Some(size);
1376 return Err("Android download is still in progress".to_string());
1377 }
1378 if let Some(parent) = Path::new(path).parent() {
1379 fs::create_dir_all(parent)
1380 .map_err(|error| format!("create Android download staging directory: {error}"))?;
1381 }
1382 run_adb_for_device(&download.device_id, &["pull", &download.device_path, path])?;
1383 let saved = fs::canonicalize(path)
1384 .map_err(|error| format!("resolve pulled Android download {path}: {error}"))?;
1385 let result = MobileDownloadSavedInfo {
1386 download_id: download_id.to_string(),
1387 path: saved.to_string_lossy().to_string(),
1388 suggested_filename: download.suggested_filename.clone(),
1389 size,
1390 note: "pulled completed Android download to server staging".to_string(),
1391 };
1392 downloads.remove(download_id);
1393 Ok(result)
1394}
1395
1396fn adb_click(
1397 device_id: &str,
1398 selector: &str,
1399 timeout_ms: Option<u32>,
1400) -> Result<UiAutomator2ClickInfo, String> {
1401 let snapshot = resolve_selector_snapshot(device_id, selector, timeout_ms)?;
1402 let bounds = snapshot
1403 .node
1404 .bounds
1405 .ok_or_else(|| format!("selector resolved without bounds: {}", snapshot.selector))?;
1406 let (x, y) = bounds.center();
1407 let _ = run_adb_for_device(
1408 device_id,
1409 &["shell", "input", "tap", &x.to_string(), &y.to_string()],
1410 )?;
1411 Ok(UiAutomator2ClickInfo {
1412 resolved_selector: snapshot.selector,
1413 note: format!(
1414 "clicked Android element via native ADB input on {}/{}",
1415 snapshot
1416 .foreground
1417 .current_package
1418 .as_deref()
1419 .unwrap_or("<unknown>"),
1420 snapshot
1421 .foreground
1422 .current_activity
1423 .as_deref()
1424 .unwrap_or("<unknown>")
1425 ),
1426 })
1427}
1428
1429fn adb_fill(
1430 device_id: &str,
1431 selector: &str,
1432 fill_value: &str,
1433 timeout_ms: Option<u32>,
1434) -> Result<UiAutomator2FillInfo, String> {
1435 let snapshot = resolve_selector_snapshot(device_id, selector, timeout_ms)?;
1436 let bounds = snapshot
1437 .node
1438 .bounds
1439 .ok_or_else(|| format!("selector resolved without bounds: {}", snapshot.selector))?;
1440 let (x, y) = bounds.center();
1441 let _ = run_adb_for_device(
1442 device_id,
1443 &["shell", "input", "tap", &x.to_string(), &y.to_string()],
1444 )?;
1445 let _ = run_adb_for_device(
1446 device_id,
1447 &["shell", "input", "keyevent", "KEYCODE_MOVE_END"],
1448 )?;
1449 let existing_text = snapshot.node.text.as_deref().unwrap_or("");
1450 for _ in existing_text.chars() {
1451 let _ = run_adb_for_device(device_id, &["shell", "input", "keyevent", "KEYCODE_DEL"])?;
1452 }
1453 let encoded = encode_adb_text(fill_value);
1454 if !encoded.is_empty() {
1455 let _ = run_adb_for_device(device_id, &["shell", "input", "text", &encoded])?;
1456 }
1457 Ok(UiAutomator2FillInfo {
1458 resolved_selector: snapshot.selector,
1459 value: fill_value.to_string(),
1460 note: format!(
1461 "filled Android element via native ADB input on {}/{}",
1462 snapshot
1463 .foreground
1464 .current_package
1465 .as_deref()
1466 .unwrap_or("<unknown>"),
1467 snapshot
1468 .foreground
1469 .current_activity
1470 .as_deref()
1471 .unwrap_or("<unknown>")
1472 ),
1473 })
1474}
1475
1476fn adb_count_elements(
1477 device_id: &str,
1478 selector: &str,
1479 _timeout_ms: Option<u32>,
1480) -> Result<u32, String> {
1481 let source = adb_dump_source(device_id)?;
1482 let nodes = parse_android_ui_nodes(&source.source)?;
1483 let count =
1484 accessibility::matching_source_nodes(device_id, &source, &nodes, selector)?.len() as u32;
1485 Ok(count)
1486}
1487
1488fn adb_focus(
1489 device_id: &str,
1490 selector: &str,
1491 timeout_ms: Option<u32>,
1492) -> Result<UiAutomator2ClickInfo, String> {
1493 let snapshot = resolve_selector_snapshot(device_id, selector, timeout_ms)?;
1494 let bounds = snapshot
1495 .node
1496 .bounds
1497 .ok_or_else(|| format!("selector resolved without bounds: {}", snapshot.selector))?;
1498 let (x, y) = bounds.center();
1499 let _ = run_adb_for_device(
1500 device_id,
1501 &["shell", "input", "tap", &x.to_string(), &y.to_string()],
1502 )?;
1503 Ok(UiAutomator2ClickInfo {
1504 resolved_selector: snapshot.selector,
1505 note: format!(
1506 "focused Android element via native ADB tap on {}/{}",
1507 snapshot
1508 .foreground
1509 .current_package
1510 .as_deref()
1511 .unwrap_or("<unknown>"),
1512 snapshot
1513 .foreground
1514 .current_activity
1515 .as_deref()
1516 .unwrap_or("<unknown>")
1517 ),
1518 })
1519}
1520
1521fn adb_press_key(
1522 device_id: &str,
1523 selector: &str,
1524 key: &str,
1525 text: Option<&str>,
1526 timeout_ms: Option<u32>,
1527) -> Result<String, String> {
1528 let focused = adb_focus(device_id, selector, timeout_ms)?;
1529 if let Some(text) = text.filter(|value| !value.is_empty()) {
1530 let encoded = encode_adb_text(text);
1531 if !encoded.is_empty() {
1532 let _ = run_adb_for_device(device_id, &["shell", "input", "text", &encoded])?;
1533 }
1534 return Ok(format!("{} and inserted Android text input", focused.note));
1535 }
1536
1537 if let Some(keyevent) = android_keyevent_for(key) {
1538 let _ = run_adb_for_device(device_id, &["shell", "input", "keyevent", keyevent])?;
1539 return Ok(format!("{} and pressed Android key `{key}`", focused.note));
1540 }
1541
1542 if key.chars().count() == 1 {
1543 let encoded = encode_adb_text(key);
1544 if !encoded.is_empty() {
1545 let _ = run_adb_for_device(device_id, &["shell", "input", "text", &encoded])?;
1546 }
1547 return Ok(format!(
1548 "{} and inserted Android character input `{key}`",
1549 focused.note
1550 ));
1551 }
1552
1553 Err(format!(
1554 "unsupported Android key `{key}`; provide a single character, `text`, or a supported special key"
1555 ))
1556}
1557
1558fn adb_get_text(
1559 device_id: &str,
1560 selector: &str,
1561 timeout_ms: Option<u32>,
1562) -> Result<String, String> {
1563 let snapshot = resolve_selector_snapshot(device_id, selector, timeout_ms)?;
1564 Ok(snapshot
1565 .node
1566 .text
1567 .clone()
1568 .or_else(|| snapshot.node.content_desc.clone())
1569 .unwrap_or_default())
1570}
1571
1572fn capture_element(
1573 browser_session: &MobileBrowserSessionHandle,
1574 page_session: &MobilePageSessionHandle,
1575 kind: &str,
1576 selector: &str,
1577 attribute_name: &str,
1578 timeout_ms: Option<u32>,
1579) -> Result<CaptureInfo, String> {
1580 accessibility::validate_scope(browser_session, page_session, "capture")?;
1581 let snapshot =
1582 resolve_selector_snapshot(&browser_session.device.device_id, selector, timeout_ms)?;
1583 let node = snapshot.node;
1584 let mut result = CaptureInfo::default();
1585 match kind {
1586 "input_value" => result.value = Some(node.text.unwrap_or_default()),
1587 "checked" => result.checked = Some(node.checked.unwrap_or(false)),
1588 "attribute" => {
1589 result.value = android_node_attribute(&node, attribute_name);
1590 }
1591 "bounding_box" => {
1592 result.bounding_box = node.bounds.and_then(|bounds| {
1593 let width = bounds.right - bounds.left;
1594 let height = bounds.bottom - bounds.top;
1595 (width > 0 && height > 0).then_some(BoundingBox {
1596 x: f64::from(bounds.left),
1597 y: f64::from(bounds.top),
1598 width: f64::from(width),
1599 height: f64::from(height),
1600 })
1601 });
1602 }
1603 _ => {
1604 return Err(format!(
1605 "capture kind `{kind}` is not supported for Android native apps"
1606 ));
1607 }
1608 }
1609 Ok(result)
1610}
1611
1612fn android_node_attribute(node: &AndroidUiNode, name: &str) -> Option<String> {
1613 let bool_string = |value: Option<bool>| value.map(|value| value.to_string());
1614 match name {
1615 "text" | "value" => node.text.clone(),
1616 "content-desc" | "contentDescription" | "description" => node.content_desc.clone(),
1617 "resource-id" | "resourceId" | "id" => node.resource_id.clone(),
1618 "class" | "className" => node.class_name.clone(),
1619 "package" | "packageName" => node.package_name.clone(),
1620 "checkable" => bool_string(node.checkable),
1621 "checked" => bool_string(node.checked),
1622 "clickable" => bool_string(node.clickable),
1623 "long-clickable" | "longClickable" => bool_string(node.long_clickable),
1624 "scrollable" => bool_string(node.scrollable),
1625 "enabled" => bool_string(node.enabled),
1626 "focusable" => bool_string(node.focusable),
1627 "focused" => bool_string(node.focused),
1628 "editable" => Some(
1629 (node.enabled.unwrap_or(true)
1630 && node
1631 .class_name
1632 .as_deref()
1633 .is_some_and(|class_name| class_name.ends_with("EditText")))
1634 .to_string(),
1635 ),
1636 "selected" => bool_string(node.selected),
1637 "password" => bool_string(node.password),
1638 "index" => node.index.map(|value| value.to_string()),
1639 "bounds" => node.bounds.map(|bounds| {
1640 format!(
1641 "[{},{}][{},{}]",
1642 bounds.left, bounds.top, bounds.right, bounds.bottom
1643 )
1644 }),
1645 _ => None,
1646 }
1647}
1648
1649fn adb_wait_for_selector(
1650 device_id: &str,
1651 selector: &str,
1652 visible: bool,
1653 _timeout_ms: Option<u32>,
1654) -> Result<(), String> {
1655 let snapshot = resolve_selector_snapshot(device_id, selector, None)?;
1656 if visible && !node_is_visible(&snapshot.node) {
1657 return Err(format!("selector found but not visible: {selector}"));
1658 }
1659 Ok(())
1660}
1661
1662fn adb_screenshot(
1663 device_id: &str,
1664 _timeout_ms: Option<u32>,
1665) -> Result<MobileScreenshotInfo, String> {
1666 let png_data = run_adb_bytes_for_device(device_id, &["exec-out", "screencap", "-p"])?;
1667 if png_data.is_empty() {
1668 return Err("adb returned an empty screenshot payload".to_string());
1669 }
1670 let foreground = current_foreground_app(device_id)?;
1671 Ok(MobileScreenshotInfo {
1672 png_data,
1673 note: format!(
1674 "captured Android screenshot via adb exec-out screencap on {}/{}",
1675 foreground.current_package.as_deref().unwrap_or("<unknown>"),
1676 foreground
1677 .current_activity
1678 .as_deref()
1679 .unwrap_or("<unknown>")
1680 ),
1681 })
1682}
1683
1684fn adb_dump_source(device_id: &str) -> Result<UiAutomator2SourceInfo, String> {
1685 let remote_path = "/data/local/tmp/allwright-window.xml";
1686 let _ = run_adb_for_device(device_id, &["shell", "uiautomator", "dump", remote_path])?;
1687 let source = run_adb_for_device(device_id, &["shell", "cat", remote_path])?;
1688 let foreground = current_foreground_app(device_id)?;
1689 Ok(UiAutomator2SourceInfo {
1690 source,
1691 current_package: foreground.current_package,
1692 current_activity: foreground.current_activity,
1693 })
1694}
1695
1696fn screenshot(
1697 browser_session: &MobileBrowserSessionHandle,
1698 _page_session: &MobilePageSessionHandle,
1699 timeout_ms: Option<u32>,
1700 full_page: bool,
1701) -> Result<MobileScreenshotInfo, String> {
1702 if full_page {
1703 adb_full_page_screenshot(&browser_session.device.device_id, timeout_ms)
1704 } else {
1705 adb_screenshot(&browser_session.device.device_id, timeout_ms)
1706 }
1707}
1708
1709const MAX_FULL_PAGE_SCREENSHOTS: usize = 20;
1710
1711fn adb_full_page_screenshot(
1712 device_id: &str,
1713 timeout_ms: Option<u32>,
1714) -> Result<MobileScreenshotInfo, String> {
1715 let first = decode_android_screenshot(adb_screenshot(device_id, timeout_ms)?.png_data)?;
1716 let width = first.width();
1717 let height = first.height();
1718 if width == 0 || height < 4 {
1719 return Err("Android returned an invalid screenshot size".to_string());
1720 }
1721
1722 let mut previous = first.clone();
1723 let mut stitched = first;
1724 let swipe_start = (height * 3 / 4) as i32;
1725 let swipe_end = (height / 4) as i32;
1726 for _ in 1..MAX_FULL_PAGE_SCREENSHOTS {
1727 run_adb_for_device(
1728 device_id,
1729 &[
1730 "shell",
1731 "input",
1732 "swipe",
1733 &(width as i32 / 2).to_string(),
1734 &swipe_start.to_string(),
1735 &(width as i32 / 2).to_string(),
1736 &swipe_end.to_string(),
1737 "250",
1738 ],
1739 )?;
1740 std::thread::sleep(Duration::from_millis(300));
1741
1742 let next = decode_android_screenshot(adb_screenshot(device_id, timeout_ms)?.png_data)?;
1743 if next.dimensions() != (width, height) {
1744 return Err(
1745 "Android screenshot dimensions changed while capturing a full page".to_string(),
1746 );
1747 }
1748 let overlap = find_screenshot_overlap(&previous, &next)?;
1749 if overlap == height {
1750 break;
1751 }
1752 append_screenshot(&mut stitched, &next, overlap)?;
1753 previous = next;
1754 }
1755
1756 let mut png_data = Vec::new();
1757 DynamicImage::ImageRgba8(stitched)
1758 .write_to(&mut std::io::Cursor::new(&mut png_data), ImageFormat::Png)
1759 .map_err(|error| format!("encode Android full-page screenshot: {error}"))?;
1760 let foreground = current_foreground_app(device_id)?;
1761 Ok(MobileScreenshotInfo {
1762 png_data,
1763 note: format!(
1764 "captured full-page Android screenshot via ADB scrolling on {}/{}",
1765 foreground.current_package.as_deref().unwrap_or("<unknown>"),
1766 foreground
1767 .current_activity
1768 .as_deref()
1769 .unwrap_or("<unknown>")
1770 ),
1771 })
1772}
1773
1774fn decode_android_screenshot(png_data: Vec<u8>) -> Result<RgbaImage, String> {
1775 image::load_from_memory_with_format(&png_data, ImageFormat::Png)
1776 .map_err(|error| format!("decode Android screenshot: {error}"))
1777 .map(DynamicImage::into_rgba8)
1778}
1779
1780fn find_screenshot_overlap(previous: &RgbaImage, next: &RgbaImage) -> Result<u32, String> {
1781 let (width, height) = previous.dimensions();
1782 if next.dimensions() != (width, height) {
1783 return Err("cannot stitch Android screenshots with different dimensions".to_string());
1784 }
1785 for overlap in ((height / 4)..=height).rev() {
1786 if screenshots_match_at_overlap(previous, next, overlap) {
1787 return Ok(overlap);
1788 }
1789 }
1790 Err(
1791 "could not align Android screenshots after scrolling; the foreground content changed"
1792 .to_string(),
1793 )
1794}
1795
1796fn screenshots_match_at_overlap(previous: &RgbaImage, next: &RgbaImage, overlap: u32) -> bool {
1797 let (width, height) = previous.dimensions();
1798 let row_step = (overlap / 24).max(1);
1799 let column_step = (width / 24).max(1);
1800 let mut matched = 0_u32;
1801 let mut sampled = 0_u32;
1802 for row in (0..overlap).step_by(row_step as usize) {
1803 for column in (0..width).step_by(column_step as usize) {
1804 let before = previous.get_pixel(column, height - overlap + row);
1805 let after = next.get_pixel(column, row);
1806 sampled += 1;
1807 if before == after {
1808 matched += 1;
1809 }
1810 }
1811 }
1812 sampled > 0 && matched * 100 >= sampled * 98
1813}
1814
1815fn append_screenshot(
1816 stitched: &mut RgbaImage,
1817 next: &RgbaImage,
1818 overlap: u32,
1819) -> Result<(), String> {
1820 let (width, height) = stitched.dimensions();
1821 let appended_height = next.height() - overlap;
1822 let combined_height = height
1823 .checked_add(appended_height)
1824 .ok_or_else(|| "Android full-page screenshot is too tall".to_string())?;
1825 let mut combined = RgbaImage::new(width, combined_height);
1826 image::imageops::replace(&mut combined, stitched, 0, 0);
1827 image::imageops::replace(&mut combined, next, 0, i64::from(height - overlap));
1828 *stitched = combined;
1829 Ok(())
1830}
1831
1832struct ResolvedSelectorSnapshot {
1833 selector: String,
1834 node: AndroidUiNode,
1835 foreground: ForegroundAppInfo,
1836}
1837
1838fn resolve_selector_snapshot(
1839 device_id: &str,
1840 selector: &str,
1841 _timeout_ms: Option<u32>,
1842) -> Result<ResolvedSelectorSnapshot, String> {
1843 let source = adb_dump_source(device_id)?;
1844 let nodes = parse_android_ui_nodes(&source.source)?;
1845 let node = accessibility::matching_source_nodes(device_id, &source, &nodes, selector)?
1846 .into_iter()
1847 .next()
1848 .ok_or_else(|| {
1849 let package_name = source.current_package.as_deref().unwrap_or("<unknown>");
1850 let activity_name = source.current_activity.as_deref().unwrap_or("<unknown>");
1851 format!("selector not found: {selector} (current app: {package_name}/{activity_name})")
1852 })?;
1853 Ok(ResolvedSelectorSnapshot {
1854 selector: selector.to_string(),
1855 node: node.clone(),
1856 foreground: ForegroundAppInfo {
1857 current_package: source.current_package,
1858 current_activity: source.current_activity,
1859 },
1860 })
1861}
1862
1863fn parse_foreground_app_from_dumpsys(output: &str) -> ForegroundAppInfo {
1864 for line in output.lines() {
1865 if !(line.contains("mCurrentFocus") || line.contains("mFocusedApp")) {
1866 continue;
1867 }
1868 if let Some((package_name, activity_name)) = extract_component_from_line(line) {
1869 return ForegroundAppInfo {
1870 current_package: Some(package_name),
1871 current_activity: Some(activity_name),
1872 };
1873 }
1874 }
1875 ForegroundAppInfo {
1876 current_package: None,
1877 current_activity: None,
1878 }
1879}
1880
1881fn extract_component_from_line(line: &str) -> Option<(String, String)> {
1882 for token in line.split_whitespace() {
1883 let cleaned = token.trim_matches(|ch: char| matches!(ch, '{' | '}' | ',' | ';'));
1884 let Some(slash_index) = cleaned.find('/') else {
1885 continue;
1886 };
1887 let package_name = cleaned[..slash_index].trim();
1888 let activity_name = cleaned[slash_index + 1..].trim();
1889 if package_name.is_empty()
1890 || activity_name.is_empty()
1891 || !package_name.contains('.')
1892 || package_name.contains('=')
1893 {
1894 continue;
1895 }
1896 return Some((package_name.to_string(), activity_name.to_string()));
1897 }
1898 None
1899}
1900
1901fn parse_android_ui_nodes(xml: &str) -> Result<Vec<AndroidUiNode>, String> {
1902 let mut nodes = Vec::new();
1903 let mut stack: Vec<usize> = Vec::new();
1904 let mut cursor = 0usize;
1905 while let Some(relative_start) = xml[cursor..].find('<') {
1906 let start = cursor + relative_start;
1907 let Some(relative_end) = xml[start..].find('>') else {
1908 break;
1909 };
1910 let end = start + relative_end;
1911 let tag = &xml[start + 1..end];
1912 let trimmed = tag.trim();
1913 if trimmed.starts_with("/node") {
1914 let _ = stack.pop();
1915 } else if trimmed.starts_with("node") {
1916 let self_closing = trimmed.ends_with('/');
1917 let attributes = parse_xml_attributes(trimmed);
1918 let node = AndroidUiNode {
1919 class_name: attributes.get("class").cloned(),
1920 resource_id: attributes.get("resource-id").cloned(),
1921 package_name: attributes.get("package").cloned(),
1922 text: attributes.get("text").cloned(),
1923 content_desc: attributes.get("content-desc").cloned(),
1924 checkable: attributes
1925 .get("checkable")
1926 .and_then(|value| parse_bool(value)),
1927 checked: attributes
1928 .get("checked")
1929 .and_then(|value| parse_bool(value)),
1930 clickable: attributes
1931 .get("clickable")
1932 .and_then(|value| parse_bool(value)),
1933 long_clickable: attributes
1934 .get("long-clickable")
1935 .and_then(|value| parse_bool(value)),
1936 scrollable: attributes
1937 .get("scrollable")
1938 .and_then(|value| parse_bool(value)),
1939 enabled: attributes
1940 .get("enabled")
1941 .and_then(|value| parse_bool(value)),
1942 focusable: attributes
1943 .get("focusable")
1944 .and_then(|value| parse_bool(value)),
1945 focused: attributes
1946 .get("focused")
1947 .and_then(|value| parse_bool(value)),
1948 selected: attributes
1949 .get("selected")
1950 .and_then(|value| parse_bool(value)),
1951 password: attributes
1952 .get("password")
1953 .and_then(|value| parse_bool(value)),
1954 index: attributes.get("index").and_then(|value| value.parse().ok()),
1955 bounds: attributes
1956 .get("bounds")
1957 .and_then(|value| parse_bounds(value)),
1958 parent_index: stack.last().copied(),
1959 };
1960 let index = nodes.len();
1961 nodes.push(node);
1962 if !self_closing {
1963 stack.push(index);
1964 }
1965 }
1966 cursor = end + 1;
1967 }
1968 Ok(nodes)
1969}
1970
1971fn parse_xml_attributes(tag: &str) -> std::collections::BTreeMap<String, String> {
1972 let mut attributes = std::collections::BTreeMap::new();
1973 let bytes = tag.as_bytes();
1974 let mut index = 0usize;
1975 while index < bytes.len() {
1976 while index < bytes.len() && !bytes[index].is_ascii_alphabetic() {
1977 index += 1;
1978 }
1979 let key_start = index;
1980 while index < bytes.len()
1981 && (bytes[index].is_ascii_alphanumeric() || matches!(bytes[index], b'-' | b'_'))
1982 {
1983 index += 1;
1984 }
1985 if key_start == index {
1986 continue;
1987 }
1988 let key = &tag[key_start..index];
1989 while index < bytes.len() && bytes[index].is_ascii_whitespace() {
1990 index += 1;
1991 }
1992 if index >= bytes.len() || bytes[index] != b'=' {
1993 continue;
1994 }
1995 index += 1;
1996 while index < bytes.len() && bytes[index].is_ascii_whitespace() {
1997 index += 1;
1998 }
1999 if index >= bytes.len() || bytes[index] != b'"' {
2000 continue;
2001 }
2002 index += 1;
2003 let value_start = index;
2004 while index < bytes.len() && bytes[index] != b'"' {
2005 index += 1;
2006 }
2007 if index > value_start {
2008 attributes.insert(
2009 key.to_string(),
2010 decode_xml_entities(&tag[value_start..index]),
2011 );
2012 }
2013 if index < bytes.len() {
2014 index += 1;
2015 }
2016 }
2017 attributes
2018}
2019
2020fn decode_xml_entities(value: &str) -> String {
2021 value
2022 .replace(""", "\"")
2023 .replace("'", "'")
2024 .replace("<", "<")
2025 .replace(">", ">")
2026 .replace("&", "&")
2027}
2028
2029fn parse_bool(value: &str) -> Option<bool> {
2030 match value.trim().to_ascii_lowercase().as_str() {
2031 "true" => Some(true),
2032 "false" => Some(false),
2033 _ => None,
2034 }
2035}
2036
2037fn parse_bounds(value: &str) -> Option<AndroidBounds> {
2038 let trimmed = value.trim();
2039 let trimmed = trimmed.strip_prefix('[')?;
2040 let (left_top, right_bottom) = trimmed.split_once("][")?;
2041 let right_bottom = right_bottom.strip_suffix(']')?;
2042 let (left, top) = left_top.split_once(',')?;
2043 let (right, bottom) = right_bottom.split_once(',')?;
2044 Some(AndroidBounds {
2045 left: left.parse().ok()?,
2046 top: top.parse().ok()?,
2047 right: right.parse().ok()?,
2048 bottom: bottom.parse().ok()?,
2049 })
2050}
2051
2052fn matching_nodes_by_selector<'a>(
2053 nodes: &'a [AndroidUiNode],
2054 selector: &str,
2055) -> Result<Vec<&'a AndroidUiNode>, String> {
2056 let segments = parse_selector_segments(selector)?;
2057 if segments.len() == 1 && segments[0].value.starts_with("/hierarchy/") {
2058 return accessibility::resolve_absolute_path(nodes, &segments[0].value)
2059 .map(|index| index.map(|i| vec![&nodes[i]]).unwrap_or_default());
2060 }
2061 let criteria = segments
2062 .iter()
2063 .map(selector_segment_to_criteria)
2064 .collect::<Result<Vec<_>, _>>()?;
2065 let Some(last) = criteria.last() else {
2066 return Ok(Vec::new());
2067 };
2068 let mut matches = Vec::new();
2069 for (index, node) in nodes.iter().enumerate() {
2070 if !node_matches_criteria(node, last) {
2071 continue;
2072 }
2073 if ancestor_chain_matches(nodes, index, &criteria[..criteria.len().saturating_sub(1)]) {
2074 matches.push(node);
2075 }
2076 }
2077 if let Some(instance) = last.instance {
2078 return Ok(matches.get(instance).copied().into_iter().collect());
2079 }
2080 Ok(matches)
2081}
2082
2083#[cfg(test)]
2084fn find_node_by_selector<'a>(
2085 nodes: &'a [AndroidUiNode],
2086 selector: &str,
2087) -> Result<&'a AndroidUiNode, String> {
2088 matching_nodes_by_selector(nodes, selector)?
2089 .into_iter()
2090 .next()
2091 .ok_or_else(|| format!("no Android element matched selector {selector:?}"))
2092}
2093
2094fn node_is_visible(node: &AndroidUiNode) -> bool {
2095 if node.enabled == Some(false) {
2096 return false;
2097 }
2098 let Some(bounds) = node.bounds else {
2099 return false;
2100 };
2101 bounds.right > bounds.left && bounds.bottom > bounds.top
2102}
2103
2104fn android_keyevent_for(key: &str) -> Option<&'static str> {
2105 match key.trim().to_ascii_lowercase().as_str() {
2106 "enter" => Some("KEYCODE_ENTER"),
2107 "tab" => Some("KEYCODE_TAB"),
2108 "backspace" | "delete" => Some("KEYCODE_DEL"),
2109 "escape" | "esc" => Some("KEYCODE_ESCAPE"),
2110 "space" => Some("KEYCODE_SPACE"),
2111 "back" => Some("KEYCODE_BACK"),
2112 "home" => Some("KEYCODE_HOME"),
2113 "arrowleft" | "left" => Some("KEYCODE_DPAD_LEFT"),
2114 "arrowright" | "right" => Some("KEYCODE_DPAD_RIGHT"),
2115 "arrowup" | "up" => Some("KEYCODE_DPAD_UP"),
2116 "arrowdown" | "down" => Some("KEYCODE_DPAD_DOWN"),
2117 "center" => Some("KEYCODE_DPAD_CENTER"),
2118 _ => None,
2119 }
2120}
2121
2122fn ancestor_chain_matches(
2123 nodes: &[AndroidUiNode],
2124 node_index: usize,
2125 criteria_chain: &[NodeCriteria],
2126) -> bool {
2127 if criteria_chain.is_empty() {
2128 return true;
2129 }
2130 let mut current_parent = nodes[node_index].parent_index;
2131 for criteria in criteria_chain.iter().rev() {
2132 let mut matched_parent = None;
2133 let mut probe = current_parent;
2134 while let Some(index) = probe {
2135 if node_matches_criteria(&nodes[index], criteria) {
2136 matched_parent = Some(index);
2137 break;
2138 }
2139 probe = nodes[index].parent_index;
2140 }
2141 let Some(index) = matched_parent else {
2142 return false;
2143 };
2144 current_parent = nodes[index].parent_index;
2145 }
2146 true
2147}
2148
2149fn node_matches_criteria(node: &AndroidUiNode, criteria: &NodeCriteria) -> bool {
2150 if let Some(role) = criteria.semantic_role.as_deref()
2151 && android_semantic_role(node) != role
2152 {
2153 return false;
2154 }
2155 if let Some(matcher) = criteria.semantic_name.as_ref()
2156 && !semantic_matches_any(
2157 matcher,
2158 [node.content_desc.as_deref(), node.text.as_deref()],
2159 )
2160 {
2161 return false;
2162 }
2163 if let Some(matcher) = criteria.semantic_text.as_ref()
2164 && !semantic_text_matches(node.text.as_deref(), matcher)
2165 {
2166 return false;
2167 }
2168 if let Some(matcher) = criteria.semantic_label.as_ref()
2169 && !semantic_matches_any(
2170 matcher,
2171 [node.content_desc.as_deref(), node.text.as_deref()],
2172 )
2173 {
2174 return false;
2175 }
2176 if let Some(matcher) = criteria.semantic_test_id.as_ref()
2177 && !semantic_matches_any(
2178 matcher,
2179 [
2180 node.resource_id.as_deref(),
2181 node.resource_id
2182 .as_deref()
2183 .and_then(|value| value.rsplit('/').next()),
2184 ],
2185 )
2186 {
2187 return false;
2188 }
2189 if let Some(disabled) = criteria.semantic_disabled
2190 && node.enabled != Some(!disabled)
2191 {
2192 return false;
2193 }
2194 if let Some(expected) = criteria.class_name.as_deref()
2195 && node.class_name.as_deref() != Some(expected)
2196 {
2197 return false;
2198 }
2199 if let Some(pattern) = criteria.class_name_matches.as_deref()
2200 && !matches_regex(node.class_name.as_deref(), pattern)
2201 {
2202 return false;
2203 }
2204 if let Some(expected) = criteria.resource_id_exact.as_deref()
2205 && node.resource_id.as_deref() != Some(expected)
2206 {
2207 return false;
2208 }
2209 if let Some(expected_suffix) = criteria.resource_id_suffix.as_deref() {
2210 let Some(resource_id) = node.resource_id.as_deref() else {
2211 return false;
2212 };
2213 if !resource_id_matches_suffix(resource_id, expected_suffix) {
2214 return false;
2215 }
2216 }
2217 if let Some(pattern) = criteria.resource_id_matches.as_deref()
2218 && !matches_regex(node.resource_id.as_deref(), pattern)
2219 {
2220 return false;
2221 }
2222 if let Some(expected) = criteria.package_name.as_deref()
2223 && node.package_name.as_deref() != Some(expected)
2224 {
2225 return false;
2226 }
2227 if let Some(pattern) = criteria.package_name_matches.as_deref()
2228 && !matches_regex(node.package_name.as_deref(), pattern)
2229 {
2230 return false;
2231 }
2232 if let Some(expected) = criteria.text.as_deref()
2233 && node.text.as_deref() != Some(expected)
2234 {
2235 return false;
2236 }
2237 if let Some(expected) = criteria.text_contains.as_deref()
2238 && !contains_text(node.text.as_deref(), expected)
2239 {
2240 return false;
2241 }
2242 if let Some(expected) = criteria.text_starts_with.as_deref()
2243 && !starts_with_text(node.text.as_deref(), expected)
2244 {
2245 return false;
2246 }
2247 if let Some(pattern) = criteria.text_matches.as_deref()
2248 && !matches_regex(node.text.as_deref(), pattern)
2249 {
2250 return false;
2251 }
2252 if let Some(expected) = criteria.content_desc.as_deref()
2253 && node.content_desc.as_deref() != Some(expected)
2254 {
2255 return false;
2256 }
2257 if let Some(expected) = criteria.content_desc_contains.as_deref()
2258 && !contains_text(node.content_desc.as_deref(), expected)
2259 {
2260 return false;
2261 }
2262 if let Some(expected) = criteria.content_desc_starts_with.as_deref()
2263 && !starts_with_text(node.content_desc.as_deref(), expected)
2264 {
2265 return false;
2266 }
2267 if let Some(pattern) = criteria.content_desc_matches.as_deref()
2268 && !matches_regex(node.content_desc.as_deref(), pattern)
2269 {
2270 return false;
2271 }
2272 if let Some(expected) = criteria.checkable
2273 && node.checkable != Some(expected)
2274 {
2275 return false;
2276 }
2277 if let Some(expected) = criteria.checked
2278 && node.checked != Some(expected)
2279 {
2280 return false;
2281 }
2282 if let Some(expected) = criteria.clickable
2283 && node.clickable != Some(expected)
2284 {
2285 return false;
2286 }
2287 if let Some(expected) = criteria.long_clickable
2288 && node.long_clickable != Some(expected)
2289 {
2290 return false;
2291 }
2292 if let Some(expected) = criteria.scrollable
2293 && node.scrollable != Some(expected)
2294 {
2295 return false;
2296 }
2297 if let Some(expected) = criteria.enabled
2298 && node.enabled != Some(expected)
2299 {
2300 return false;
2301 }
2302 if let Some(expected) = criteria.focusable
2303 && node.focusable != Some(expected)
2304 {
2305 return false;
2306 }
2307 if let Some(expected) = criteria.focused
2308 && node.focused != Some(expected)
2309 {
2310 return false;
2311 }
2312 if let Some(expected) = criteria.selected
2313 && node.selected != Some(expected)
2314 {
2315 return false;
2316 }
2317 if let Some(expected) = criteria.index
2318 && node.index != Some(expected)
2319 {
2320 return false;
2321 }
2322 true
2323}
2324
2325fn android_semantic_role(node: &AndroidUiNode) -> &'static str {
2326 match node
2327 .class_name
2328 .as_deref()
2329 .unwrap_or("")
2330 .rsplit('.')
2331 .next()
2332 .unwrap_or("")
2333 {
2334 "Button" | "ImageButton" => "button",
2335 "EditText" => "textbox",
2336 "CheckBox" => "checkbox",
2337 "RadioButton" => "radio",
2338 "Switch" | "SwitchCompat" | "ToggleButton" => "switch",
2339 "SeekBar" => "slider",
2340 "ProgressBar" => "progressbar",
2341 "Spinner" => "combobox",
2342 "ListView" | "RecyclerView" => "list",
2343 "ImageView" => "img",
2344 "TextView" => "text",
2345 _ => "generic",
2346 }
2347}
2348
2349fn semantic_matches_any<'a>(
2350 matcher: &SemanticTextMatcher,
2351 values: impl IntoIterator<Item = Option<&'a str>>,
2352) -> bool {
2353 values
2354 .into_iter()
2355 .any(|value| semantic_text_matches(value, matcher))
2356}
2357
2358fn semantic_text_matches(actual: Option<&str>, matcher: &SemanticTextMatcher) -> bool {
2359 let Some(actual) = actual else { return false };
2360 if matcher.regex {
2361 let flags: String = matcher
2362 .flags
2363 .chars()
2364 .filter(|flag| matches!(flag, 'i' | 'm' | 's'))
2365 .collect();
2366 let pattern = if flags.is_empty() {
2367 matcher.value.clone()
2368 } else {
2369 format!("(?{flags}:{})", matcher.value)
2370 };
2371 return Regex::new(&pattern).is_ok_and(|regex| regex.is_match(actual));
2372 }
2373 if matcher.exact {
2374 actual == matcher.value
2375 } else {
2376 actual
2377 .to_lowercase()
2378 .contains(&matcher.value.to_lowercase())
2379 }
2380}
2381
2382fn parse_selector_segments(selector: &str) -> Result<Vec<SelectorSegment>, String> {
2383 let mut segments = Vec::new();
2384 let normalized = normalize_selector_for_transport(selector);
2385 let trimmed = normalized.trim();
2386 let mut index = 0usize;
2387 while index < trimmed.len() {
2388 let remainder = &trimmed[index..];
2389 let (flavor, prefix_len) = if remainder.starts_with("css=") {
2390 (SelectorFlavorInternal::Css, 4usize)
2391 } else if remainder.starts_with("xpath=") {
2392 (SelectorFlavorInternal::XPath, 6usize)
2393 } else if remainder.starts_with("uia=") {
2394 (SelectorFlavorInternal::UiAutomator, 4usize)
2395 } else if remainder.starts_with("aw=") {
2396 (SelectorFlavorInternal::Semantic, 3usize)
2397 } else {
2398 return Err(format!("unsupported selector segment in `{trimmed}`"));
2399 };
2400 index += prefix_len;
2401 let bytes = trimmed.as_bytes();
2402 if bytes.get(index).copied() != Some(b'"') {
2403 return Err(format!(
2404 "selector segment must use JSON string syntax: `{trimmed}`"
2405 ));
2406 }
2407 let start = index;
2408 index += 1;
2409 let mut escaped = false;
2410 while index < trimmed.len() {
2411 let byte = bytes[index];
2412 if escaped {
2413 escaped = false;
2414 } else if byte == b'\\' {
2415 escaped = true;
2416 } else if byte == b'"' {
2417 index += 1;
2418 break;
2419 }
2420 index += 1;
2421 }
2422 let json_body = &trimmed[start..index];
2423 let value = serde_json::from_str::<String>(json_body)
2424 .map_err(|error| format!("failed to decode selector segment {json_body}: {error}"))?;
2425 segments.push(SelectorSegment { flavor, value });
2426 while index < trimmed.len() && trimmed.as_bytes()[index].is_ascii_whitespace() {
2427 index += 1;
2428 }
2429 }
2430 if segments.is_empty() {
2431 return Err("selector must not be empty".to_string());
2432 }
2433 Ok(segments)
2434}
2435
2436fn selector_segment_to_criteria(segment: &SelectorSegment) -> Result<NodeCriteria, String> {
2437 match segment.flavor {
2438 SelectorFlavorInternal::Css => parse_css_criteria(&segment.value),
2439 SelectorFlavorInternal::XPath => parse_xpath_criteria(&segment.value),
2440 SelectorFlavorInternal::UiAutomator => parse_uiautomator_criteria(&segment.value),
2441 SelectorFlavorInternal::Semantic => parse_semantic_criteria(&segment.value),
2442 }
2443}
2444
2445fn parse_semantic_criteria(selector: &str) -> Result<NodeCriteria, String> {
2446 let spec: serde_json::Value = serde_json::from_str(selector)
2447 .map_err(|error| format!("invalid semantic mobile selector: {error}"))?;
2448 let kind = spec["kind"]
2449 .as_str()
2450 .ok_or("semantic mobile selector requires `kind`")?;
2451 let exact = spec["exact"].as_bool().unwrap_or(false);
2452 let matcher = |key: &str| parse_semantic_matcher(&spec[key], exact);
2453 let mut criteria = NodeCriteria::default();
2454 match kind {
2455 "role" => {
2456 criteria.semantic_role = Some(
2457 spec["role"]
2458 .as_str()
2459 .ok_or("getByRole requires `role`")?
2460 .to_ascii_lowercase(),
2461 );
2462 if !spec["name"].is_null() {
2463 criteria.semantic_name = Some(matcher("name")?);
2464 }
2465 criteria.checked = spec["checked"].as_bool();
2466 criteria.selected = spec["selected"].as_bool();
2467 criteria.semantic_disabled = spec["disabled"].as_bool();
2468 }
2469 "text" => criteria.semantic_text = Some(matcher("text")?),
2470 "label" => criteria.semantic_label = Some(matcher("text")?),
2471 "testId" => criteria.semantic_test_id = Some(parse_semantic_matcher(&spec["text"], true)?),
2472 other => {
2473 return Err(format!(
2474 "unsupported semantic mobile selector kind `{other}`"
2475 ));
2476 }
2477 }
2478 Ok(criteria)
2479}
2480
2481fn parse_semantic_matcher(
2482 value: &serde_json::Value,
2483 exact: bool,
2484) -> Result<SemanticTextMatcher, String> {
2485 if let Some(value) = value.as_str() {
2486 return Ok(SemanticTextMatcher {
2487 value: value.to_string(),
2488 regex: false,
2489 flags: String::new(),
2490 exact,
2491 });
2492 }
2493 let pattern = value["regex"]
2494 .as_str()
2495 .ok_or("semantic text matcher must be a string or regular expression")?;
2496 let flags = value["flags"].as_str().unwrap_or("");
2497 Ok(SemanticTextMatcher {
2498 value: pattern.to_string(),
2499 regex: true,
2500 flags: flags.to_string(),
2501 exact: false,
2502 })
2503}
2504
2505fn parse_uiautomator_criteria(selector: &str) -> Result<NodeCriteria, String> {
2506 let mut criteria = NodeCriteria::default();
2507 for token in split_uiautomator_tokens(selector) {
2508 let (raw_key, raw_value) = split_uiautomator_token(&token)?;
2509 let key = raw_key.to_ascii_lowercase();
2510 match key.as_str() {
2511 "text" => criteria.text = Some(raw_value.to_string()),
2512 "textcontains" => criteria.text_contains = Some(raw_value.to_string()),
2513 "textstartswith" => criteria.text_starts_with = Some(raw_value.to_string()),
2514 "textmatches" => criteria.text_matches = Some(raw_value.to_string()),
2515 "classname" => criteria.class_name = Some(raw_value.to_string()),
2516 "classnamematches" => criteria.class_name_matches = Some(raw_value.to_string()),
2517 "description" | "desc" => criteria.content_desc = Some(raw_value.to_string()),
2518 "descriptioncontains" | "desccontains" => {
2519 criteria.content_desc_contains = Some(raw_value.to_string())
2520 }
2521 "descriptionstartswith" | "descstartswith" => {
2522 criteria.content_desc_starts_with = Some(raw_value.to_string())
2523 }
2524 "descriptionmatches" | "descmatches" => {
2525 criteria.content_desc_matches = Some(raw_value.to_string())
2526 }
2527 "packagename" | "package" => criteria.package_name = Some(raw_value.to_string()),
2528 "packagenamematches" => criteria.package_name_matches = Some(raw_value.to_string()),
2529 "resourceid" => criteria.resource_id_exact = Some(raw_value.to_string()),
2530 "resourceidmatches" => criteria.resource_id_matches = Some(raw_value.to_string()),
2531 "checkable" => criteria.checkable = Some(parse_selector_bool(raw_value, &token)?),
2532 "checked" => criteria.checked = Some(parse_selector_bool(raw_value, &token)?),
2533 "clickable" => criteria.clickable = Some(parse_selector_bool(raw_value, &token)?),
2534 "longclickable" => {
2535 criteria.long_clickable = Some(parse_selector_bool(raw_value, &token)?)
2536 }
2537 "scrollable" => criteria.scrollable = Some(parse_selector_bool(raw_value, &token)?),
2538 "enabled" => criteria.enabled = Some(parse_selector_bool(raw_value, &token)?),
2539 "focusable" => criteria.focusable = Some(parse_selector_bool(raw_value, &token)?),
2540 "focused" => criteria.focused = Some(parse_selector_bool(raw_value, &token)?),
2541 "selected" => criteria.selected = Some(parse_selector_bool(raw_value, &token)?),
2542 "index" => criteria.index = Some(parse_selector_usize(raw_value, &token)?),
2543 "instance" => criteria.instance = Some(parse_selector_usize(raw_value, &token)?),
2544 other => return Err(format!("unsupported UiAutomator selector key `{other}`")),
2545 }
2546 }
2547 Ok(criteria)
2548}
2549
2550fn split_uiautomator_tokens(selector: &str) -> Vec<String> {
2551 let mut tokens = Vec::new();
2552 let mut current = String::new();
2553 let mut in_single = false;
2554 let mut in_double = false;
2555 for ch in selector.chars() {
2556 match ch {
2557 '\'' if !in_double => {
2558 in_single = !in_single;
2559 current.push(ch);
2560 }
2561 '"' if !in_single => {
2562 in_double = !in_double;
2563 current.push(ch);
2564 }
2565 ',' | ';' if !in_single && !in_double => {
2566 let trimmed = current.trim();
2567 if !trimmed.is_empty() {
2568 tokens.push(trimmed.to_string());
2569 }
2570 current.clear();
2571 }
2572 _ => current.push(ch),
2573 }
2574 }
2575 let trimmed = current.trim();
2576 if !trimmed.is_empty() {
2577 tokens.push(trimmed.to_string());
2578 }
2579 tokens
2580}
2581
2582fn split_uiautomator_token(token: &str) -> Result<(&str, &str), String> {
2583 let Some((key, value)) = token.split_once(['=', ':']) else {
2584 return Err(format!("invalid UiAutomator selector token `{token}`"));
2585 };
2586 Ok((key.trim(), value.trim()))
2587}
2588
2589fn parse_selector_bool(value: &str, token: &str) -> Result<bool, String> {
2590 parse_bool(value).ok_or_else(|| format!("invalid boolean value in `{token}`"))
2591}
2592
2593fn parse_selector_usize(value: &str, token: &str) -> Result<usize, String> {
2594 value
2595 .parse()
2596 .map_err(|_| format!("invalid integer value in `{token}`"))
2597}
2598
2599fn parse_css_criteria(selector: &str) -> Result<NodeCriteria, String> {
2600 let mut criteria = NodeCriteria::default();
2601 let mut remainder = selector.trim();
2602 while let Some(start) = remainder.find('[') {
2603 let end = remainder[start + 1..]
2604 .find(']')
2605 .map(|index| start + 1 + index)
2606 .ok_or_else(|| format!("unterminated CSS attribute selector in `{selector}`"))?;
2607 let attribute = &remainder[start + 1..end];
2608 let (name, value) = parse_attribute_filter(attribute)?;
2609 match name {
2610 "text" => criteria.text = Some(value),
2611 "content-desc" => criteria.content_desc = Some(value),
2612 "resource-id" => criteria.resource_id_exact = Some(value),
2613 _ => {
2614 return Err(format!(
2615 "unsupported CSS attribute selector `{name}` in `{selector}`"
2616 ));
2617 }
2618 }
2619 let mut next = String::with_capacity(remainder.len());
2620 next.push_str(remainder[..start].trim_end());
2621 next.push(' ');
2622 next.push_str(remainder[end + 1..].trim_start());
2623 let owned = next.trim().to_string();
2624 remainder = Box::leak(owned.into_boxed_str());
2625 }
2626
2627 let class_or_id = remainder.trim();
2628 if let Some(resource_id) = class_or_id.strip_prefix('#') {
2629 if resource_id.contains(':') {
2630 criteria.resource_id_exact = Some(resource_id.to_string());
2631 } else {
2632 criteria.resource_id_suffix = Some(format!(":id/{resource_id}"));
2633 }
2634 } else if let Some(class_name) = class_or_id.strip_prefix('.') {
2635 if !class_name.is_empty() {
2636 criteria.class_name = Some(class_name.to_string());
2637 }
2638 } else if !class_or_id.is_empty() {
2639 criteria.class_name = Some(class_or_id.to_string());
2640 }
2641 Ok(criteria)
2642}
2643
2644fn parse_xpath_criteria(selector: &str) -> Result<NodeCriteria, String> {
2645 let trimmed = selector.trim();
2646 let mut criteria = NodeCriteria::default();
2647 let mut path = trimmed;
2648 if let Some(stripped) = path.strip_prefix(".//") {
2649 path = stripped;
2650 } else if let Some(stripped) = path.strip_prefix("//") {
2651 path = stripped;
2652 }
2653
2654 let path = path.trim();
2655 let (node_pattern, predicates) = if let Some(start) = path.find('[') {
2656 let end = path
2657 .rfind(']')
2658 .ok_or_else(|| format!("unterminated XPath in `{selector}`"))?;
2659 (&path[..start], Some(&path[start + 1..end]))
2660 } else {
2661 (path, None)
2662 };
2663
2664 let node_pattern = node_pattern.trim();
2665 if !node_pattern.is_empty() && node_pattern != "*" {
2666 criteria.class_name = Some(node_pattern.to_string());
2667 }
2668
2669 if let Some(predicates) = predicates {
2670 for predicate in predicates.split(" and ") {
2671 let predicate = predicate.trim().trim_matches(|ch| ch == '(' || ch == ')');
2672 if let Some(value) = parse_xpath_attr_equals(predicate, "@text") {
2673 criteria.text = Some(value);
2674 continue;
2675 }
2676 if let Some(value) = parse_xpath_attr_equals(predicate, "@content-desc") {
2677 criteria.content_desc = Some(value);
2678 continue;
2679 }
2680 if let Some(value) = parse_xpath_attr_equals(predicate, "@class") {
2681 criteria.class_name = Some(value);
2682 continue;
2683 }
2684 if let Some(value) = parse_xpath_attr_equals(predicate, "@resource-id") {
2685 if value.contains(':') {
2686 criteria.resource_id_exact = Some(value);
2687 } else {
2688 criteria.resource_id_suffix = Some(format!(":id/{value}"));
2689 }
2690 continue;
2691 }
2692 if let Some(value) = parse_xpath_resource_suffix(predicate) {
2693 criteria.resource_id_suffix = Some(value);
2694 continue;
2695 }
2696 }
2697 }
2698
2699 Ok(criteria)
2700}
2701
2702fn parse_attribute_filter(attribute: &str) -> Result<(&str, String), String> {
2703 let (name, value) = attribute
2704 .split_once('=')
2705 .ok_or_else(|| format!("unsupported CSS attribute selector `{attribute}`"))?;
2706 Ok((name.trim(), parse_quoted_value(value.trim())?))
2707}
2708
2709fn parse_xpath_attr_equals(predicate: &str, attr_name: &str) -> Option<String> {
2710 let remainder = predicate.strip_prefix(attr_name)?;
2711 let remainder = remainder.trim_start();
2712 let remainder = remainder.strip_prefix('=')?.trim_start();
2713 parse_quoted_value(remainder).ok()
2714}
2715
2716fn parse_xpath_resource_suffix(predicate: &str) -> Option<String> {
2717 let suffix_marker = ":id/";
2718 let start = predicate.find(suffix_marker)?;
2719 let candidate = &predicate[start - 1..];
2720 parse_quoted_value(candidate).ok()
2721}
2722
2723fn resource_id_matches_suffix(resource_id: &str, expected_suffix: &str) -> bool {
2724 if resource_id.ends_with(expected_suffix) {
2725 return true;
2726 }
2727
2728 let bare_expected = expected_suffix
2729 .rsplit('/')
2730 .next()
2731 .filter(|value| !value.is_empty());
2732 matches!(bare_expected, Some(expected) if resource_id == expected)
2733}
2734
2735fn contains_text(actual: Option<&str>, expected: &str) -> bool {
2736 actual.is_some_and(|value| value.contains(expected))
2737}
2738
2739fn starts_with_text(actual: Option<&str>, expected: &str) -> bool {
2740 actual.is_some_and(|value| value.starts_with(expected))
2741}
2742
2743fn matches_regex(actual: Option<&str>, pattern: &str) -> bool {
2744 let Some(actual) = actual else {
2745 return false;
2746 };
2747 Regex::new(pattern).is_ok_and(|regex| regex.is_match(actual))
2748}
2749
2750fn parse_quoted_value(input: &str) -> Result<String, String> {
2751 let trimmed = input.trim();
2752 let quote = trimmed
2753 .chars()
2754 .next()
2755 .filter(|ch| *ch == '\'' || *ch == '"')
2756 .ok_or_else(|| format!("expected quoted value in `{input}`"))?;
2757 let end = trimmed[1..]
2758 .find(quote)
2759 .ok_or_else(|| format!("unterminated quoted value in `{input}`"))?;
2760 Ok(trimmed[1..1 + end].to_string())
2761}
2762
2763fn encode_adb_text(value: &str) -> String {
2764 let mut encoded = String::new();
2765 for ch in value.chars() {
2766 match ch {
2767 ' ' => encoded.push_str("%s"),
2768 '"' => encoded.push_str("\\\""),
2769 '\'' => encoded.push_str("\\'"),
2770 '&' | '|' | ';' | '<' | '>' | '(' | ')' | '$' | '\\' => {
2771 encoded.push('\\');
2772 encoded.push(ch);
2773 }
2774 _ => encoded.push(ch),
2775 }
2776 }
2777 encoded
2778}
2779
2780#[cfg(test)]
2781mod tests {
2782 use super::*;
2783
2784 #[test]
2785 fn recognizes_standard_android_picker_packages() {
2786 assert!(is_android_picker(Some("com.google.android.documentsui")));
2787 assert!(is_android_picker(Some("com.vendor.filepicker")));
2788 assert!(is_android_picker(Some("com.android.files")));
2789 assert!(!is_android_picker(Some("com.example.app")));
2790 assert!(!is_android_picker(None));
2791 }
2792
2793 #[test]
2794 fn detects_new_and_replaced_public_downloads() {
2795 let before = HashMap::from([
2796 (
2797 "/sdcard/Download/existing.pdf".to_string(),
2798 "10:1".to_string(),
2799 ),
2800 (
2801 "/sdcard/Download/replaced.pdf".to_string(),
2802 "20:1".to_string(),
2803 ),
2804 ]);
2805 let unchanged = before.clone();
2806 assert_eq!(changed_public_download(&before, &unchanged), None);
2807
2808 let mut replaced = before.clone();
2809 replaced.insert(
2810 "/sdcard/Download/replaced.pdf".to_string(),
2811 "21:2".to_string(),
2812 );
2813 assert_eq!(
2814 changed_public_download(&before, &replaced).as_deref(),
2815 Some("/sdcard/Download/replaced.pdf")
2816 );
2817
2818 let mut added = before.clone();
2819 added.insert("/sdcard/Download/new.pdf".to_string(), "1:2".to_string());
2820 assert!(changed_public_download(&before, &added).is_some());
2821 }
2822
2823 #[tokio::test]
2824 async fn boots_android_runtime() {
2825 assert_eq!(boot().await, "android ready");
2826 }
2827
2828 #[test]
2829 fn defaults_to_uiautomator2() {
2830 let android = profile();
2831 assert_eq!(
2832 android.default_backend,
2833 MobileAutomationBackend::UiAutomator2
2834 );
2835 assert_eq!(android.backends, ANDROID_BACKENDS);
2836 assert!(android.capabilities.supports_native_views);
2837 assert!(android.capabilities.supports_webviews);
2838 }
2839
2840 #[test]
2841 fn exposes_android_runtime_milestones() {
2842 let readiness = runtime_readiness();
2843 assert_eq!(readiness.maturity, RuntimeMaturity::Scaffolding);
2844 assert!(
2845 readiness
2846 .missing_runtime_artifacts
2847 .iter()
2848 .any(|item| item.contains("server-side mobile session routing"))
2849 );
2850 }
2851
2852 #[test]
2853 fn connect_flow_matches_web_shape() {
2854 let options = ConnectOptions {
2855 platform: MobilePlatform::Android,
2856 device: Some("emulator-5554".to_string()),
2857 adb_endpoint: None,
2858 agent_endpoint: None,
2859 preserve_app_state: true,
2860 timeout_ms: Some(5_000),
2861 };
2862
2863 let command = connect_command(options.clone());
2864
2865 match command {
2866 MobileCommand::Connect(connect) => {
2867 assert_eq!(connect.platform, MobilePlatform::Android);
2868 assert_eq!(connect.device.as_deref(), Some("emulator-5554"));
2869 }
2870 _ => panic!("expected mobile connect command"),
2871 }
2872 }
2873
2874 #[test]
2875 fn launch_flow_keeps_apk_work_separate() {
2876 let browser_session = MobileBrowserSessionHandle {
2877 platform: MobilePlatform::Android,
2878 automation: MobileAutomationSessionInfo {
2879 backend: "uiautomator2".to_string(),
2880 session_id: "uiautomator2:emulator-5554".to_string(),
2881 note: "ready".to_string(),
2882 },
2883 device: DeviceTarget {
2884 platform: MobilePlatform::Android,
2885 device_id: "emulator-5554".to_string(),
2886 connection_kind: DeviceConnectionKind::Emulator,
2887 },
2888 };
2889
2890 let command = launch_command(
2891 browser_session,
2892 LaunchOptions {
2893 apk_path: Some("/tmp/app.apk".to_string()),
2894 app_id: Some("dev.allwright.sample".to_string()),
2895 launch_activity: Some(".MainActivity".to_string()),
2896 stop_before_launch: true,
2897 timeout_ms: Some(15_000),
2898 },
2899 );
2900
2901 match command {
2902 MobileCommand::LaunchApp { options, .. } => {
2903 assert_eq!(options.apk_path.as_deref(), Some("/tmp/app.apk"));
2904 assert_eq!(options.app_id.as_deref(), Some("dev.allwright.sample"));
2905 assert!(options.stop_before_launch);
2906 }
2907 _ => panic!("expected launch command"),
2908 }
2909 }
2910
2911 #[test]
2912 fn parse_adb_devices_extracts_friendly_names() {
2913 let devices = parse_adb_devices(
2914 "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",
2915 );
2916
2917 assert_eq!(devices.len(), 2);
2918 assert_eq!(devices[0].name.as_deref(), Some("Pixel 8 API 35"));
2919 assert_eq!(devices[1].name.as_deref(), Some("QA Galaxy S24"));
2920 assert_eq!(devices[0].connection_kind, DeviceConnectionKind::Emulator);
2921 assert_eq!(devices[1].connection_kind, DeviceConnectionKind::Usb);
2922 }
2923
2924 #[test]
2925 fn connect_uses_requested_device_when_provided() {
2926 let options = ConnectOptions {
2927 platform: MobilePlatform::Android,
2928 device: Some("R5CX123".to_string()),
2929 adb_endpoint: None,
2930 agent_endpoint: None,
2931 preserve_app_state: false,
2932 timeout_ms: None,
2933 };
2934 let devices = vec![
2935 AdbDevice {
2936 serial: "emulator-5554".to_string(),
2937 name: Some("Pixel 8 API 35".to_string()),
2938 connection_kind: DeviceConnectionKind::Emulator,
2939 state: "device".to_string(),
2940 },
2941 AdbDevice {
2942 serial: "R5CX123".to_string(),
2943 name: Some("QA Galaxy S24".to_string()),
2944 connection_kind: DeviceConnectionKind::Usb,
2945 state: "device".to_string(),
2946 },
2947 ];
2948
2949 let selected = select_device_for_connect(&options, &devices).expect("device should match");
2950 assert_eq!(selected.serial, "R5CX123");
2951 assert_eq!(selected.connection_kind, DeviceConnectionKind::Usb);
2952 }
2953
2954 #[test]
2955 fn connect_defaults_to_first_available_device() {
2956 let options = ConnectOptions {
2957 platform: MobilePlatform::Android,
2958 device: None,
2959 adb_endpoint: None,
2960 agent_endpoint: None,
2961 preserve_app_state: false,
2962 timeout_ms: None,
2963 };
2964 let devices = vec![
2965 AdbDevice {
2966 serial: "emulator-5554".to_string(),
2967 name: Some("Pixel 8 API 35".to_string()),
2968 connection_kind: DeviceConnectionKind::Emulator,
2969 state: "device".to_string(),
2970 },
2971 AdbDevice {
2972 serial: "R5CX123".to_string(),
2973 name: Some("QA Galaxy S24".to_string()),
2974 connection_kind: DeviceConnectionKind::Usb,
2975 state: "device".to_string(),
2976 },
2977 ];
2978
2979 let selected = select_device_for_connect(&options, &devices).expect("first device");
2980 assert_eq!(selected.serial, "emulator-5554");
2981 }
2982
2983 #[test]
2984 fn connect_matches_friendly_device_name() {
2985 let options = ConnectOptions {
2986 platform: MobilePlatform::Android,
2987 device: Some("qa galaxy s24".to_string()),
2988 adb_endpoint: None,
2989 agent_endpoint: None,
2990 preserve_app_state: false,
2991 timeout_ms: None,
2992 };
2993 let devices = vec![
2994 AdbDevice {
2995 serial: "emulator-5554".to_string(),
2996 name: Some("Pixel 8 API 35".to_string()),
2997 connection_kind: DeviceConnectionKind::Emulator,
2998 state: "device".to_string(),
2999 },
3000 AdbDevice {
3001 serial: "R5CX123".to_string(),
3002 name: Some("QA Galaxy S24".to_string()),
3003 connection_kind: DeviceConnectionKind::Usb,
3004 state: "device".to_string(),
3005 },
3006 ];
3007
3008 let selected = select_device_for_connect(&options, &devices).expect("friendly name match");
3009 assert_eq!(selected.serial, "R5CX123");
3010 }
3011
3012 #[test]
3013 fn connect_fails_when_requested_device_is_missing() {
3014 let options = ConnectOptions {
3015 platform: MobilePlatform::Android,
3016 device: Some("missing-device".to_string()),
3017 adb_endpoint: None,
3018 agent_endpoint: None,
3019 preserve_app_state: false,
3020 timeout_ms: None,
3021 };
3022 let devices = vec![AdbDevice {
3023 serial: "emulator-5554".to_string(),
3024 name: Some("Pixel 8 API 35".to_string()),
3025 connection_kind: DeviceConnectionKind::Emulator,
3026 state: "device".to_string(),
3027 }];
3028
3029 let error = select_device_for_connect(&options, &devices).expect_err("missing device");
3030 assert_eq!(
3031 error,
3032 ConnectSelectionError::RequestedDeviceNotFound {
3033 requested: "missing-device".to_string()
3034 }
3035 );
3036 }
3037
3038 #[test]
3039 fn parse_package_name_from_aapt_output_extracts_package() {
3040 let package_name = parse_package_name_from_aapt_badging(
3041 "package: name='dev.allwright.sample' versionCode='1' versionName='1.0'\n",
3042 )
3043 .expect("package name should parse");
3044 assert_eq!(package_name, "dev.allwright.sample");
3045 }
3046
3047 #[test]
3048 fn detects_remote_apk_sources() {
3049 assert!(is_remote_apk_source("https://example.com/app.apk"));
3050 assert!(is_remote_apk_source("http://example.com/app.apk"));
3051 assert!(!is_remote_apk_source("/tmp/app.apk"));
3052 }
3053
3054 #[test]
3055 fn resolves_local_apk_source() {
3056 let apk_path = env::temp_dir().join("allwright-mobile-android-local-test.apk");
3057 fs::write(&apk_path, b"apk").expect("write local apk fixture");
3058
3059 let resolved =
3060 resolve_apk_source(apk_path.to_string_lossy().as_ref()).expect("resolve local apk");
3061 assert_eq!(resolved.path, apk_path);
3062
3063 fs::remove_file(&apk_path).expect("remove local apk fixture");
3064 }
3065
3066 #[test]
3067 fn foreground_app_parses_component_after_window_prefix() {
3068 let app = parse_foreground_app_from_dumpsys("mCurrentFocus=Window{42 u0 test.app/.Main}");
3069 assert_eq!(app.current_package.as_deref(), Some("test.app"));
3070 assert_eq!(app.current_activity.as_deref(), Some(".Main"));
3071 }
3072
3073 #[test]
3074 fn dump_source_keeps_page_context_separate() {
3075 let page_session = MobilePageSessionHandle {
3076 page_id: "emulator-5554:dev.allwright.sample".to_string(),
3077 package_name: Some("dev.allwright.sample".to_string()),
3078 activity_name: Some(".MainActivity".to_string()),
3079 webview_context: None,
3080 };
3081 assert_eq!(page_session.page_id, "emulator-5554:dev.allwright.sample");
3082 }
3083
3084 #[test]
3085 fn parses_uiautomator_selector_strategies() {
3086 let criteria = parse_uiautomator_criteria(
3087 "textContains=Account,descriptionStartsWith=Open,classNameMatches=android\\.widget\\..*,clickable=true,index=2,instance=1,resourceIdMatches=.*bottom_nav.*,packageName=com.example.airticket",
3088 )
3089 .expect("uia criteria should parse");
3090
3091 assert_eq!(criteria.text_contains.as_deref(), Some("Account"));
3092 assert_eq!(criteria.content_desc_starts_with.as_deref(), Some("Open"));
3093 assert_eq!(
3094 criteria.class_name_matches.as_deref(),
3095 Some("android\\.widget\\..*")
3096 );
3097 assert_eq!(criteria.clickable, Some(true));
3098 assert_eq!(criteria.index, Some(2));
3099 assert_eq!(criteria.instance, Some(1));
3100 assert_eq!(
3101 criteria.resource_id_matches.as_deref(),
3102 Some(".*bottom_nav.*")
3103 );
3104 assert_eq!(
3105 criteria.package_name.as_deref(),
3106 Some("com.example.airticket")
3107 );
3108 }
3109
3110 #[test]
3111 fn matches_uiautomator_selectors_against_dumped_nodes() {
3112 let nodes = parse_android_ui_nodes(sample_ui_hierarchy()).expect("xml should parse");
3113
3114 let account = find_node_by_selector(&nodes, "text=Account").expect("text selector");
3115 assert_eq!(
3116 account.resource_id.as_deref(),
3117 Some("com.example.airticket:id/bottom_nav_account")
3118 );
3119
3120 let by_desc =
3121 find_node_by_selector(&nodes, "descriptionContains=Account").expect("desc selector");
3122 assert_eq!(
3123 by_desc.resource_id.as_deref(),
3124 Some("com.example.airticket:id/bottom_nav_account")
3125 );
3126
3127 let by_resource =
3128 find_node_by_selector(&nodes, "resourceIdMatches=.*bottom_nav_.*,selected=true")
3129 .expect("resource regex selector");
3130 assert_eq!(
3131 by_resource.resource_id.as_deref(),
3132 Some("com.example.airticket:id/bottom_nav_account")
3133 );
3134
3135 let by_class = find_node_by_selector(
3136 &nodes,
3137 "classNameMatches=android\\.widget\\..*,packageNameMatches=com\\.example\\..*,clickable=true,instance=1",
3138 )
3139 .expect("class/package/instance selector");
3140 assert_eq!(
3141 by_class.resource_id.as_deref(),
3142 Some("com.example.airticket:id/bottom_nav_account")
3143 );
3144
3145 let by_css_id = find_node_by_selector(&nodes, r##"css="#bottom_nav_account""##)
3146 .expect("css id selector");
3147 assert_eq!(
3148 by_css_id.resource_id.as_deref(),
3149 Some("com.example.airticket:id/bottom_nav_account")
3150 );
3151
3152 let bare_nodes =
3153 parse_android_ui_nodes(sample_ui_hierarchy_with_bare_resource_ids()).expect("xml");
3154 let bare_by_css_id = find_node_by_selector(&bare_nodes, r##"css="#signup_first_name""##)
3155 .expect("bare css id");
3156 assert_eq!(
3157 bare_by_css_id.resource_id.as_deref(),
3158 Some("signup_first_name")
3159 );
3160 }
3161
3162 #[test]
3163 fn exposes_native_capture_attributes_and_bounds() {
3164 let nodes = parse_android_ui_nodes(sample_ui_hierarchy()).expect("xml should parse");
3165 let node = find_node_by_selector(&nodes, "resourceIdMatches=.*bottom_nav_.*,selected=true")
3166 .expect("selected navigation node");
3167 assert_eq!(
3168 android_node_attribute(node, "text").as_deref(),
3169 Some("Account")
3170 );
3171 assert_eq!(
3172 android_node_attribute(node, "selected").as_deref(),
3173 Some("true")
3174 );
3175 assert!(android_node_attribute(node, "not-a-native-attribute").is_none());
3176 let bounds = node.bounds.expect("sample node has bounds");
3177 assert!(bounds.right > bounds.left);
3178 assert!(bounds.bottom > bounds.top);
3179
3180 let input = find_node_by_selector(&nodes, "resourceId=com.example.airticket:id/email")
3181 .expect("email input");
3182 assert_eq!(
3183 android_node_attribute(input, "editable").as_deref(),
3184 Some("true")
3185 );
3186 assert_eq!(
3187 android_node_attribute(input, "focused").as_deref(),
3188 Some("true")
3189 );
3190 }
3191
3192 #[test]
3193 fn matches_playwright_style_semantic_mobile_selectors() {
3194 let nodes = parse_android_ui_nodes(sample_ui_hierarchy()).expect("xml should parse");
3195 let selector = |spec: serde_json::Value| {
3196 format!("aw={}", serde_json::to_string(&spec.to_string()).unwrap())
3197 };
3198
3199 let account = find_node_by_selector(
3200 &nodes,
3201 &selector(serde_json::json!({"kind":"role", "role":"text", "name":"account", "selected":true})),
3202 )
3203 .expect("semantic role selector");
3204 assert_eq!(account.text.as_deref(), Some("Account"));
3205
3206 let by_text = find_node_by_selector(
3207 &nodes,
3208 &selector(serde_json::json!({"kind":"text", "text":{"regex":"^Acc", "flags":"i"}})),
3209 )
3210 .expect("semantic text selector");
3211 assert_eq!(by_text.text.as_deref(), Some("Account"));
3212
3213 let by_test_id = find_node_by_selector(
3214 &nodes,
3215 &selector(serde_json::json!({"kind":"testId", "text":"bottom_nav_account"})),
3216 )
3217 .expect("semantic test id selector");
3218 assert_eq!(
3219 by_test_id.resource_id.as_deref(),
3220 Some("com.example.airticket:id/bottom_nav_account")
3221 );
3222 }
3223
3224 #[test]
3225 fn stitches_scrolled_screenshots_without_duplicate_overlap() {
3226 let first =
3227 RgbaImage::from_fn(4, 10, |x, y| image::Rgba([(x * 11) as u8, y as u8, 0, 255]));
3228 let second = RgbaImage::from_fn(4, 10, |x, y| {
3229 image::Rgba([(x * 11) as u8, (y + 4) as u8, 0, 255])
3230 });
3231
3232 let overlap = find_screenshot_overlap(&first, &second).expect("screenshots align");
3233 assert_eq!(overlap, 6);
3234
3235 let mut stitched = first;
3236 append_screenshot(&mut stitched, &second, overlap).expect("screenshots stitch");
3237 assert_eq!(stitched.dimensions(), (4, 14));
3238 assert_eq!(stitched.get_pixel(0, 13), second.get_pixel(0, 9));
3239 }
3240
3241 fn sample_ui_hierarchy() -> &'static str {
3242 r#"<?xml version='1.0' encoding='UTF-8' standalone='yes' ?>
3243<hierarchy rotation="0">
3244 <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]">
3245 <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]" />
3246 <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]" />
3247 <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]" />
3248 </node>
3249</hierarchy>"#
3250 }
3251
3252 fn sample_ui_hierarchy_with_bare_resource_ids() -> &'static str {
3253 r#"<?xml version='1.0' encoding='UTF-8' standalone='yes' ?>
3254<hierarchy rotation="0">
3255 <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]">
3256 <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]" />
3257 </node>
3258</hierarchy>"#
3259 }
3260}