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