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 MobileFillInfo, MobilePageInfo, MobilePageSessionHandle, MobilePlatform,
9 MobileRuntimeReadiness, MobileSurfaceProfile, RuntimeMaturity, boot_surface,
10 normalize_selector_for_transport,
11};
12use regex::Regex;
13use std::env;
14use std::ffi::{CStr, CString, c_char};
15use std::fmt;
16use std::path::Path;
17use std::process::Command;
18
19#[derive(Debug, Clone, Copy, Default)]
20pub struct MobileAndroidPlugin;
21
22const ANDROID_BACKENDS: &[MobileAutomationBackend] = &[MobileAutomationBackend::UiAutomator2];
23const ANDROID_APP_KINDS: &[MobileAppKind] = &[
24 MobileAppKind::Native,
25 MobileAppKind::Hybrid,
26 MobileAppKind::BrowserWrapped,
27];
28const ANDROID_MISSING_RUNTIME_ARTIFACTS: &[&str] = &[
29 "server-side mobile session routing in allwright-core",
30 "expanded command coverage beyond connect, launch, click, and fill",
31];
32const ANDROID_NEXT_MILESTONES: &[&str] = &[
33 "route Android mobile commands through the core engine session server",
34 "add text and waitForSelector on top of the native ADB-driven runtime",
35 "expand runtime packaging validation across macOS, Linux, and Windows release assets",
36];
37
38#[derive(Debug, Clone, PartialEq, Eq)]
39pub struct AdbDevice {
40 pub serial: String,
41 pub name: Option<String>,
42 pub connection_kind: DeviceConnectionKind,
43 pub state: String,
44}
45
46#[derive(Debug, Clone, PartialEq, Eq)]
47pub enum ConnectSelectionError {
48 NoDevicesAvailable,
49 RequestedDeviceNotFound { requested: String },
50}
51
52impl fmt::Display for ConnectSelectionError {
53 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
54 match self {
55 Self::NoDevicesAvailable => write!(f, "no Android devices were discovered over ADB"),
56 Self::RequestedDeviceNotFound { requested } => {
57 write!(
58 f,
59 "requested Android device `{requested}` was not found over ADB"
60 )
61 }
62 }
63 }
64}
65
66#[derive(Debug, Clone, PartialEq, Eq)]
67pub struct UiAutomator2ConnectInfo {
68 pub device_name: Option<String>,
69 pub current_package: Option<String>,
70 pub current_activity: Option<String>,
71}
72
73#[derive(Debug, Clone, PartialEq, Eq)]
74pub struct UiAutomator2ClickInfo {
75 pub resolved_selector: String,
76 pub note: String,
77}
78
79#[derive(Debug, Clone, PartialEq, Eq)]
80pub struct UiAutomator2SourceInfo {
81 pub source: String,
82 pub current_package: Option<String>,
83 pub current_activity: Option<String>,
84}
85
86#[derive(Debug, Clone, PartialEq, Eq)]
87pub struct UiAutomator2FillInfo {
88 pub resolved_selector: String,
89 pub value: String,
90 pub note: String,
91}
92
93#[derive(Debug, Clone, PartialEq, Eq)]
94struct ForegroundAppInfo {
95 current_package: Option<String>,
96 current_activity: Option<String>,
97}
98
99#[derive(Debug, Clone, PartialEq, Eq)]
100struct AndroidUiNode {
101 class_name: Option<String>,
102 resource_id: Option<String>,
103 package_name: Option<String>,
104 text: Option<String>,
105 content_desc: Option<String>,
106 checkable: Option<bool>,
107 checked: Option<bool>,
108 clickable: Option<bool>,
109 long_clickable: Option<bool>,
110 scrollable: Option<bool>,
111 enabled: Option<bool>,
112 focusable: Option<bool>,
113 focused: Option<bool>,
114 selected: Option<bool>,
115 index: Option<usize>,
116 bounds: Option<AndroidBounds>,
117 parent_index: Option<usize>,
118}
119
120#[derive(Debug, Clone, Copy, PartialEq, Eq)]
121struct AndroidBounds {
122 left: i32,
123 top: i32,
124 right: i32,
125 bottom: i32,
126}
127
128impl AndroidBounds {
129 fn center(self) -> (i32, i32) {
130 ((self.left + self.right) / 2, (self.top + self.bottom) / 2)
131 }
132}
133
134#[derive(Debug, Clone, PartialEq, Eq)]
135enum SelectorFlavorInternal {
136 Css,
137 XPath,
138 UiAutomator,
139}
140
141#[derive(Debug, Clone, PartialEq, Eq)]
142struct SelectorSegment {
143 flavor: SelectorFlavorInternal,
144 value: String,
145}
146
147#[derive(Debug, Clone, Default, PartialEq, Eq)]
148struct NodeCriteria {
149 class_name: Option<String>,
150 class_name_matches: Option<String>,
151 resource_id_exact: Option<String>,
152 resource_id_suffix: Option<String>,
153 resource_id_matches: Option<String>,
154 package_name: Option<String>,
155 package_name_matches: Option<String>,
156 text: Option<String>,
157 text_contains: Option<String>,
158 text_starts_with: Option<String>,
159 text_matches: Option<String>,
160 content_desc: Option<String>,
161 content_desc_contains: Option<String>,
162 content_desc_starts_with: Option<String>,
163 content_desc_matches: Option<String>,
164 checkable: Option<bool>,
165 checked: Option<bool>,
166 clickable: Option<bool>,
167 long_clickable: Option<bool>,
168 scrollable: Option<bool>,
169 enabled: Option<bool>,
170 focusable: Option<bool>,
171 focused: Option<bool>,
172 selected: Option<bool>,
173 index: Option<usize>,
174 instance: Option<usize>,
175}
176
177impl SurfacePlugin for MobileAndroidPlugin {
178 fn descriptor(&self) -> SurfacePluginDescriptor {
179 descriptor()
180 }
181}
182
183pub fn descriptor() -> SurfacePluginDescriptor {
184 SurfacePluginDescriptor {
185 id: "mobile-android",
186 family: SurfaceFamily::Mobile,
187 version: env!("CARGO_PKG_VERSION"),
188 description: "Android mobile surface plugin for the allwright engine.",
189 }
190}
191
192pub fn profile() -> MobileSurfaceProfile {
193 MobileSurfaceProfile {
194 plugin_id: "mobile-android",
195 display_name: "Android",
196 family: SurfaceFamily::Mobile,
197 backends: ANDROID_BACKENDS,
198 default_backend: MobileAutomationBackend::UiAutomator2,
199 supported_app_kinds: ANDROID_APP_KINDS,
200 capabilities: MobileCapabilitySet {
201 supports_native_views: true,
202 supports_webviews: true,
203 supports_deep_links: true,
204 supports_shell_commands: true,
205 supports_device_logs: true,
206 },
207 bootstrap_hint: "Connect a device or emulator over ADB, then bootstrap a UiAutomator2 session before launching and clicking through the app.",
208 }
209}
210
211pub fn runtime_readiness() -> MobileRuntimeReadiness {
212 MobileRuntimeReadiness {
213 maturity: RuntimeMaturity::Scaffolding,
214 missing_runtime_artifacts: ANDROID_MISSING_RUNTIME_ARTIFACTS,
215 next_milestones: ANDROID_NEXT_MILESTONES,
216 }
217}
218
219pub fn connect_command(options: ConnectOptions) -> MobileCommand {
220 MobileCommand::Connect(options)
221}
222
223pub fn launch_command(
224 browser_session: MobileBrowserSessionHandle,
225 options: LaunchOptions,
226) -> MobileCommand {
227 MobileCommand::LaunchApp {
228 browser_session,
229 options,
230 }
231}
232
233pub fn list_adb_devices() -> Result<Vec<AdbDevice>, String> {
234 let output = run_adb(&["devices", "-l"])?;
235 Ok(parse_adb_devices(&output))
236}
237
238pub fn select_device_for_connect(
239 options: &ConnectOptions,
240 devices: &[AdbDevice],
241) -> Result<AdbDevice, ConnectSelectionError> {
242 let available = devices
243 .iter()
244 .filter(|device| device.state == "device")
245 .cloned()
246 .collect::<Vec<_>>();
247 if available.is_empty() {
248 return Err(ConnectSelectionError::NoDevicesAvailable);
249 }
250
251 if let Some(requested) = options.device.as_deref() {
252 return available
253 .iter()
254 .find(|device| {
255 device.serial == requested
256 || device
257 .name
258 .as_deref()
259 .is_some_and(|name| name.eq_ignore_ascii_case(requested))
260 })
261 .cloned()
262 .ok_or_else(|| ConnectSelectionError::RequestedDeviceNotFound {
263 requested: requested.to_string(),
264 });
265 }
266
267 available
268 .first()
269 .cloned()
270 .ok_or(ConnectSelectionError::NoDevicesAvailable)
271}
272
273pub fn connect(options: &ConnectOptions) -> Result<MobileConnectInfo, String> {
274 if options.platform != MobilePlatform::Android {
275 return Err("mobile-android connect only supports the Android platform".to_string());
276 }
277
278 if let Some(endpoint) = options.adb_endpoint.as_deref() {
279 let trimmed = endpoint.trim();
280 if !trimmed.is_empty() {
281 let _ = run_adb(&["connect", trimmed])?;
282 }
283 }
284
285 let devices = list_adb_devices()?;
286 let selected =
287 select_device_for_connect(options, &devices).map_err(|error| error.to_string())?;
288 let foreground = current_foreground_app(&selected.serial)?;
289
290 Ok(MobileConnectInfo {
291 browser: "android".to_string(),
292 note: format!(
293 "connected Android device `{}` over ADB and attached the native Android runtime",
294 selected.name.as_deref().unwrap_or(selected.serial.as_str())
295 ),
296 browser_session: MobileBrowserSessionHandle {
297 platform: MobilePlatform::Android,
298 automation: MobileAutomationSessionInfo {
299 backend: "android-adb".to_string(),
300 session_id: format!("android-adb:{}", selected.serial),
301 note: "session established through the Android surface plugin".to_string(),
302 },
303 device: DeviceTarget {
304 platform: MobilePlatform::Android,
305 device_id: selected.serial.clone(),
306 connection_kind: selected.connection_kind,
307 },
308 },
309 initial_page: MobilePageInfo {
310 note: "attached to the device foreground context".to_string(),
311 page_session: MobilePageSessionHandle {
312 page_id: format!("{}:foreground", selected.serial),
313 package_name: foreground.current_package,
314 activity_name: foreground.current_activity,
315 webview_context: None,
316 },
317 },
318 })
319}
320
321pub fn launch_app(
322 browser_session: &MobileBrowserSessionHandle,
323 options: &LaunchOptions,
324) -> Result<MobilePageInfo, String> {
325 let device_id = browser_session.device.device_id.as_str();
326
327 if let Some(apk_path) = options.apk_path.as_deref() {
328 let apk = Path::new(apk_path);
329 if !apk.is_file() {
330 return Err(format!("APK path does not exist: {}", apk.display()));
331 }
332 let apk_owned = apk.to_string_lossy().into_owned();
333 let _ = run_adb_for_device(device_id, &["install", "-r", &apk_owned])?;
334 }
335
336 let package_name = match options.app_id.clone() {
337 Some(package_name) => package_name,
338 None => {
339 if let Some(apk_path) = options.apk_path.as_deref() {
340 resolve_package_name_from_apk(apk_path)?
341 } else {
342 return Err(
343 "launch_app requires `app_id`, or an APK path whose package name can be resolved"
344 .to_string(),
345 );
346 }
347 }
348 };
349
350 if options.stop_before_launch {
351 let _ = run_adb_for_device(device_id, &["shell", "am", "force-stop", &package_name])?;
352 }
353
354 if let Some(activity) = options.launch_activity.as_deref() {
355 let component = format!("{package_name}/{activity}");
356 let _ = run_adb_for_device(device_id, &["shell", "am", "start", "-W", "-n", &component])?;
357 } else {
358 let _ = run_adb_for_device(
359 device_id,
360 &[
361 "shell",
362 "monkey",
363 "-p",
364 &package_name,
365 "-c",
366 "android.intent.category.LAUNCHER",
367 "1",
368 ],
369 )?;
370 }
371
372 let current = current_foreground_app(device_id)?;
373 let activity_name = current
374 .current_activity
375 .or_else(|| options.launch_activity.clone());
376
377 Ok(MobilePageInfo {
378 note: format!(
379 "launched Android app `{package_name}` on {}",
380 browser_session.device.device_id
381 ),
382 page_session: MobilePageSessionHandle {
383 page_id: format!("{}:{package_name}", browser_session.device.device_id),
384 package_name: Some(package_name),
385 activity_name,
386 webview_context: None,
387 },
388 })
389}
390
391pub fn click_element(
392 browser_session: &MobileBrowserSessionHandle,
393 page_session: &MobilePageSessionHandle,
394 selector: &str,
395 timeout_ms: Option<u32>,
396) -> Result<MobileClickInfo, String> {
397 let normalized = normalize_selector_for_transport(selector);
398 if normalized.trim().is_empty() {
399 return Err("click_element requires a non-empty selector".to_string());
400 }
401
402 let clicked = adb_click(&browser_session.device.device_id, &normalized, timeout_ms)?;
403 Ok(MobileClickInfo {
404 selector: clicked.resolved_selector,
405 note: format!("{}; page={}", clicked.note, page_session.page_id),
406 session_id: browser_session.automation.session_id.clone(),
407 })
408}
409
410pub fn fill_element(
411 browser_session: &MobileBrowserSessionHandle,
412 page_session: &MobilePageSessionHandle,
413 selector: &str,
414 value: &str,
415 timeout_ms: Option<u32>,
416) -> Result<MobileFillInfo, String> {
417 let normalized = normalize_selector_for_transport(selector);
418 if normalized.trim().is_empty() {
419 return Err("fill_element requires a non-empty selector".to_string());
420 }
421
422 let filled = adb_fill(
423 &browser_session.device.device_id,
424 &normalized,
425 value,
426 timeout_ms,
427 )?;
428 Ok(MobileFillInfo {
429 selector: filled.resolved_selector,
430 value: filled.value,
431 note: format!("{}; page={}", filled.note, page_session.page_id),
432 })
433}
434
435pub fn dump_source(
436 browser_session: &MobileBrowserSessionHandle,
437 _page_session: &MobilePageSessionHandle,
438) -> Result<UiAutomator2SourceInfo, String> {
439 let source = adb_dump_source(&browser_session.device.device_id)?;
440 Ok(UiAutomator2SourceInfo {
441 source: source.source,
442 current_package: source.current_package,
443 current_activity: source.current_activity,
444 })
445}
446
447pub fn connect_result_example(options: &ConnectOptions) -> Result<MobileCommandResult, String> {
448 Ok(MobileCommandResult::Connect(connect(options)?))
449}
450
451pub async fn boot() -> String {
452 boot_surface("android", 10).await
453}
454
455#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
456struct MobilePluginEnvelope {
457 ok: bool,
458 result: Option<MobileCommandResult>,
459 error: Option<String>,
460}
461
462fn plugin_response(result: Result<MobileCommandResult, String>) -> *mut c_char {
463 let payload = match result {
464 Ok(result) => MobilePluginEnvelope {
465 ok: true,
466 result: Some(result),
467 error: None,
468 },
469 Err(error) => MobilePluginEnvelope {
470 ok: false,
471 result: None,
472 error: Some(error),
473 },
474 };
475
476 match serde_json::to_string(&payload) {
477 Ok(json) => CString::new(json)
478 .map(CString::into_raw)
479 .unwrap_or_else(|_| CString::new("{\"ok\":false,\"result\":null,\"error\":\"failed to encode plugin response\"}").expect("static JSON").into_raw()),
480 Err(error) => CString::new(format!(
481 "{{\"ok\":false,\"result\":null,\"error\":\"failed to serialize plugin response: {error}\"}}"
482 ))
483 .map(CString::into_raw)
484 .unwrap_or_else(|_| CString::new("{\"ok\":false,\"result\":null,\"error\":\"failed to serialize plugin response\"}").expect("static JSON").into_raw()),
485 }
486}
487
488fn handle_plugin_command(command: MobileCommand) -> Result<MobileCommandResult, String> {
489 match command {
490 MobileCommand::Connect(options) => connect(&options).map(MobileCommandResult::Connect),
491 MobileCommand::LaunchApp {
492 browser_session,
493 options,
494 } => launch_app(&browser_session, &options).map(MobileCommandResult::LaunchApp),
495 MobileCommand::OpenPage { browser_session } => {
496 let info = MobilePageInfo {
497 note: "attached to the Android foreground app context".to_string(),
498 page_session: MobilePageSessionHandle {
499 page_id: format!("{}:foreground", browser_session.device.device_id),
500 package_name: None,
501 activity_name: None,
502 webview_context: None,
503 },
504 };
505 Ok(MobileCommandResult::OpenPage(info))
506 }
507 MobileCommand::ClosePage { .. } => Ok(MobileCommandResult::ClosePage),
508 MobileCommand::ClickElement {
509 browser_session,
510 page_session,
511 selector,
512 timeout_ms,
513 } => click_element(&browser_session, &page_session, &selector, timeout_ms)
514 .map(MobileCommandResult::ClickElement),
515 MobileCommand::CountElements { .. } => {
516 Err("mobile-android plugin does not implement `count_elements` yet".to_string())
517 }
518 MobileCommand::FillElement {
519 browser_session,
520 page_session,
521 selector,
522 value,
523 timeout_ms,
524 } => fill_element(
525 &browser_session,
526 &page_session,
527 &selector,
528 &value,
529 timeout_ms,
530 )
531 .map(MobileCommandResult::FillElement),
532 MobileCommand::GetText { .. } => {
533 Err("mobile-android plugin does not implement `get_text` yet".to_string())
534 }
535 MobileCommand::WaitForSelector { .. } => {
536 Err("mobile-android plugin does not implement `wait_for_selector` yet".to_string())
537 }
538 }
539}
540
541#[unsafe(no_mangle)]
542pub extern "C" fn allwright_plugin_api_version() -> u32 {
543 ALLWRIGHT_PLUGIN_API_VERSION
544}
545
546#[unsafe(no_mangle)]
547pub extern "C" fn allwright_plugin_id() -> *const c_char {
548 c"mobile-android".as_ptr()
549}
550
551#[unsafe(no_mangle)]
552pub unsafe extern "C" fn allwright_plugin_invoke(request_json: *const c_char) -> *mut c_char {
553 if request_json.is_null() {
554 return plugin_response(Err("plugin request pointer is null".to_string()));
555 }
556
557 let request = match unsafe { CStr::from_ptr(request_json) }.to_str() {
558 Ok(request) => request,
559 Err(error) => {
560 return plugin_response(Err(format!("plugin request is not valid UTF-8: {error}")));
561 }
562 };
563
564 let command: MobileCommand = match serde_json::from_str(request) {
565 Ok(command) => command,
566 Err(error) => {
567 return plugin_response(Err(format!(
568 "failed to parse mobile plugin request JSON: {error}"
569 )));
570 }
571 };
572
573 plugin_response(handle_plugin_command(command))
574}
575
576#[unsafe(no_mangle)]
577pub unsafe extern "C" fn allwright_plugin_free_string(value: *mut c_char) {
578 if !value.is_null() {
579 unsafe {
580 let _ = CString::from_raw(value);
581 }
582 }
583}
584
585fn parse_adb_devices(output: &str) -> Vec<AdbDevice> {
586 output
587 .lines()
588 .map(str::trim)
589 .filter(|line| !line.is_empty() && !line.starts_with("List of devices attached"))
590 .filter_map(|line| {
591 let mut parts = line.split_whitespace();
592 let serial = parts.next()?.to_string();
593 let state = parts.next()?.to_string();
594 let metadata = parts.collect::<Vec<_>>();
595 let mut name = None;
596 for item in metadata {
597 if let Some(model) = item.strip_prefix("model:") {
598 name = Some(model.replace('_', " "));
599 break;
600 }
601 if let Some(device_name) = item.strip_prefix("device:") {
602 name = Some(device_name.replace('_', " "));
603 }
604 }
605
606 Some(AdbDevice {
607 connection_kind: classify_device_connection_kind(&serial),
608 serial,
609 name,
610 state,
611 })
612 })
613 .collect()
614}
615
616fn classify_device_connection_kind(serial: &str) -> DeviceConnectionKind {
617 if serial.starts_with("emulator-") {
618 return DeviceConnectionKind::Emulator;
619 }
620 if serial.contains(':') {
621 return DeviceConnectionKind::RemoteAdb;
622 }
623 DeviceConnectionKind::Usb
624}
625
626fn run_adb(args: &[&str]) -> Result<String, String> {
627 run_command(&adb_command_path(), args)
628}
629
630fn run_adb_for_device(device_id: &str, args: &[&str]) -> Result<String, String> {
631 let mut all_args = vec!["-s", device_id];
632 all_args.extend_from_slice(args);
633 run_command(&adb_command_path(), &all_args)
634}
635
636fn adb_command_path() -> String {
637 env::var("ALLWRIGHT_ANDROID_ADB").unwrap_or_else(|_| "adb".to_string())
638}
639
640fn run_command(command: &str, args: &[&str]) -> Result<String, String> {
641 let output = Command::new(command)
642 .args(args)
643 .output()
644 .map_err(|error| format!("failed to run `{command}`: {error}"))?;
645 if output.status.success() {
646 return Ok(String::from_utf8_lossy(&output.stdout).trim().to_string());
647 }
648
649 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
650 let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
651 let detail = if !stderr.is_empty() { stderr } else { stdout };
652 Err(format!(
653 "`{command} {}` failed with status {}: {}",
654 args.join(" "),
655 output
656 .status
657 .code()
658 .map_or_else(|| "signal".to_string(), |code| code.to_string()),
659 detail
660 ))
661}
662
663fn resolve_package_name_from_apk(apk_path: &str) -> Result<String, String> {
664 if let Ok(output) = run_command("aapt", &["dump", "badging", apk_path]) {
665 if let Some(package_name) = parse_package_name_from_aapt_badging(&output) {
666 return Ok(package_name);
667 }
668 }
669
670 if let Ok(output) = run_command("apkanalyzer", &["manifest", "application-id", apk_path]) {
671 let package_name = output.trim();
672 if !package_name.is_empty() {
673 return Ok(package_name.to_string());
674 }
675 }
676
677 Err(
678 "could not resolve the app package name from the APK. Provide `--app-id`, or install `aapt`/`apkanalyzer`."
679 .to_string(),
680 )
681}
682
683fn parse_package_name_from_aapt_badging(output: &str) -> Option<String> {
684 output.lines().find_map(|line| {
685 let package_line = line.trim();
686 if !package_line.starts_with("package: ") {
687 return None;
688 }
689 let needle = "name='";
690 let start = package_line.find(needle)? + needle.len();
691 let remainder = &package_line[start..];
692 let end = remainder.find('\'')?;
693 Some(remainder[..end].to_string())
694 })
695}
696
697fn current_foreground_app(device_id: &str) -> Result<ForegroundAppInfo, String> {
698 let window_dump = run_adb_for_device(device_id, &["shell", "dumpsys", "window", "windows"])
699 .or_else(|_| run_adb_for_device(device_id, &["shell", "dumpsys", "window"]))?;
700 Ok(parse_foreground_app_from_dumpsys(&window_dump))
701}
702
703fn adb_click(
704 device_id: &str,
705 selector: &str,
706 timeout_ms: Option<u32>,
707) -> Result<UiAutomator2ClickInfo, String> {
708 let snapshot = resolve_selector_snapshot(device_id, selector, timeout_ms)?;
709 let bounds = snapshot
710 .node
711 .bounds
712 .ok_or_else(|| format!("selector resolved without bounds: {}", snapshot.selector))?;
713 let (x, y) = bounds.center();
714 let _ = run_adb_for_device(
715 device_id,
716 &["shell", "input", "tap", &x.to_string(), &y.to_string()],
717 )?;
718 Ok(UiAutomator2ClickInfo {
719 resolved_selector: snapshot.selector,
720 note: format!(
721 "clicked Android element via native ADB input on {}/{}",
722 snapshot
723 .foreground
724 .current_package
725 .as_deref()
726 .unwrap_or("<unknown>"),
727 snapshot
728 .foreground
729 .current_activity
730 .as_deref()
731 .unwrap_or("<unknown>")
732 ),
733 })
734}
735
736fn adb_fill(
737 device_id: &str,
738 selector: &str,
739 fill_value: &str,
740 timeout_ms: Option<u32>,
741) -> Result<UiAutomator2FillInfo, String> {
742 let snapshot = resolve_selector_snapshot(device_id, selector, timeout_ms)?;
743 let bounds = snapshot
744 .node
745 .bounds
746 .ok_or_else(|| format!("selector resolved without bounds: {}", snapshot.selector))?;
747 let (x, y) = bounds.center();
748 let _ = run_adb_for_device(
749 device_id,
750 &["shell", "input", "tap", &x.to_string(), &y.to_string()],
751 )?;
752 let _ = run_adb_for_device(device_id, &["shell", "input", "keyevent", "KEYCODE_MOVE_END"])?;
753 let existing_text = snapshot.node.text.as_deref().unwrap_or("");
754 for _ in existing_text.chars() {
755 let _ = run_adb_for_device(device_id, &["shell", "input", "keyevent", "KEYCODE_DEL"])?;
756 }
757 let encoded = encode_adb_text(fill_value);
758 if !encoded.is_empty() {
759 let _ = run_adb_for_device(device_id, &["shell", "input", "text", &encoded])?;
760 }
761 Ok(UiAutomator2FillInfo {
762 resolved_selector: snapshot.selector,
763 value: fill_value.to_string(),
764 note: format!(
765 "filled Android element via native ADB input on {}/{}",
766 snapshot
767 .foreground
768 .current_package
769 .as_deref()
770 .unwrap_or("<unknown>"),
771 snapshot
772 .foreground
773 .current_activity
774 .as_deref()
775 .unwrap_or("<unknown>")
776 ),
777 })
778}
779
780fn adb_dump_source(device_id: &str) -> Result<UiAutomator2SourceInfo, String> {
781 let remote_path = "/data/local/tmp/allwright-window.xml";
782 let _ = run_adb_for_device(device_id, &["shell", "uiautomator", "dump", remote_path])?;
783 let source = run_adb_for_device(device_id, &["shell", "cat", remote_path])?;
784 let foreground = current_foreground_app(device_id)?;
785 Ok(UiAutomator2SourceInfo {
786 source,
787 current_package: foreground.current_package,
788 current_activity: foreground.current_activity,
789 })
790}
791
792struct ResolvedSelectorSnapshot {
793 selector: String,
794 node: AndroidUiNode,
795 foreground: ForegroundAppInfo,
796}
797
798fn resolve_selector_snapshot(
799 device_id: &str,
800 selector: &str,
801 _timeout_ms: Option<u32>,
802) -> Result<ResolvedSelectorSnapshot, String> {
803 let source = adb_dump_source(device_id)?;
804 let nodes = parse_android_ui_nodes(&source.source)?;
805 let node = find_node_by_selector(&nodes, selector).ok_or_else(|| {
806 let package_name = source.current_package.as_deref().unwrap_or("<unknown>");
807 let activity_name = source.current_activity.as_deref().unwrap_or("<unknown>");
808 format!(
809 "selector not found: {selector} (current app: {package_name}/{activity_name})"
810 )
811 })?;
812 Ok(ResolvedSelectorSnapshot {
813 selector: selector.to_string(),
814 node: node.clone(),
815 foreground: ForegroundAppInfo {
816 current_package: source.current_package,
817 current_activity: source.current_activity,
818 },
819 })
820}
821
822fn parse_foreground_app_from_dumpsys(output: &str) -> ForegroundAppInfo {
823 for line in output.lines() {
824 if !(line.contains("mCurrentFocus") || line.contains("mFocusedApp")) {
825 continue;
826 }
827 if let Some((package_name, activity_name)) = extract_component_from_line(line) {
828 return ForegroundAppInfo {
829 current_package: Some(package_name),
830 current_activity: Some(activity_name),
831 };
832 }
833 }
834 ForegroundAppInfo {
835 current_package: None,
836 current_activity: None,
837 }
838}
839
840fn extract_component_from_line(line: &str) -> Option<(String, String)> {
841 for token in line.split_whitespace() {
842 let cleaned = token.trim_matches(|ch: char| matches!(ch, '{' | '}' | ',' | ';'));
843 let slash_index = cleaned.find('/')?;
844 let package_name = cleaned[..slash_index].trim();
845 let activity_name = cleaned[slash_index + 1..].trim();
846 if package_name.is_empty()
847 || activity_name.is_empty()
848 || !package_name.contains('.')
849 || package_name.contains('=')
850 {
851 continue;
852 }
853 return Some((package_name.to_string(), activity_name.to_string()));
854 }
855 None
856}
857
858fn parse_android_ui_nodes(xml: &str) -> Result<Vec<AndroidUiNode>, String> {
859 let mut nodes = Vec::new();
860 let mut stack: Vec<usize> = Vec::new();
861 let mut cursor = 0usize;
862 while let Some(relative_start) = xml[cursor..].find('<') {
863 let start = cursor + relative_start;
864 let Some(relative_end) = xml[start..].find('>') else {
865 break;
866 };
867 let end = start + relative_end;
868 let tag = &xml[start + 1..end];
869 let trimmed = tag.trim();
870 if trimmed.starts_with("/node") {
871 let _ = stack.pop();
872 } else if trimmed.starts_with("node") {
873 let self_closing = trimmed.ends_with('/');
874 let attributes = parse_xml_attributes(trimmed);
875 let node = AndroidUiNode {
876 class_name: attributes.get("class").cloned(),
877 resource_id: attributes.get("resource-id").cloned(),
878 package_name: attributes.get("package").cloned(),
879 text: attributes.get("text").cloned(),
880 content_desc: attributes.get("content-desc").cloned(),
881 checkable: attributes.get("checkable").and_then(|value| parse_bool(value)),
882 checked: attributes.get("checked").and_then(|value| parse_bool(value)),
883 clickable: attributes.get("clickable").and_then(|value| parse_bool(value)),
884 long_clickable: attributes
885 .get("long-clickable")
886 .and_then(|value| parse_bool(value)),
887 scrollable: attributes.get("scrollable").and_then(|value| parse_bool(value)),
888 enabled: attributes.get("enabled").and_then(|value| parse_bool(value)),
889 focusable: attributes.get("focusable").and_then(|value| parse_bool(value)),
890 focused: attributes.get("focused").and_then(|value| parse_bool(value)),
891 selected: attributes.get("selected").and_then(|value| parse_bool(value)),
892 index: attributes.get("index").and_then(|value| value.parse().ok()),
893 bounds: attributes
894 .get("bounds")
895 .and_then(|value| parse_bounds(value)),
896 parent_index: stack.last().copied(),
897 };
898 let index = nodes.len();
899 nodes.push(node);
900 if !self_closing {
901 stack.push(index);
902 }
903 }
904 cursor = end + 1;
905 }
906 Ok(nodes)
907}
908
909fn parse_xml_attributes(tag: &str) -> std::collections::BTreeMap<String, String> {
910 let mut attributes = std::collections::BTreeMap::new();
911 let bytes = tag.as_bytes();
912 let mut index = 0usize;
913 while index < bytes.len() {
914 while index < bytes.len() && !bytes[index].is_ascii_alphabetic() {
915 index += 1;
916 }
917 let key_start = index;
918 while index < bytes.len()
919 && (bytes[index].is_ascii_alphanumeric() || matches!(bytes[index], b'-' | b'_'))
920 {
921 index += 1;
922 }
923 if key_start == index {
924 continue;
925 }
926 let key = &tag[key_start..index];
927 while index < bytes.len() && bytes[index].is_ascii_whitespace() {
928 index += 1;
929 }
930 if index >= bytes.len() || bytes[index] != b'=' {
931 continue;
932 }
933 index += 1;
934 while index < bytes.len() && bytes[index].is_ascii_whitespace() {
935 index += 1;
936 }
937 if index >= bytes.len() || bytes[index] != b'"' {
938 continue;
939 }
940 index += 1;
941 let value_start = index;
942 while index < bytes.len() && bytes[index] != b'"' {
943 index += 1;
944 }
945 if index > value_start {
946 attributes.insert(
947 key.to_string(),
948 decode_xml_entities(&tag[value_start..index]),
949 );
950 }
951 if index < bytes.len() {
952 index += 1;
953 }
954 }
955 attributes
956}
957
958fn decode_xml_entities(value: &str) -> String {
959 value
960 .replace(""", "\"")
961 .replace("'", "'")
962 .replace("<", "<")
963 .replace(">", ">")
964 .replace("&", "&")
965}
966
967fn parse_bool(value: &str) -> Option<bool> {
968 match value.trim().to_ascii_lowercase().as_str() {
969 "true" => Some(true),
970 "false" => Some(false),
971 _ => None,
972 }
973}
974
975fn parse_bounds(value: &str) -> Option<AndroidBounds> {
976 let trimmed = value.trim();
977 let trimmed = trimmed.strip_prefix('[')?;
978 let (left_top, right_bottom) = trimmed.split_once("][")?;
979 let right_bottom = right_bottom.strip_suffix(']')?;
980 let (left, top) = left_top.split_once(',')?;
981 let (right, bottom) = right_bottom.split_once(',')?;
982 Some(AndroidBounds {
983 left: left.parse().ok()?,
984 top: top.parse().ok()?,
985 right: right.parse().ok()?,
986 bottom: bottom.parse().ok()?,
987 })
988}
989
990fn find_node_by_selector<'a>(
991 nodes: &'a [AndroidUiNode],
992 selector: &str,
993) -> Option<&'a AndroidUiNode> {
994 let segments = parse_selector_segments(selector).ok()?;
995 let criteria = segments
996 .iter()
997 .map(selector_segment_to_criteria)
998 .collect::<Result<Vec<_>, _>>()
999 .ok()?;
1000 let last = criteria.last()?;
1001 let mut matches = Vec::new();
1002 for (index, node) in nodes.iter().enumerate() {
1003 if !node_matches_criteria(node, last) {
1004 continue;
1005 }
1006 if ancestor_chain_matches(nodes, index, &criteria[..criteria.len().saturating_sub(1)]) {
1007 matches.push(node);
1008 }
1009 }
1010 if let Some(instance) = last.instance {
1011 return matches.get(instance).copied();
1012 }
1013 matches.into_iter().next()
1014}
1015
1016fn ancestor_chain_matches(
1017 nodes: &[AndroidUiNode],
1018 node_index: usize,
1019 criteria_chain: &[NodeCriteria],
1020) -> bool {
1021 if criteria_chain.is_empty() {
1022 return true;
1023 }
1024 let mut current_parent = nodes[node_index].parent_index;
1025 for criteria in criteria_chain.iter().rev() {
1026 let mut matched_parent = None;
1027 let mut probe = current_parent;
1028 while let Some(index) = probe {
1029 if node_matches_criteria(&nodes[index], criteria) {
1030 matched_parent = Some(index);
1031 break;
1032 }
1033 probe = nodes[index].parent_index;
1034 }
1035 let Some(index) = matched_parent else {
1036 return false;
1037 };
1038 current_parent = nodes[index].parent_index;
1039 }
1040 true
1041}
1042
1043fn node_matches_criteria(node: &AndroidUiNode, criteria: &NodeCriteria) -> bool {
1044 if let Some(expected) = criteria.class_name.as_deref()
1045 && node.class_name.as_deref() != Some(expected)
1046 {
1047 return false;
1048 }
1049 if let Some(pattern) = criteria.class_name_matches.as_deref()
1050 && !matches_regex(node.class_name.as_deref(), pattern)
1051 {
1052 return false;
1053 }
1054 if let Some(expected) = criteria.resource_id_exact.as_deref()
1055 && node.resource_id.as_deref() != Some(expected)
1056 {
1057 return false;
1058 }
1059 if let Some(expected_suffix) = criteria.resource_id_suffix.as_deref() {
1060 let Some(resource_id) = node.resource_id.as_deref() else {
1061 return false;
1062 };
1063 if !resource_id.ends_with(expected_suffix) {
1064 return false;
1065 }
1066 }
1067 if let Some(pattern) = criteria.resource_id_matches.as_deref()
1068 && !matches_regex(node.resource_id.as_deref(), pattern)
1069 {
1070 return false;
1071 }
1072 if let Some(expected) = criteria.package_name.as_deref()
1073 && node.package_name.as_deref() != Some(expected)
1074 {
1075 return false;
1076 }
1077 if let Some(pattern) = criteria.package_name_matches.as_deref()
1078 && !matches_regex(node.package_name.as_deref(), pattern)
1079 {
1080 return false;
1081 }
1082 if let Some(expected) = criteria.text.as_deref()
1083 && node.text.as_deref() != Some(expected)
1084 {
1085 return false;
1086 }
1087 if let Some(expected) = criteria.text_contains.as_deref()
1088 && !contains_text(node.text.as_deref(), expected)
1089 {
1090 return false;
1091 }
1092 if let Some(expected) = criteria.text_starts_with.as_deref()
1093 && !starts_with_text(node.text.as_deref(), expected)
1094 {
1095 return false;
1096 }
1097 if let Some(pattern) = criteria.text_matches.as_deref()
1098 && !matches_regex(node.text.as_deref(), pattern)
1099 {
1100 return false;
1101 }
1102 if let Some(expected) = criteria.content_desc.as_deref()
1103 && node.content_desc.as_deref() != Some(expected)
1104 {
1105 return false;
1106 }
1107 if let Some(expected) = criteria.content_desc_contains.as_deref()
1108 && !contains_text(node.content_desc.as_deref(), expected)
1109 {
1110 return false;
1111 }
1112 if let Some(expected) = criteria.content_desc_starts_with.as_deref()
1113 && !starts_with_text(node.content_desc.as_deref(), expected)
1114 {
1115 return false;
1116 }
1117 if let Some(pattern) = criteria.content_desc_matches.as_deref()
1118 && !matches_regex(node.content_desc.as_deref(), pattern)
1119 {
1120 return false;
1121 }
1122 if let Some(expected) = criteria.checkable && node.checkable != Some(expected) {
1123 return false;
1124 }
1125 if let Some(expected) = criteria.checked && node.checked != Some(expected) {
1126 return false;
1127 }
1128 if let Some(expected) = criteria.clickable && node.clickable != Some(expected) {
1129 return false;
1130 }
1131 if let Some(expected) = criteria.long_clickable && node.long_clickable != Some(expected) {
1132 return false;
1133 }
1134 if let Some(expected) = criteria.scrollable && node.scrollable != Some(expected) {
1135 return false;
1136 }
1137 if let Some(expected) = criteria.enabled && node.enabled != Some(expected) {
1138 return false;
1139 }
1140 if let Some(expected) = criteria.focusable && node.focusable != Some(expected) {
1141 return false;
1142 }
1143 if let Some(expected) = criteria.focused && node.focused != Some(expected) {
1144 return false;
1145 }
1146 if let Some(expected) = criteria.selected && node.selected != Some(expected) {
1147 return false;
1148 }
1149 if let Some(expected) = criteria.index && node.index != Some(expected) {
1150 return false;
1151 }
1152 true
1153}
1154
1155fn parse_selector_segments(selector: &str) -> Result<Vec<SelectorSegment>, String> {
1156 let mut segments = Vec::new();
1157 let normalized = normalize_selector_for_transport(selector);
1158 let trimmed = normalized.trim();
1159 let mut index = 0usize;
1160 while index < trimmed.len() {
1161 let remainder = &trimmed[index..];
1162 let (flavor, prefix_len) = if remainder.starts_with("css=") {
1163 (SelectorFlavorInternal::Css, 4usize)
1164 } else if remainder.starts_with("xpath=") {
1165 (SelectorFlavorInternal::XPath, 6usize)
1166 } else if remainder.starts_with("uia=") {
1167 (SelectorFlavorInternal::UiAutomator, 4usize)
1168 } else {
1169 return Err(format!("unsupported selector segment in `{trimmed}`"));
1170 };
1171 index += prefix_len;
1172 let bytes = trimmed.as_bytes();
1173 if bytes.get(index).copied() != Some(b'"') {
1174 return Err(format!("selector segment must use JSON string syntax: `{trimmed}`"));
1175 }
1176 let start = index;
1177 index += 1;
1178 let mut escaped = false;
1179 while index < trimmed.len() {
1180 let byte = bytes[index];
1181 if escaped {
1182 escaped = false;
1183 } else if byte == b'\\' {
1184 escaped = true;
1185 } else if byte == b'"' {
1186 index += 1;
1187 break;
1188 }
1189 index += 1;
1190 }
1191 let json_body = &trimmed[start..index];
1192 let value = serde_json::from_str::<String>(json_body)
1193 .map_err(|error| format!("failed to decode selector segment {json_body}: {error}"))?;
1194 segments.push(SelectorSegment { flavor, value });
1195 while index < trimmed.len() && trimmed.as_bytes()[index].is_ascii_whitespace() {
1196 index += 1;
1197 }
1198 }
1199 if segments.is_empty() {
1200 return Err("selector must not be empty".to_string());
1201 }
1202 Ok(segments)
1203}
1204
1205fn selector_segment_to_criteria(segment: &SelectorSegment) -> Result<NodeCriteria, String> {
1206 match segment.flavor {
1207 SelectorFlavorInternal::Css => parse_css_criteria(&segment.value),
1208 SelectorFlavorInternal::XPath => parse_xpath_criteria(&segment.value),
1209 SelectorFlavorInternal::UiAutomator => parse_uiautomator_criteria(&segment.value),
1210 }
1211}
1212
1213fn parse_uiautomator_criteria(selector: &str) -> Result<NodeCriteria, String> {
1214 let mut criteria = NodeCriteria::default();
1215 for token in split_uiautomator_tokens(selector) {
1216 let (raw_key, raw_value) = split_uiautomator_token(&token)?;
1217 let key = raw_key.to_ascii_lowercase();
1218 match key.as_str() {
1219 "text" => criteria.text = Some(raw_value.to_string()),
1220 "textcontains" => criteria.text_contains = Some(raw_value.to_string()),
1221 "textstartswith" => criteria.text_starts_with = Some(raw_value.to_string()),
1222 "textmatches" => criteria.text_matches = Some(raw_value.to_string()),
1223 "classname" => criteria.class_name = Some(raw_value.to_string()),
1224 "classnamematches" => criteria.class_name_matches = Some(raw_value.to_string()),
1225 "description" | "desc" => criteria.content_desc = Some(raw_value.to_string()),
1226 "descriptioncontains" | "desccontains" => {
1227 criteria.content_desc_contains = Some(raw_value.to_string())
1228 }
1229 "descriptionstartswith" | "descstartswith" => {
1230 criteria.content_desc_starts_with = Some(raw_value.to_string())
1231 }
1232 "descriptionmatches" | "descmatches" => {
1233 criteria.content_desc_matches = Some(raw_value.to_string())
1234 }
1235 "packagename" | "package" => criteria.package_name = Some(raw_value.to_string()),
1236 "packagenamematches" => criteria.package_name_matches = Some(raw_value.to_string()),
1237 "resourceid" => criteria.resource_id_exact = Some(raw_value.to_string()),
1238 "resourceidmatches" => criteria.resource_id_matches = Some(raw_value.to_string()),
1239 "checkable" => criteria.checkable = Some(parse_selector_bool(raw_value, &token)?),
1240 "checked" => criteria.checked = Some(parse_selector_bool(raw_value, &token)?),
1241 "clickable" => criteria.clickable = Some(parse_selector_bool(raw_value, &token)?),
1242 "longclickable" => {
1243 criteria.long_clickable = Some(parse_selector_bool(raw_value, &token)?)
1244 }
1245 "scrollable" => criteria.scrollable = Some(parse_selector_bool(raw_value, &token)?),
1246 "enabled" => criteria.enabled = Some(parse_selector_bool(raw_value, &token)?),
1247 "focusable" => criteria.focusable = Some(parse_selector_bool(raw_value, &token)?),
1248 "focused" => criteria.focused = Some(parse_selector_bool(raw_value, &token)?),
1249 "selected" => criteria.selected = Some(parse_selector_bool(raw_value, &token)?),
1250 "index" => criteria.index = Some(parse_selector_usize(raw_value, &token)?),
1251 "instance" => criteria.instance = Some(parse_selector_usize(raw_value, &token)?),
1252 other => return Err(format!("unsupported UiAutomator selector key `{other}`")),
1253 }
1254 }
1255 Ok(criteria)
1256}
1257
1258fn split_uiautomator_tokens(selector: &str) -> Vec<String> {
1259 let mut tokens = Vec::new();
1260 let mut current = String::new();
1261 let mut in_single = false;
1262 let mut in_double = false;
1263 for ch in selector.chars() {
1264 match ch {
1265 '\'' if !in_double => {
1266 in_single = !in_single;
1267 current.push(ch);
1268 }
1269 '"' if !in_single => {
1270 in_double = !in_double;
1271 current.push(ch);
1272 }
1273 ',' | ';' if !in_single && !in_double => {
1274 let trimmed = current.trim();
1275 if !trimmed.is_empty() {
1276 tokens.push(trimmed.to_string());
1277 }
1278 current.clear();
1279 }
1280 _ => current.push(ch),
1281 }
1282 }
1283 let trimmed = current.trim();
1284 if !trimmed.is_empty() {
1285 tokens.push(trimmed.to_string());
1286 }
1287 tokens
1288}
1289
1290fn split_uiautomator_token(token: &str) -> Result<(&str, &str), String> {
1291 let Some((key, value)) = token.split_once(['=', ':']) else {
1292 return Err(format!("invalid UiAutomator selector token `{token}`"));
1293 };
1294 Ok((key.trim(), value.trim()))
1295}
1296
1297fn parse_selector_bool(value: &str, token: &str) -> Result<bool, String> {
1298 parse_bool(value).ok_or_else(|| format!("invalid boolean value in `{token}`"))
1299}
1300
1301fn parse_selector_usize(value: &str, token: &str) -> Result<usize, String> {
1302 value
1303 .parse()
1304 .map_err(|_| format!("invalid integer value in `{token}`"))
1305}
1306
1307fn parse_css_criteria(selector: &str) -> Result<NodeCriteria, String> {
1308 let mut criteria = NodeCriteria::default();
1309 let mut remainder = selector.trim();
1310 while let Some(start) = remainder.find('[') {
1311 let end = remainder[start + 1..]
1312 .find(']')
1313 .map(|index| start + 1 + index)
1314 .ok_or_else(|| format!("unterminated CSS attribute selector in `{selector}`"))?;
1315 let attribute = &remainder[start + 1..end];
1316 let (name, value) = parse_attribute_filter(attribute)?;
1317 match name {
1318 "text" => criteria.text = Some(value),
1319 "content-desc" => criteria.content_desc = Some(value),
1320 "resource-id" => criteria.resource_id_exact = Some(value),
1321 _ => {
1322 return Err(format!(
1323 "unsupported CSS attribute selector `{name}` in `{selector}`"
1324 ));
1325 }
1326 }
1327 let mut next = String::with_capacity(remainder.len());
1328 next.push_str(remainder[..start].trim_end());
1329 next.push(' ');
1330 next.push_str(remainder[end + 1..].trim_start());
1331 let owned = next.trim().to_string();
1332 remainder = Box::leak(owned.into_boxed_str());
1333 }
1334
1335 let class_or_id = remainder.trim();
1336 if let Some(resource_id) = class_or_id.strip_prefix('#') {
1337 if resource_id.contains(':') {
1338 criteria.resource_id_exact = Some(resource_id.to_string());
1339 } else {
1340 criteria.resource_id_suffix = Some(format!(":id/{resource_id}"));
1341 }
1342 } else if let Some(class_name) = class_or_id.strip_prefix('.') {
1343 if !class_name.is_empty() {
1344 criteria.class_name = Some(class_name.to_string());
1345 }
1346 } else if !class_or_id.is_empty() {
1347 criteria.class_name = Some(class_or_id.to_string());
1348 }
1349 Ok(criteria)
1350}
1351
1352fn parse_xpath_criteria(selector: &str) -> Result<NodeCriteria, String> {
1353 let trimmed = selector.trim();
1354 let mut criteria = NodeCriteria::default();
1355 let mut path = trimmed;
1356 if let Some(stripped) = path.strip_prefix(".//") {
1357 path = stripped;
1358 } else if let Some(stripped) = path.strip_prefix("//") {
1359 path = stripped;
1360 }
1361
1362 let path = path.trim();
1363 let (node_pattern, predicates) = if let Some(start) = path.find('[') {
1364 let end = path.rfind(']').ok_or_else(|| format!("unterminated XPath in `{selector}`"))?;
1365 (&path[..start], Some(&path[start + 1..end]))
1366 } else {
1367 (path, None)
1368 };
1369
1370 let node_pattern = node_pattern.trim();
1371 if !node_pattern.is_empty() && node_pattern != "*" {
1372 criteria.class_name = Some(node_pattern.to_string());
1373 }
1374
1375 if let Some(predicates) = predicates {
1376 for predicate in predicates.split(" and ") {
1377 let predicate = predicate.trim().trim_matches(|ch| ch == '(' || ch == ')');
1378 if let Some(value) = parse_xpath_attr_equals(predicate, "@text") {
1379 criteria.text = Some(value);
1380 continue;
1381 }
1382 if let Some(value) = parse_xpath_attr_equals(predicate, "@content-desc") {
1383 criteria.content_desc = Some(value);
1384 continue;
1385 }
1386 if let Some(value) = parse_xpath_attr_equals(predicate, "@class") {
1387 criteria.class_name = Some(value);
1388 continue;
1389 }
1390 if let Some(value) = parse_xpath_attr_equals(predicate, "@resource-id") {
1391 if value.contains(':') {
1392 criteria.resource_id_exact = Some(value);
1393 } else {
1394 criteria.resource_id_suffix = Some(format!(":id/{value}"));
1395 }
1396 continue;
1397 }
1398 if let Some(value) = parse_xpath_resource_suffix(predicate) {
1399 criteria.resource_id_suffix = Some(value);
1400 continue;
1401 }
1402 }
1403 }
1404
1405 Ok(criteria)
1406}
1407
1408fn parse_attribute_filter(attribute: &str) -> Result<(&str, String), String> {
1409 let (name, value) = attribute
1410 .split_once('=')
1411 .ok_or_else(|| format!("unsupported CSS attribute selector `{attribute}`"))?;
1412 Ok((name.trim(), parse_quoted_value(value.trim())?))
1413}
1414
1415fn parse_xpath_attr_equals(predicate: &str, attr_name: &str) -> Option<String> {
1416 let remainder = predicate.strip_prefix(attr_name)?;
1417 let remainder = remainder.trim_start();
1418 let remainder = remainder.strip_prefix('=')?.trim_start();
1419 parse_quoted_value(remainder).ok()
1420}
1421
1422fn parse_xpath_resource_suffix(predicate: &str) -> Option<String> {
1423 let suffix_marker = ":id/";
1424 let start = predicate.find(suffix_marker)?;
1425 let candidate = &predicate[start - 1..];
1426 parse_quoted_value(candidate).ok()
1427}
1428
1429fn contains_text(actual: Option<&str>, expected: &str) -> bool {
1430 actual.is_some_and(|value| value.contains(expected))
1431}
1432
1433fn starts_with_text(actual: Option<&str>, expected: &str) -> bool {
1434 actual.is_some_and(|value| value.starts_with(expected))
1435}
1436
1437fn matches_regex(actual: Option<&str>, pattern: &str) -> bool {
1438 let Some(actual) = actual else {
1439 return false;
1440 };
1441 Regex::new(pattern).is_ok_and(|regex| regex.is_match(actual))
1442}
1443
1444fn parse_quoted_value(input: &str) -> Result<String, String> {
1445 let trimmed = input.trim();
1446 let quote = trimmed
1447 .chars()
1448 .next()
1449 .filter(|ch| *ch == '\'' || *ch == '"')
1450 .ok_or_else(|| format!("expected quoted value in `{input}`"))?;
1451 let end = trimmed[1..]
1452 .find(quote)
1453 .ok_or_else(|| format!("unterminated quoted value in `{input}`"))?;
1454 Ok(trimmed[1..1 + end].to_string())
1455}
1456
1457fn encode_adb_text(value: &str) -> String {
1458 let mut encoded = String::new();
1459 for ch in value.chars() {
1460 match ch {
1461 ' ' => encoded.push_str("%s"),
1462 '"' => encoded.push_str("\\\""),
1463 '\'' => encoded.push_str("\\'"),
1464 '&' | '|' | ';' | '<' | '>' | '(' | ')' | '$' | '\\' => {
1465 encoded.push('\\');
1466 encoded.push(ch);
1467 }
1468 _ => encoded.push(ch),
1469 }
1470 }
1471 encoded
1472}
1473
1474#[cfg(test)]
1475mod tests {
1476 use super::*;
1477
1478 #[tokio::test]
1479 async fn boots_android_runtime() {
1480 assert_eq!(boot().await, "android ready");
1481 }
1482
1483 #[test]
1484 fn defaults_to_uiautomator2() {
1485 let android = profile();
1486 assert_eq!(
1487 android.default_backend,
1488 MobileAutomationBackend::UiAutomator2
1489 );
1490 assert_eq!(android.backends, ANDROID_BACKENDS);
1491 assert!(android.capabilities.supports_native_views);
1492 assert!(android.capabilities.supports_webviews);
1493 }
1494
1495 #[test]
1496 fn exposes_android_runtime_milestones() {
1497 let readiness = runtime_readiness();
1498 assert_eq!(readiness.maturity, RuntimeMaturity::Scaffolding);
1499 assert!(
1500 readiness
1501 .missing_runtime_artifacts
1502 .iter()
1503 .any(|item| item.contains("server-side mobile session routing"))
1504 );
1505 }
1506
1507 #[test]
1508 fn connect_flow_matches_web_shape() {
1509 let options = ConnectOptions {
1510 platform: MobilePlatform::Android,
1511 device: Some("emulator-5554".to_string()),
1512 adb_endpoint: None,
1513 preserve_app_state: true,
1514 timeout_ms: Some(5_000),
1515 };
1516
1517 let command = connect_command(options.clone());
1518
1519 match command {
1520 MobileCommand::Connect(connect) => {
1521 assert_eq!(connect.platform, MobilePlatform::Android);
1522 assert_eq!(connect.device.as_deref(), Some("emulator-5554"));
1523 }
1524 _ => panic!("expected mobile connect command"),
1525 }
1526 }
1527
1528 #[test]
1529 fn launch_flow_keeps_apk_work_separate() {
1530 let browser_session = MobileBrowserSessionHandle {
1531 platform: MobilePlatform::Android,
1532 automation: MobileAutomationSessionInfo {
1533 backend: "uiautomator2".to_string(),
1534 session_id: "uiautomator2:emulator-5554".to_string(),
1535 note: "ready".to_string(),
1536 },
1537 device: DeviceTarget {
1538 platform: MobilePlatform::Android,
1539 device_id: "emulator-5554".to_string(),
1540 connection_kind: DeviceConnectionKind::Emulator,
1541 },
1542 };
1543
1544 let command = launch_command(
1545 browser_session,
1546 LaunchOptions {
1547 apk_path: Some("/tmp/app.apk".to_string()),
1548 app_id: Some("dev.allwright.sample".to_string()),
1549 launch_activity: Some(".MainActivity".to_string()),
1550 stop_before_launch: true,
1551 timeout_ms: Some(15_000),
1552 },
1553 );
1554
1555 match command {
1556 MobileCommand::LaunchApp { options, .. } => {
1557 assert_eq!(options.apk_path.as_deref(), Some("/tmp/app.apk"));
1558 assert_eq!(options.app_id.as_deref(), Some("dev.allwright.sample"));
1559 assert!(options.stop_before_launch);
1560 }
1561 _ => panic!("expected launch command"),
1562 }
1563 }
1564
1565 #[test]
1566 fn parse_adb_devices_extracts_friendly_names() {
1567 let devices = parse_adb_devices(
1568 "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",
1569 );
1570
1571 assert_eq!(devices.len(), 2);
1572 assert_eq!(devices[0].name.as_deref(), Some("Pixel 8 API 35"));
1573 assert_eq!(devices[1].name.as_deref(), Some("QA Galaxy S24"));
1574 assert_eq!(devices[0].connection_kind, DeviceConnectionKind::Emulator);
1575 assert_eq!(devices[1].connection_kind, DeviceConnectionKind::Usb);
1576 }
1577
1578 #[test]
1579 fn connect_uses_requested_device_when_provided() {
1580 let options = ConnectOptions {
1581 platform: MobilePlatform::Android,
1582 device: Some("R5CX123".to_string()),
1583 adb_endpoint: None,
1584 preserve_app_state: false,
1585 timeout_ms: None,
1586 };
1587 let devices = vec![
1588 AdbDevice {
1589 serial: "emulator-5554".to_string(),
1590 name: Some("Pixel 8 API 35".to_string()),
1591 connection_kind: DeviceConnectionKind::Emulator,
1592 state: "device".to_string(),
1593 },
1594 AdbDevice {
1595 serial: "R5CX123".to_string(),
1596 name: Some("QA Galaxy S24".to_string()),
1597 connection_kind: DeviceConnectionKind::Usb,
1598 state: "device".to_string(),
1599 },
1600 ];
1601
1602 let selected = select_device_for_connect(&options, &devices).expect("device should match");
1603 assert_eq!(selected.serial, "R5CX123");
1604 assert_eq!(selected.connection_kind, DeviceConnectionKind::Usb);
1605 }
1606
1607 #[test]
1608 fn connect_defaults_to_first_available_device() {
1609 let options = ConnectOptions {
1610 platform: MobilePlatform::Android,
1611 device: None,
1612 adb_endpoint: None,
1613 preserve_app_state: false,
1614 timeout_ms: None,
1615 };
1616 let devices = vec![
1617 AdbDevice {
1618 serial: "emulator-5554".to_string(),
1619 name: Some("Pixel 8 API 35".to_string()),
1620 connection_kind: DeviceConnectionKind::Emulator,
1621 state: "device".to_string(),
1622 },
1623 AdbDevice {
1624 serial: "R5CX123".to_string(),
1625 name: Some("QA Galaxy S24".to_string()),
1626 connection_kind: DeviceConnectionKind::Usb,
1627 state: "device".to_string(),
1628 },
1629 ];
1630
1631 let selected = select_device_for_connect(&options, &devices).expect("first device");
1632 assert_eq!(selected.serial, "emulator-5554");
1633 }
1634
1635 #[test]
1636 fn connect_matches_friendly_device_name() {
1637 let options = ConnectOptions {
1638 platform: MobilePlatform::Android,
1639 device: Some("qa galaxy s24".to_string()),
1640 adb_endpoint: None,
1641 preserve_app_state: false,
1642 timeout_ms: None,
1643 };
1644 let devices = vec![
1645 AdbDevice {
1646 serial: "emulator-5554".to_string(),
1647 name: Some("Pixel 8 API 35".to_string()),
1648 connection_kind: DeviceConnectionKind::Emulator,
1649 state: "device".to_string(),
1650 },
1651 AdbDevice {
1652 serial: "R5CX123".to_string(),
1653 name: Some("QA Galaxy S24".to_string()),
1654 connection_kind: DeviceConnectionKind::Usb,
1655 state: "device".to_string(),
1656 },
1657 ];
1658
1659 let selected = select_device_for_connect(&options, &devices).expect("friendly name match");
1660 assert_eq!(selected.serial, "R5CX123");
1661 }
1662
1663 #[test]
1664 fn connect_fails_when_requested_device_is_missing() {
1665 let options = ConnectOptions {
1666 platform: MobilePlatform::Android,
1667 device: Some("missing-device".to_string()),
1668 adb_endpoint: None,
1669 preserve_app_state: false,
1670 timeout_ms: None,
1671 };
1672 let devices = vec![AdbDevice {
1673 serial: "emulator-5554".to_string(),
1674 name: Some("Pixel 8 API 35".to_string()),
1675 connection_kind: DeviceConnectionKind::Emulator,
1676 state: "device".to_string(),
1677 }];
1678
1679 let error = select_device_for_connect(&options, &devices).expect_err("missing device");
1680 assert_eq!(
1681 error,
1682 ConnectSelectionError::RequestedDeviceNotFound {
1683 requested: "missing-device".to_string()
1684 }
1685 );
1686 }
1687
1688 #[test]
1689 fn parse_package_name_from_aapt_output_extracts_package() {
1690 let package_name = parse_package_name_from_aapt_badging(
1691 "package: name='dev.allwright.sample' versionCode='1' versionName='1.0'\n",
1692 )
1693 .expect("package name should parse");
1694 assert_eq!(package_name, "dev.allwright.sample");
1695 }
1696
1697 #[test]
1698 fn dump_source_keeps_page_context_separate() {
1699 let page_session = MobilePageSessionHandle {
1700 page_id: "emulator-5554:dev.allwright.sample".to_string(),
1701 package_name: Some("dev.allwright.sample".to_string()),
1702 activity_name: Some(".MainActivity".to_string()),
1703 webview_context: None,
1704 };
1705 assert_eq!(page_session.page_id, "emulator-5554:dev.allwright.sample");
1706 }
1707
1708 #[test]
1709 fn parses_uiautomator_selector_strategies() {
1710 let criteria = parse_uiautomator_criteria(
1711 "textContains=Account,descriptionStartsWith=Open,classNameMatches=android\\.widget\\..*,clickable=true,index=2,instance=1,resourceIdMatches=.*bottom_nav.*,packageName=com.example.airticket",
1712 )
1713 .expect("uia criteria should parse");
1714
1715 assert_eq!(criteria.text_contains.as_deref(), Some("Account"));
1716 assert_eq!(criteria.content_desc_starts_with.as_deref(), Some("Open"));
1717 assert_eq!(
1718 criteria.class_name_matches.as_deref(),
1719 Some("android\\.widget\\..*")
1720 );
1721 assert_eq!(criteria.clickable, Some(true));
1722 assert_eq!(criteria.index, Some(2));
1723 assert_eq!(criteria.instance, Some(1));
1724 assert_eq!(
1725 criteria.resource_id_matches.as_deref(),
1726 Some(".*bottom_nav.*")
1727 );
1728 assert_eq!(
1729 criteria.package_name.as_deref(),
1730 Some("com.example.airticket")
1731 );
1732 }
1733
1734 #[test]
1735 fn matches_uiautomator_selectors_against_dumped_nodes() {
1736 let nodes = parse_android_ui_nodes(sample_ui_hierarchy()).expect("xml should parse");
1737
1738 let account = find_node_by_selector(&nodes, "text=Account").expect("text selector");
1739 assert_eq!(
1740 account.resource_id.as_deref(),
1741 Some("com.example.airticket:id/bottom_nav_account")
1742 );
1743
1744 let by_desc =
1745 find_node_by_selector(&nodes, "descriptionContains=Account").expect("desc selector");
1746 assert_eq!(
1747 by_desc.resource_id.as_deref(),
1748 Some("com.example.airticket:id/bottom_nav_account")
1749 );
1750
1751 let by_resource = find_node_by_selector(
1752 &nodes,
1753 "resourceIdMatches=.*bottom_nav_.*,selected=true",
1754 )
1755 .expect("resource regex selector");
1756 assert_eq!(
1757 by_resource.resource_id.as_deref(),
1758 Some("com.example.airticket:id/bottom_nav_account")
1759 );
1760
1761 let by_class = find_node_by_selector(
1762 &nodes,
1763 "classNameMatches=android\\.widget\\..*,packageNameMatches=com\\.example\\..*,clickable=true,instance=1",
1764 )
1765 .expect("class/package/instance selector");
1766 assert_eq!(
1767 by_class.resource_id.as_deref(),
1768 Some("com.example.airticket:id/bottom_nav_account")
1769 );
1770 }
1771
1772 fn sample_ui_hierarchy() -> &'static str {
1773 r#"<?xml version='1.0' encoding='UTF-8' standalone='yes' ?>
1774<hierarchy rotation="0">
1775 <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]">
1776 <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]" />
1777 <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]" />
1778 <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]" />
1779 </node>
1780</hierarchy>"#
1781 }
1782}