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