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