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