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