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