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