Skip to main content

allwright_surface_mobile_android/
lib.rs

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, &registration).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::PointerInput {
870            browser_session,
871            page_session,
872            action,
873            x,
874            y,
875            to_x,
876            to_y,
877            delta_x,
878            delta_y,
879            duration_ms,
880        } => android_pointer_input(
881            &browser_session,
882            &page_session,
883            &action,
884            x,
885            y,
886            to_x,
887            to_y,
888            delta_x,
889            delta_y,
890            duration_ms,
891        )
892        .map(MobileCommandResult::PointerInput),
893        MobileCommand::KeyboardInput {
894            browser_session,
895            page_session,
896            key,
897            text,
898        } => android_keyboard_input(
899            &browser_session,
900            &page_session,
901            key.as_deref(),
902            text.as_deref(),
903        )
904        .map(MobileCommandResult::KeyboardInput),
905        MobileCommand::GetText {
906            browser_session,
907            page_session,
908            selector,
909            timeout_ms,
910        } => get_text(&browser_session, &page_session, &selector, timeout_ms)
911            .map(MobileCommandResult::GetText),
912        MobileCommand::GetInnerText {
913            browser_session,
914            page_session,
915            selector,
916            timeout_ms,
917        } => get_inner_text(&browser_session, &page_session, &selector, timeout_ms)
918            .map(MobileCommandResult::GetInnerText),
919        MobileCommand::WaitForSelector {
920            browser_session,
921            page_session,
922            selector,
923            visible,
924            timeout_ms,
925        } => wait_for_selector(
926            &browser_session,
927            &page_session,
928            &selector,
929            visible,
930            timeout_ms,
931        )
932        .map(MobileCommandResult::WaitForSelector),
933        MobileCommand::Screenshot {
934            browser_session,
935            page_session,
936            timeout_ms,
937            full_page,
938        } => screenshot(&browser_session, &page_session, timeout_ms, full_page)
939            .map(MobileCommandResult::Screenshot),
940    }
941}
942
943#[unsafe(no_mangle)]
944pub extern "C" fn allwright_plugin_api_version() -> u32 {
945    ALLWRIGHT_PLUGIN_API_VERSION
946}
947
948#[unsafe(no_mangle)]
949pub extern "C" fn allwright_plugin_id() -> *const c_char {
950    c"mobile-android".as_ptr()
951}
952
953#[unsafe(no_mangle)]
954pub unsafe extern "C" fn allwright_plugin_invoke(request_json: *const c_char) -> *mut c_char {
955    if request_json.is_null() {
956        return plugin_response(Err("plugin request pointer is null".to_string()));
957    }
958
959    let request = match unsafe { CStr::from_ptr(request_json) }.to_str() {
960        Ok(request) => request,
961        Err(error) => {
962            return plugin_response(Err(format!("plugin request is not valid UTF-8: {error}")));
963        }
964    };
965
966    let command: MobileCommand = match serde_json::from_str(request) {
967        Ok(command) => command,
968        Err(error) => {
969            return plugin_response(Err(format!(
970                "failed to parse mobile plugin request JSON: {error}"
971            )));
972        }
973    };
974
975    plugin_response(handle_plugin_command(command))
976}
977
978#[unsafe(no_mangle)]
979pub unsafe extern "C" fn allwright_plugin_free_string(value: *mut c_char) {
980    if !value.is_null() {
981        unsafe {
982            let _ = CString::from_raw(value);
983        }
984    }
985}
986
987fn parse_adb_devices(output: &str) -> Vec<AdbDevice> {
988    output
989        .lines()
990        .map(str::trim)
991        .filter(|line| !line.is_empty() && !line.starts_with("List of devices attached"))
992        .filter_map(|line| {
993            let mut parts = line.split_whitespace();
994            let serial = parts.next()?.to_string();
995            let state = parts.next()?.to_string();
996            let metadata = parts.collect::<Vec<_>>();
997            let mut name = None;
998            for item in metadata {
999                if let Some(model) = item.strip_prefix("model:") {
1000                    name = Some(model.replace('_', " "));
1001                    break;
1002                }
1003                if let Some(device_name) = item.strip_prefix("device:") {
1004                    name = Some(device_name.replace('_', " "));
1005                }
1006            }
1007
1008            Some(AdbDevice {
1009                connection_kind: classify_device_connection_kind(&serial),
1010                serial,
1011                name,
1012                state,
1013            })
1014        })
1015        .collect()
1016}
1017
1018fn classify_device_connection_kind(serial: &str) -> DeviceConnectionKind {
1019    if serial.starts_with("emulator-") {
1020        return DeviceConnectionKind::Emulator;
1021    }
1022    if serial.contains(':') {
1023        return DeviceConnectionKind::RemoteAdb;
1024    }
1025    DeviceConnectionKind::Usb
1026}
1027
1028fn run_adb(args: &[&str]) -> Result<String, String> {
1029    run_command(&adb_command_path(), args)
1030}
1031
1032fn run_adb_for_device(device_id: &str, args: &[&str]) -> Result<String, String> {
1033    let mut all_args = vec!["-s", device_id];
1034    all_args.extend_from_slice(args);
1035    run_command(&adb_command_path(), &all_args)
1036}
1037
1038fn run_adb_bytes_for_device(device_id: &str, args: &[&str]) -> Result<Vec<u8>, String> {
1039    let mut all_args = vec!["-s", device_id];
1040    all_args.extend_from_slice(args);
1041    run_command_bytes(&adb_command_path(), &all_args)
1042}
1043
1044fn adb_command_path() -> String {
1045    env::var("ALLWRIGHT_ANDROID_ADB").unwrap_or_else(|_| "adb".to_string())
1046}
1047
1048fn run_command(command: &str, args: &[&str]) -> Result<String, String> {
1049    let output = run_command_output(command, args)?;
1050    Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
1051}
1052
1053fn run_command_bytes(command: &str, args: &[&str]) -> Result<Vec<u8>, String> {
1054    Ok(run_command_output(command, args)?.stdout)
1055}
1056
1057fn run_command_output(command: &str, args: &[&str]) -> Result<std::process::Output, String> {
1058    let output = Command::new(command)
1059        .args(args)
1060        .output()
1061        .map_err(|error| format!("failed to run `{command}`: {error}"))?;
1062    if output.status.success() {
1063        return Ok(output);
1064    }
1065
1066    let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
1067    let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
1068    let detail = if !stderr.is_empty() { stderr } else { stdout };
1069    Err(format!(
1070        "`{command} {}` failed with status {}: {}",
1071        args.join(" "),
1072        output
1073            .status
1074            .code()
1075            .map_or_else(|| "signal".to_string(), |code| code.to_string()),
1076        detail
1077    ))
1078}
1079
1080#[derive(Debug)]
1081struct ResolvedApkSource {
1082    path: PathBuf,
1083    _downloaded_file: Option<PathBuf>,
1084}
1085
1086fn resolve_apk_source(apk_path_or_url: &str) -> Result<ResolvedApkSource, String> {
1087    if is_remote_apk_source(apk_path_or_url) {
1088        let downloaded_path = download_apk_to_temp(apk_path_or_url)?;
1089        return Ok(ResolvedApkSource {
1090            path: downloaded_path.clone(),
1091            _downloaded_file: Some(downloaded_path),
1092        });
1093    }
1094
1095    let apk = Path::new(apk_path_or_url);
1096    if !apk.is_file() {
1097        return Err(format!("APK path does not exist: {}", apk.display()));
1098    }
1099    Ok(ResolvedApkSource {
1100        path: apk.to_path_buf(),
1101        _downloaded_file: None,
1102    })
1103}
1104
1105fn is_remote_apk_source(apk_path_or_url: &str) -> bool {
1106    let lowered = apk_path_or_url.trim().to_ascii_lowercase();
1107    lowered.starts_with("http://") || lowered.starts_with("https://")
1108}
1109
1110fn download_apk_to_temp(url: &str) -> Result<PathBuf, String> {
1111    let response = reqwest::blocking::get(url)
1112        .map_err(|error| format!("failed to download APK from `{url}`: {error}"))?;
1113    if !response.status().is_success() {
1114        return Err(format!(
1115            "failed to download APK from `{url}`: HTTP {}",
1116            response.status()
1117        ));
1118    }
1119
1120    let bytes = response
1121        .bytes()
1122        .map_err(|error| format!("failed to read APK download from `{url}`: {error}"))?;
1123    let timestamp = SystemTime::now()
1124        .duration_since(UNIX_EPOCH)
1125        .map_err(|error| format!("failed to resolve system time for APK download: {error}"))?
1126        .as_millis();
1127    let path = env::temp_dir().join(format!("allwright-mobile-android-{timestamp}.apk"));
1128    fs::write(&path, bytes.as_ref()).map_err(|error| {
1129        format!(
1130            "failed to write downloaded APK to {}: {error}",
1131            path.display()
1132        )
1133    })?;
1134    Ok(path)
1135}
1136
1137fn resolve_package_name_from_apk_path(apk_path: &Path) -> Result<String, String> {
1138    let apk_path = apk_path.to_string_lossy().into_owned();
1139    if let Ok(output) = run_command("aapt", &["dump", "badging", &apk_path]) {
1140        if let Some(package_name) = parse_package_name_from_aapt_badging(&output) {
1141            return Ok(package_name);
1142        }
1143    }
1144
1145    if let Ok(output) = run_command("apkanalyzer", &["manifest", "application-id", &apk_path]) {
1146        let package_name = output.trim();
1147        if !package_name.is_empty() {
1148            return Ok(package_name.to_string());
1149        }
1150    }
1151
1152    Err(
1153        "could not resolve the app package name from the APK. Provide `--app-id`, or install `aapt`/`apkanalyzer`."
1154            .to_string(),
1155    )
1156}
1157
1158fn parse_package_name_from_aapt_badging(output: &str) -> Option<String> {
1159    output.lines().find_map(|line| {
1160        let package_line = line.trim();
1161        if !package_line.starts_with("package: ") {
1162            return None;
1163        }
1164        let needle = "name='";
1165        let start = package_line.find(needle)? + needle.len();
1166        let remainder = &package_line[start..];
1167        let end = remainder.find('\'')?;
1168        Some(remainder[..end].to_string())
1169    })
1170}
1171
1172fn current_foreground_app(device_id: &str) -> Result<ForegroundAppInfo, String> {
1173    let window_dump = run_adb_for_device(device_id, &["shell", "dumpsys", "window", "windows"])
1174        .or_else(|_| run_adb_for_device(device_id, &["shell", "dumpsys", "window"]))?;
1175    Ok(parse_foreground_app_from_dumpsys(&window_dump))
1176}
1177
1178fn android_hooks() -> &'static Mutex<HashMap<String, AndroidHookState>> {
1179    ANDROID_HOOKS.get_or_init(|| Mutex::new(HashMap::new()))
1180}
1181
1182fn android_choosers() -> &'static Mutex<HashMap<String, AndroidChooserState>> {
1183    ANDROID_CHOOSERS.get_or_init(|| Mutex::new(HashMap::new()))
1184}
1185
1186fn android_downloads() -> &'static Mutex<HashMap<String, AndroidDownloadState>> {
1187    ANDROID_DOWNLOADS.get_or_init(|| Mutex::new(HashMap::new()))
1188}
1189
1190fn list_public_downloads(device_id: &str) -> Result<HashMap<String, String>, String> {
1191    let output = run_adb_for_device(
1192        device_id,
1193        &["shell", "find", "/sdcard/Download", "-type", "f"],
1194    )?;
1195    output
1196        .lines()
1197        .map(str::trim)
1198        .filter(|path| !path.is_empty())
1199        .map(|path| {
1200            let signature = run_adb_for_device(device_id, &["shell", "stat", "-c", "%s:%Y", path])?;
1201            Ok((path.to_string(), signature))
1202        })
1203        .collect()
1204}
1205
1206fn changed_public_download(
1207    before: &HashMap<String, String>,
1208    after: &HashMap<String, String>,
1209) -> Option<String> {
1210    after
1211        .iter()
1212        .find(|(path, signature)| before.get(*path) != Some(*signature))
1213        .map(|(path, _)| path.clone())
1214}
1215
1216fn android_file_size(device_id: &str, path: &str) -> Result<u64, String> {
1217    run_adb_for_device(device_id, &["shell", "stat", "-c", "%s", path])?
1218        .trim()
1219        .parse::<u64>()
1220        .map_err(|error| format!("Android file size is invalid for {path}: {error}"))
1221}
1222
1223fn is_android_picker(package: Option<&str>) -> bool {
1224    package.is_some_and(|package| {
1225        let package = package.to_ascii_lowercase();
1226        package.contains("documentsui")
1227            || package.contains("filepicker")
1228            || package.ends_with(".files")
1229    })
1230}
1231
1232fn register_android_hook(
1233    browser_session: &MobileBrowserSessionHandle,
1234    page_session: &MobilePageSessionHandle,
1235    hook_type: MobileHookType,
1236) -> Result<MobileHookRegistration, String> {
1237    accessibility::validate_scope(browser_session, page_session, "hook")?;
1238    let registration_id = accessibility::unique_id("android-hook");
1239    let device_id = browser_session.device.device_id.clone();
1240    let state = match hook_type {
1241        MobileHookType::FileChooser => AndroidHookState::FileChooser {
1242            device_id: device_id.clone(),
1243            source_package: current_foreground_app(&device_id)?.current_package,
1244        },
1245        MobileHookType::Download => AndroidHookState::Download {
1246            device_id: device_id.clone(),
1247            existing_files: list_public_downloads(&device_id)?,
1248        },
1249    };
1250    android_hooks()
1251        .lock()
1252        .map_err(|_| "Android hook registry is unavailable".to_string())?
1253        .insert(registration_id.clone(), state);
1254    Ok(MobileHookRegistration {
1255        opaque_state: registration_id,
1256    })
1257}
1258
1259fn poll_android_hook(
1260    browser_session: &MobileBrowserSessionHandle,
1261    registration: &MobileHookRegistration,
1262) -> Result<MobileHookResult, String> {
1263    let registration_id = &registration.opaque_state;
1264    let state = android_hooks()
1265        .lock()
1266        .map_err(|_| "Android hook registry is unavailable".to_string())?
1267        .get(registration_id)
1268        .cloned()
1269        .ok_or_else(|| "Android hook registration is no longer available".to_string())?;
1270    match state {
1271        AndroidHookState::FileChooser {
1272            device_id,
1273            source_package,
1274        } => {
1275            if device_id != browser_session.device.device_id {
1276                return Err("Android hook belongs to a different device".to_string());
1277            }
1278            let foreground = current_foreground_app(&device_id)?;
1279            if foreground.current_package == source_package
1280                || !is_android_picker(foreground.current_package.as_deref())
1281            {
1282                return Err("Android file chooser hook is still waiting for a picker".to_string());
1283            }
1284            let chooser_id = accessibility::unique_id("android-chooser");
1285            android_choosers()
1286                .lock()
1287                .map_err(|_| "Android chooser registry is unavailable".to_string())?
1288                .insert(chooser_id.clone(), AndroidChooserState { device_id });
1289            android_hooks()
1290                .lock()
1291                .map_err(|_| "Android hook registry is unavailable".to_string())?
1292                .remove(registration_id);
1293            Ok(MobileHookResult::FileChooser(MobileFileChooserInfo {
1294                file_chooser_id: chooser_id,
1295                is_multiple: false,
1296                note: "Android system document picker opened".to_string(),
1297            }))
1298        }
1299        AndroidHookState::Download {
1300            device_id,
1301            existing_files,
1302        } => {
1303            if device_id != browser_session.device.device_id {
1304                return Err("Android hook belongs to a different device".to_string());
1305            }
1306            let current = list_public_downloads(&device_id)?;
1307            let Some(device_path) = changed_public_download(&existing_files, &current) else {
1308                return Err(
1309                    "Android download hook is still waiting for a public download".to_string(),
1310                );
1311            };
1312            let suggested_filename = Path::new(&device_path)
1313                .file_name()
1314                .and_then(|name| name.to_str())
1315                .unwrap_or("download")
1316                .to_string();
1317            let download_id = accessibility::unique_id("android-download");
1318            android_downloads()
1319                .lock()
1320                .map_err(|_| "Android download registry is unavailable".to_string())?
1321                .insert(
1322                    download_id.clone(),
1323                    AndroidDownloadState {
1324                        device_id,
1325                        device_path,
1326                        suggested_filename: suggested_filename.clone(),
1327                        observed_size: None,
1328                    },
1329                );
1330            android_hooks()
1331                .lock()
1332                .map_err(|_| "Android hook registry is unavailable".to_string())?
1333                .remove(registration_id);
1334            Ok(MobileHookResult::Download(MobileDownloadInfo {
1335                download_id,
1336                suggested_filename,
1337                note: "Android public download started".to_string(),
1338            }))
1339        }
1340    }
1341}
1342
1343fn set_android_file_chooser_files(
1344    browser_session: &MobileBrowserSessionHandle,
1345    page_session: &MobilePageSessionHandle,
1346    file_chooser_id: &str,
1347    files: &[String],
1348) -> Result<MobileFileChooserFilesSetInfo, String> {
1349    accessibility::validate_scope(browser_session, page_session, "file chooser")?;
1350    if files.len() != 1 {
1351        return Err("Android file chooser currently supports exactly one file".to_string());
1352    }
1353    let chooser = android_choosers()
1354        .lock()
1355        .map_err(|_| "Android chooser registry is unavailable".to_string())?
1356        .get(file_chooser_id)
1357        .cloned()
1358        .ok_or_else(|| format!("unknown Android file chooser {file_chooser_id}"))?;
1359    if chooser.device_id != browser_session.device.device_id {
1360        return Err("Android file chooser belongs to a different device".to_string());
1361    }
1362    let source = fs::canonicalize(&files[0])
1363        .map_err(|error| format!("resolve staged upload {}: {error}", files[0]))?;
1364    let filename = source
1365        .file_name()
1366        .and_then(|name| name.to_str())
1367        .ok_or_else(|| "staged upload requires a valid file name".to_string())?;
1368    let remote_path = format!("/sdcard/Download/{filename}");
1369    run_adb_for_device(
1370        &chooser.device_id,
1371        &["push", &source.to_string_lossy(), &remote_path],
1372    )?;
1373    let _ = run_adb_for_device(
1374        &chooser.device_id,
1375        &[
1376            "shell",
1377            "am",
1378            "broadcast",
1379            "-a",
1380            "android.intent.action.MEDIA_SCANNER_SCAN_FILE",
1381            "-d",
1382            &format!("file://{remote_path}"),
1383        ],
1384    );
1385    let source_snapshot = adb_dump_source(&chooser.device_id)?;
1386    let nodes = parse_android_ui_nodes(&source_snapshot.source)?;
1387    let node = nodes
1388        .iter()
1389        .find(|node| {
1390            node.text.as_deref() == Some(filename) || node.content_desc.as_deref() == Some(filename)
1391        })
1392        .ok_or_else(|| format!("Android picker does not show staged file {filename}"))?;
1393    let (x, y) = node
1394        .bounds
1395        .ok_or_else(|| format!("Android picker file {filename} has no selectable bounds"))?
1396        .center();
1397    run_adb_for_device(
1398        &chooser.device_id,
1399        &["shell", "input", "tap", &x.to_string(), &y.to_string()],
1400    )?;
1401    android_choosers()
1402        .lock()
1403        .map_err(|_| "Android chooser registry is unavailable".to_string())?
1404        .remove(file_chooser_id);
1405    Ok(MobileFileChooserFilesSetInfo {
1406        file_chooser_id: file_chooser_id.to_string(),
1407        files: files.to_vec(),
1408        note: format!("staged and selected {filename} in the Android document picker"),
1409    })
1410}
1411
1412fn save_android_download(
1413    browser_session: &MobileBrowserSessionHandle,
1414    page_session: &MobilePageSessionHandle,
1415    download_id: &str,
1416    path: &str,
1417) -> Result<MobileDownloadSavedInfo, String> {
1418    accessibility::validate_scope(browser_session, page_session, "download")?;
1419    let mut downloads = android_downloads()
1420        .lock()
1421        .map_err(|_| "Android download registry is unavailable".to_string())?;
1422    let download = downloads
1423        .get_mut(download_id)
1424        .ok_or_else(|| format!("unknown Android download {download_id}"))?;
1425    if download.device_id != browser_session.device.device_id {
1426        return Err("Android download belongs to a different device".to_string());
1427    }
1428    let size = android_file_size(&download.device_id, &download.device_path)?;
1429    if download.observed_size != Some(size) {
1430        download.observed_size = Some(size);
1431        return Err("Android download is still in progress".to_string());
1432    }
1433    if let Some(parent) = Path::new(path).parent() {
1434        fs::create_dir_all(parent)
1435            .map_err(|error| format!("create Android download staging directory: {error}"))?;
1436    }
1437    run_adb_for_device(&download.device_id, &["pull", &download.device_path, path])?;
1438    let saved = fs::canonicalize(path)
1439        .map_err(|error| format!("resolve pulled Android download {path}: {error}"))?;
1440    let result = MobileDownloadSavedInfo {
1441        download_id: download_id.to_string(),
1442        path: saved.to_string_lossy().to_string(),
1443        suggested_filename: download.suggested_filename.clone(),
1444        size,
1445        note: "pulled completed Android download to server staging".to_string(),
1446    };
1447    downloads.remove(download_id);
1448    Ok(result)
1449}
1450
1451fn adb_click(
1452    device_id: &str,
1453    selector: &str,
1454    timeout_ms: Option<u32>,
1455) -> Result<UiAutomator2ClickInfo, String> {
1456    let snapshot = resolve_selector_snapshot(device_id, selector, timeout_ms)?;
1457    let bounds = snapshot
1458        .node
1459        .bounds
1460        .ok_or_else(|| format!("selector resolved without bounds: {}", snapshot.selector))?;
1461    let (x, y) = bounds.center();
1462    let _ = run_adb_for_device(
1463        device_id,
1464        &["shell", "input", "tap", &x.to_string(), &y.to_string()],
1465    )?;
1466    Ok(UiAutomator2ClickInfo {
1467        resolved_selector: snapshot.selector,
1468        note: format!(
1469            "clicked Android element via native ADB input on {}/{}",
1470            snapshot
1471                .foreground
1472                .current_package
1473                .as_deref()
1474                .unwrap_or("<unknown>"),
1475            snapshot
1476                .foreground
1477                .current_activity
1478                .as_deref()
1479                .unwrap_or("<unknown>")
1480        ),
1481    })
1482}
1483
1484fn adb_fill(
1485    device_id: &str,
1486    selector: &str,
1487    fill_value: &str,
1488    timeout_ms: Option<u32>,
1489) -> Result<UiAutomator2FillInfo, String> {
1490    let snapshot = resolve_selector_snapshot(device_id, selector, timeout_ms)?;
1491    let bounds = snapshot
1492        .node
1493        .bounds
1494        .ok_or_else(|| format!("selector resolved without bounds: {}", snapshot.selector))?;
1495    let (x, y) = bounds.center();
1496    let _ = run_adb_for_device(
1497        device_id,
1498        &["shell", "input", "tap", &x.to_string(), &y.to_string()],
1499    )?;
1500    let _ = run_adb_for_device(
1501        device_id,
1502        &["shell", "input", "keyevent", "KEYCODE_MOVE_END"],
1503    )?;
1504    let existing_text = snapshot.node.text.as_deref().unwrap_or("");
1505    for _ in existing_text.chars() {
1506        let _ = run_adb_for_device(device_id, &["shell", "input", "keyevent", "KEYCODE_DEL"])?;
1507    }
1508    let encoded = encode_adb_text(fill_value);
1509    if !encoded.is_empty() {
1510        let _ = run_adb_for_device(device_id, &["shell", "input", "text", &encoded])?;
1511    }
1512    Ok(UiAutomator2FillInfo {
1513        resolved_selector: snapshot.selector,
1514        value: fill_value.to_string(),
1515        note: format!(
1516            "filled Android element via native ADB input on {}/{}",
1517            snapshot
1518                .foreground
1519                .current_package
1520                .as_deref()
1521                .unwrap_or("<unknown>"),
1522            snapshot
1523                .foreground
1524                .current_activity
1525                .as_deref()
1526                .unwrap_or("<unknown>")
1527        ),
1528    })
1529}
1530
1531fn adb_count_elements(
1532    device_id: &str,
1533    selector: &str,
1534    _timeout_ms: Option<u32>,
1535) -> Result<u32, String> {
1536    let source = adb_dump_source(device_id)?;
1537    let nodes = parse_android_ui_nodes(&source.source)?;
1538    let count =
1539        accessibility::matching_source_nodes(device_id, &source, &nodes, selector)?.len() as u32;
1540    Ok(count)
1541}
1542
1543fn adb_focus(
1544    device_id: &str,
1545    selector: &str,
1546    timeout_ms: Option<u32>,
1547) -> Result<UiAutomator2ClickInfo, String> {
1548    let snapshot = resolve_selector_snapshot(device_id, selector, timeout_ms)?;
1549    let bounds = snapshot
1550        .node
1551        .bounds
1552        .ok_or_else(|| format!("selector resolved without bounds: {}", snapshot.selector))?;
1553    let (x, y) = bounds.center();
1554    let _ = run_adb_for_device(
1555        device_id,
1556        &["shell", "input", "tap", &x.to_string(), &y.to_string()],
1557    )?;
1558    Ok(UiAutomator2ClickInfo {
1559        resolved_selector: snapshot.selector,
1560        note: format!(
1561            "focused Android element via native ADB tap on {}/{}",
1562            snapshot
1563                .foreground
1564                .current_package
1565                .as_deref()
1566                .unwrap_or("<unknown>"),
1567            snapshot
1568                .foreground
1569                .current_activity
1570                .as_deref()
1571                .unwrap_or("<unknown>")
1572        ),
1573    })
1574}
1575
1576fn adb_press_key(
1577    device_id: &str,
1578    selector: &str,
1579    key: &str,
1580    text: Option<&str>,
1581    timeout_ms: Option<u32>,
1582) -> Result<String, String> {
1583    let focused = adb_focus(device_id, selector, timeout_ms)?;
1584    if let Some(text) = text.filter(|value| !value.is_empty()) {
1585        let encoded = encode_adb_text(text);
1586        if !encoded.is_empty() {
1587            let _ = run_adb_for_device(device_id, &["shell", "input", "text", &encoded])?;
1588        }
1589        return Ok(format!("{} and inserted Android text input", focused.note));
1590    }
1591
1592    if let Some(keyevent) = android_keyevent_for(key) {
1593        let _ = run_adb_for_device(device_id, &["shell", "input", "keyevent", keyevent])?;
1594        return Ok(format!("{} and pressed Android key `{key}`", focused.note));
1595    }
1596
1597    if key.chars().count() == 1 {
1598        let encoded = encode_adb_text(key);
1599        if !encoded.is_empty() {
1600            let _ = run_adb_for_device(device_id, &["shell", "input", "text", &encoded])?;
1601        }
1602        return Ok(format!(
1603            "{} and inserted Android character input `{key}`",
1604            focused.note
1605        ));
1606    }
1607
1608    Err(format!(
1609        "unsupported Android key `{key}`; provide a single character, `text`, or a supported special key"
1610    ))
1611}
1612
1613fn scroll_element(
1614    browser_session: &MobileBrowserSessionHandle,
1615    page_session: &MobilePageSessionHandle,
1616    selector: &str,
1617    direction: &str,
1618    timeout_ms: Option<u32>,
1619) -> Result<MobilePressInfo, String> {
1620    accessibility::validate_scope(browser_session, page_session, selector)?;
1621    let snapshot =
1622        resolve_selector_snapshot(&browser_session.device.device_id, selector, timeout_ms)?;
1623    let bounds = snapshot
1624        .node
1625        .bounds
1626        .ok_or_else(|| format!("selector resolved without bounds: {}", snapshot.selector))?;
1627    let center_x = (bounds.left + bounds.right) / 2;
1628    let center_y = (bounds.top + bounds.bottom) / 2;
1629    let inset_x = ((bounds.right - bounds.left) / 5).max(1);
1630    let inset_y = ((bounds.bottom - bounds.top) / 5).max(1);
1631    let (start_x, start_y, end_x, end_y) = match direction {
1632        "down" => (
1633            center_x,
1634            bounds.bottom - inset_y,
1635            center_x,
1636            bounds.top + inset_y,
1637        ),
1638        "up" => (
1639            center_x,
1640            bounds.top + inset_y,
1641            center_x,
1642            bounds.bottom - inset_y,
1643        ),
1644        "right" => (
1645            bounds.right - inset_x,
1646            center_y,
1647            bounds.left + inset_x,
1648            center_y,
1649        ),
1650        "left" => (
1651            bounds.left + inset_x,
1652            center_y,
1653            bounds.right - inset_x,
1654            center_y,
1655        ),
1656        _ => return Err(format!("unsupported scroll direction `{direction}`")),
1657    };
1658    run_adb_for_device(
1659        &browser_session.device.device_id,
1660        &[
1661            "shell",
1662            "input",
1663            "swipe",
1664            &start_x.to_string(),
1665            &start_y.to_string(),
1666            &end_x.to_string(),
1667            &end_y.to_string(),
1668            "250",
1669        ],
1670    )?;
1671    Ok(MobilePressInfo {
1672        selector: snapshot.selector,
1673        key: direction.to_string(),
1674        note: format!("scrolled Android element {direction} through an ADB swipe"),
1675    })
1676}
1677
1678#[allow(clippy::too_many_arguments)]
1679fn android_pointer_input(
1680    browser_session: &MobileBrowserSessionHandle,
1681    page_session: &MobilePageSessionHandle,
1682    action: &str,
1683    x: f64,
1684    y: f64,
1685    to_x: Option<f64>,
1686    to_y: Option<f64>,
1687    delta_x: Option<f64>,
1688    delta_y: Option<f64>,
1689    duration_ms: Option<u32>,
1690) -> Result<MobilePressInfo, String> {
1691    accessibility::validate_scope(
1692        browser_session,
1693        page_session,
1694        "aw={\"kind\":\"role\",\"role\":\"application\"}",
1695    )?;
1696    if !x.is_finite() || !y.is_finite() {
1697        return Err("pointer coordinates must be finite".to_string());
1698    }
1699    let device_id = &browser_session.device.device_id;
1700    let (x, y) = (x.round() as i32, y.round() as i32);
1701    match action {
1702        "tap" => {
1703            run_adb_for_device(
1704                device_id,
1705                &["shell", "input", "tap", &x.to_string(), &y.to_string()],
1706            )?;
1707        }
1708        "double_tap" => {
1709            for _ in 0..2 {
1710                run_adb_for_device(
1711                    device_id,
1712                    &["shell", "input", "tap", &x.to_string(), &y.to_string()],
1713                )?;
1714                std::thread::sleep(Duration::from_millis(70));
1715            }
1716        }
1717        "swipe" | "scroll" => {
1718            let end_x = to_x.unwrap_or(x as f64 - delta_x.unwrap_or(0.0)).round() as i32;
1719            let end_y = to_y.unwrap_or(y as f64 - delta_y.unwrap_or(0.0)).round() as i32;
1720            run_adb_for_device(
1721                device_id,
1722                &[
1723                    "shell",
1724                    "input",
1725                    "swipe",
1726                    &x.to_string(),
1727                    &y.to_string(),
1728                    &end_x.to_string(),
1729                    &end_y.to_string(),
1730                    &duration_ms.unwrap_or(250).to_string(),
1731                ],
1732            )?;
1733        }
1734        _ => return Err(format!("unsupported Android pointer action `{action}`")),
1735    }
1736    Ok(MobilePressInfo {
1737        selector: format!("coordinate({x}, {y})"),
1738        key: action.to_string(),
1739        note: format!("performed live Android {action} through ADB input"),
1740    })
1741}
1742
1743fn android_keyboard_input(
1744    browser_session: &MobileBrowserSessionHandle,
1745    page_session: &MobilePageSessionHandle,
1746    key: Option<&str>,
1747    text: Option<&str>,
1748) -> Result<MobilePressInfo, String> {
1749    accessibility::validate_scope(
1750        browser_session,
1751        page_session,
1752        "aw={\"kind\":\"role\",\"role\":\"application\"}",
1753    )?;
1754    let device_id = &browser_session.device.device_id;
1755    if let Some(text) = text.filter(|value| !value.is_empty()) {
1756        let encoded = encode_adb_text(text);
1757        if !encoded.is_empty() {
1758            run_adb_for_device(device_id, &["shell", "input", "text", &encoded])?;
1759        }
1760    } else if let Some(key) = key {
1761        let keyevent = android_keyevent_for(key)
1762            .ok_or_else(|| format!("unsupported Android live key `{key}`"))?;
1763        run_adb_for_device(device_id, &["shell", "input", "keyevent", keyevent])?;
1764    } else {
1765        return Err("keyboard input requires `key` or `text`".to_string());
1766    }
1767    Ok(MobilePressInfo {
1768        selector: "focused element".to_string(),
1769        key: key.unwrap_or("text").to_string(),
1770        note: "sent live keyboard input through ADB".to_string(),
1771    })
1772}
1773
1774fn adb_get_text(
1775    device_id: &str,
1776    selector: &str,
1777    timeout_ms: Option<u32>,
1778) -> Result<String, String> {
1779    let snapshot = resolve_selector_snapshot(device_id, selector, timeout_ms)?;
1780    Ok(snapshot
1781        .node
1782        .text
1783        .clone()
1784        .or_else(|| snapshot.node.content_desc.clone())
1785        .unwrap_or_default())
1786}
1787
1788fn capture_element(
1789    browser_session: &MobileBrowserSessionHandle,
1790    page_session: &MobilePageSessionHandle,
1791    kind: &str,
1792    selector: &str,
1793    attribute_name: &str,
1794    timeout_ms: Option<u32>,
1795) -> Result<CaptureInfo, String> {
1796    accessibility::validate_scope(browser_session, page_session, "capture")?;
1797    let snapshot =
1798        resolve_selector_snapshot(&browser_session.device.device_id, selector, timeout_ms)?;
1799    let node = snapshot.node;
1800    let mut result = CaptureInfo::default();
1801    match kind {
1802        "input_value" => result.value = Some(node.text.unwrap_or_default()),
1803        "checked" => result.checked = Some(node.checked.unwrap_or(false)),
1804        "attribute" => {
1805            result.value = android_node_attribute(&node, attribute_name);
1806        }
1807        "bounding_box" => {
1808            result.bounding_box = node.bounds.and_then(|bounds| {
1809                let width = bounds.right - bounds.left;
1810                let height = bounds.bottom - bounds.top;
1811                (width > 0 && height > 0).then_some(BoundingBox {
1812                    x: f64::from(bounds.left),
1813                    y: f64::from(bounds.top),
1814                    width: f64::from(width),
1815                    height: f64::from(height),
1816                })
1817            });
1818        }
1819        _ => {
1820            return Err(format!(
1821                "capture kind `{kind}` is not supported for Android native apps"
1822            ));
1823        }
1824    }
1825    Ok(result)
1826}
1827
1828fn android_node_attribute(node: &AndroidUiNode, name: &str) -> Option<String> {
1829    let bool_string = |value: Option<bool>| value.map(|value| value.to_string());
1830    match name {
1831        "text" | "value" => node.text.clone(),
1832        "content-desc" | "contentDescription" | "description" => node.content_desc.clone(),
1833        "resource-id" | "resourceId" | "id" => node.resource_id.clone(),
1834        "class" | "className" => node.class_name.clone(),
1835        "package" | "packageName" => node.package_name.clone(),
1836        "checkable" => bool_string(node.checkable),
1837        "checked" => bool_string(node.checked),
1838        "clickable" => bool_string(node.clickable),
1839        "long-clickable" | "longClickable" => bool_string(node.long_clickable),
1840        "scrollable" => bool_string(node.scrollable),
1841        "enabled" => bool_string(node.enabled),
1842        "focusable" => bool_string(node.focusable),
1843        "focused" => bool_string(node.focused),
1844        "editable" => Some(
1845            (node.enabled.unwrap_or(true)
1846                && node
1847                    .class_name
1848                    .as_deref()
1849                    .is_some_and(|class_name| class_name.ends_with("EditText")))
1850            .to_string(),
1851        ),
1852        "selected" => bool_string(node.selected),
1853        "password" => bool_string(node.password),
1854        "index" => node.index.map(|value| value.to_string()),
1855        "bounds" => node.bounds.map(|bounds| {
1856            format!(
1857                "[{},{}][{},{}]",
1858                bounds.left, bounds.top, bounds.right, bounds.bottom
1859            )
1860        }),
1861        _ => None,
1862    }
1863}
1864
1865fn adb_wait_for_selector(
1866    device_id: &str,
1867    selector: &str,
1868    visible: bool,
1869    _timeout_ms: Option<u32>,
1870) -> Result<(), String> {
1871    let snapshot = resolve_selector_snapshot(device_id, selector, None)?;
1872    if visible && !node_is_visible(&snapshot.node) {
1873        return Err(format!("selector found but not visible: {selector}"));
1874    }
1875    Ok(())
1876}
1877
1878fn adb_screenshot(
1879    device_id: &str,
1880    _timeout_ms: Option<u32>,
1881) -> Result<MobileScreenshotInfo, String> {
1882    let png_data = run_adb_bytes_for_device(device_id, &["exec-out", "screencap", "-p"])?;
1883    if png_data.is_empty() {
1884        return Err("adb returned an empty screenshot payload".to_string());
1885    }
1886    let foreground = current_foreground_app(device_id)?;
1887    Ok(MobileScreenshotInfo {
1888        png_data,
1889        note: format!(
1890            "captured Android screenshot via adb exec-out screencap on {}/{}",
1891            foreground.current_package.as_deref().unwrap_or("<unknown>"),
1892            foreground
1893                .current_activity
1894                .as_deref()
1895                .unwrap_or("<unknown>")
1896        ),
1897    })
1898}
1899
1900fn adb_dump_source(device_id: &str) -> Result<UiAutomator2SourceInfo, String> {
1901    let remote_path = "/data/local/tmp/allwright-window.xml";
1902    let _ = run_adb_for_device(device_id, &["shell", "uiautomator", "dump", remote_path])?;
1903    let source = run_adb_for_device(device_id, &["shell", "cat", remote_path])?;
1904    let foreground = current_foreground_app(device_id)?;
1905    Ok(UiAutomator2SourceInfo {
1906        source,
1907        current_package: foreground.current_package,
1908        current_activity: foreground.current_activity,
1909    })
1910}
1911
1912fn screenshot(
1913    browser_session: &MobileBrowserSessionHandle,
1914    _page_session: &MobilePageSessionHandle,
1915    timeout_ms: Option<u32>,
1916    full_page: bool,
1917) -> Result<MobileScreenshotInfo, String> {
1918    if full_page {
1919        adb_full_page_screenshot(&browser_session.device.device_id, timeout_ms)
1920    } else {
1921        adb_screenshot(&browser_session.device.device_id, timeout_ms)
1922    }
1923}
1924
1925const MAX_FULL_PAGE_SCREENSHOTS: usize = 20;
1926
1927fn adb_full_page_screenshot(
1928    device_id: &str,
1929    timeout_ms: Option<u32>,
1930) -> Result<MobileScreenshotInfo, String> {
1931    let first = decode_android_screenshot(adb_screenshot(device_id, timeout_ms)?.png_data)?;
1932    let width = first.width();
1933    let height = first.height();
1934    if width == 0 || height < 4 {
1935        return Err("Android returned an invalid screenshot size".to_string());
1936    }
1937
1938    let mut previous = first.clone();
1939    let mut stitched = first;
1940    let swipe_start = (height * 3 / 4) as i32;
1941    let swipe_end = (height / 4) as i32;
1942    for _ in 1..MAX_FULL_PAGE_SCREENSHOTS {
1943        run_adb_for_device(
1944            device_id,
1945            &[
1946                "shell",
1947                "input",
1948                "swipe",
1949                &(width as i32 / 2).to_string(),
1950                &swipe_start.to_string(),
1951                &(width as i32 / 2).to_string(),
1952                &swipe_end.to_string(),
1953                "250",
1954            ],
1955        )?;
1956        std::thread::sleep(Duration::from_millis(300));
1957
1958        let next = decode_android_screenshot(adb_screenshot(device_id, timeout_ms)?.png_data)?;
1959        if next.dimensions() != (width, height) {
1960            return Err(
1961                "Android screenshot dimensions changed while capturing a full page".to_string(),
1962            );
1963        }
1964        let overlap = find_screenshot_overlap(&previous, &next)?;
1965        if overlap == height {
1966            break;
1967        }
1968        append_screenshot(&mut stitched, &next, overlap)?;
1969        previous = next;
1970    }
1971
1972    let mut png_data = Vec::new();
1973    DynamicImage::ImageRgba8(stitched)
1974        .write_to(&mut std::io::Cursor::new(&mut png_data), ImageFormat::Png)
1975        .map_err(|error| format!("encode Android full-page screenshot: {error}"))?;
1976    let foreground = current_foreground_app(device_id)?;
1977    Ok(MobileScreenshotInfo {
1978        png_data,
1979        note: format!(
1980            "captured full-page Android screenshot via ADB scrolling on {}/{}",
1981            foreground.current_package.as_deref().unwrap_or("<unknown>"),
1982            foreground
1983                .current_activity
1984                .as_deref()
1985                .unwrap_or("<unknown>")
1986        ),
1987    })
1988}
1989
1990fn decode_android_screenshot(png_data: Vec<u8>) -> Result<RgbaImage, String> {
1991    image::load_from_memory_with_format(&png_data, ImageFormat::Png)
1992        .map_err(|error| format!("decode Android screenshot: {error}"))
1993        .map(DynamicImage::into_rgba8)
1994}
1995
1996fn find_screenshot_overlap(previous: &RgbaImage, next: &RgbaImage) -> Result<u32, String> {
1997    let (width, height) = previous.dimensions();
1998    if next.dimensions() != (width, height) {
1999        return Err("cannot stitch Android screenshots with different dimensions".to_string());
2000    }
2001    for overlap in ((height / 4)..=height).rev() {
2002        if screenshots_match_at_overlap(previous, next, overlap) {
2003            return Ok(overlap);
2004        }
2005    }
2006    Err(
2007        "could not align Android screenshots after scrolling; the foreground content changed"
2008            .to_string(),
2009    )
2010}
2011
2012fn screenshots_match_at_overlap(previous: &RgbaImage, next: &RgbaImage, overlap: u32) -> bool {
2013    let (width, height) = previous.dimensions();
2014    let row_step = (overlap / 24).max(1);
2015    let column_step = (width / 24).max(1);
2016    let mut matched = 0_u32;
2017    let mut sampled = 0_u32;
2018    for row in (0..overlap).step_by(row_step as usize) {
2019        for column in (0..width).step_by(column_step as usize) {
2020            let before = previous.get_pixel(column, height - overlap + row);
2021            let after = next.get_pixel(column, row);
2022            sampled += 1;
2023            if before == after {
2024                matched += 1;
2025            }
2026        }
2027    }
2028    sampled > 0 && matched * 100 >= sampled * 98
2029}
2030
2031fn append_screenshot(
2032    stitched: &mut RgbaImage,
2033    next: &RgbaImage,
2034    overlap: u32,
2035) -> Result<(), String> {
2036    let (width, height) = stitched.dimensions();
2037    let appended_height = next.height() - overlap;
2038    let combined_height = height
2039        .checked_add(appended_height)
2040        .ok_or_else(|| "Android full-page screenshot is too tall".to_string())?;
2041    let mut combined = RgbaImage::new(width, combined_height);
2042    image::imageops::replace(&mut combined, stitched, 0, 0);
2043    image::imageops::replace(&mut combined, next, 0, i64::from(height - overlap));
2044    *stitched = combined;
2045    Ok(())
2046}
2047
2048struct ResolvedSelectorSnapshot {
2049    selector: String,
2050    node: AndroidUiNode,
2051    foreground: ForegroundAppInfo,
2052}
2053
2054fn resolve_selector_snapshot(
2055    device_id: &str,
2056    selector: &str,
2057    _timeout_ms: Option<u32>,
2058) -> Result<ResolvedSelectorSnapshot, String> {
2059    let source = adb_dump_source(device_id)?;
2060    let nodes = parse_android_ui_nodes(&source.source)?;
2061    let node = accessibility::matching_source_nodes(device_id, &source, &nodes, selector)?
2062        .into_iter()
2063        .next()
2064        .ok_or_else(|| {
2065            let package_name = source.current_package.as_deref().unwrap_or("<unknown>");
2066            let activity_name = source.current_activity.as_deref().unwrap_or("<unknown>");
2067            format!("selector not found: {selector} (current app: {package_name}/{activity_name})")
2068        })?;
2069    Ok(ResolvedSelectorSnapshot {
2070        selector: selector.to_string(),
2071        node: node.clone(),
2072        foreground: ForegroundAppInfo {
2073            current_package: source.current_package,
2074            current_activity: source.current_activity,
2075        },
2076    })
2077}
2078
2079fn parse_foreground_app_from_dumpsys(output: &str) -> ForegroundAppInfo {
2080    for line in output.lines() {
2081        if !(line.contains("mCurrentFocus") || line.contains("mFocusedApp")) {
2082            continue;
2083        }
2084        if let Some((package_name, activity_name)) = extract_component_from_line(line) {
2085            return ForegroundAppInfo {
2086                current_package: Some(package_name),
2087                current_activity: Some(activity_name),
2088            };
2089        }
2090    }
2091    ForegroundAppInfo {
2092        current_package: None,
2093        current_activity: None,
2094    }
2095}
2096
2097fn extract_component_from_line(line: &str) -> Option<(String, String)> {
2098    for token in line.split_whitespace() {
2099        let cleaned = token.trim_matches(|ch: char| matches!(ch, '{' | '}' | ',' | ';'));
2100        let Some(slash_index) = cleaned.find('/') else {
2101            continue;
2102        };
2103        let package_name = cleaned[..slash_index].trim();
2104        let activity_name = cleaned[slash_index + 1..].trim();
2105        if package_name.is_empty()
2106            || activity_name.is_empty()
2107            || !package_name.contains('.')
2108            || package_name.contains('=')
2109        {
2110            continue;
2111        }
2112        return Some((package_name.to_string(), activity_name.to_string()));
2113    }
2114    None
2115}
2116
2117fn parse_android_ui_nodes(xml: &str) -> Result<Vec<AndroidUiNode>, String> {
2118    let mut nodes = Vec::new();
2119    let mut stack: Vec<usize> = Vec::new();
2120    let mut cursor = 0usize;
2121    while let Some(relative_start) = xml[cursor..].find('<') {
2122        let start = cursor + relative_start;
2123        let Some(relative_end) = xml[start..].find('>') else {
2124            break;
2125        };
2126        let end = start + relative_end;
2127        let tag = &xml[start + 1..end];
2128        let trimmed = tag.trim();
2129        if trimmed.starts_with("/node") {
2130            let _ = stack.pop();
2131        } else if trimmed.starts_with("node") {
2132            let self_closing = trimmed.ends_with('/');
2133            let attributes = parse_xml_attributes(trimmed);
2134            let node = AndroidUiNode {
2135                class_name: attributes.get("class").cloned(),
2136                resource_id: attributes.get("resource-id").cloned(),
2137                package_name: attributes.get("package").cloned(),
2138                text: attributes.get("text").cloned(),
2139                content_desc: attributes.get("content-desc").cloned(),
2140                checkable: attributes
2141                    .get("checkable")
2142                    .and_then(|value| parse_bool(value)),
2143                checked: attributes
2144                    .get("checked")
2145                    .and_then(|value| parse_bool(value)),
2146                clickable: attributes
2147                    .get("clickable")
2148                    .and_then(|value| parse_bool(value)),
2149                long_clickable: attributes
2150                    .get("long-clickable")
2151                    .and_then(|value| parse_bool(value)),
2152                scrollable: attributes
2153                    .get("scrollable")
2154                    .and_then(|value| parse_bool(value)),
2155                enabled: attributes
2156                    .get("enabled")
2157                    .and_then(|value| parse_bool(value)),
2158                focusable: attributes
2159                    .get("focusable")
2160                    .and_then(|value| parse_bool(value)),
2161                focused: attributes
2162                    .get("focused")
2163                    .and_then(|value| parse_bool(value)),
2164                selected: attributes
2165                    .get("selected")
2166                    .and_then(|value| parse_bool(value)),
2167                password: attributes
2168                    .get("password")
2169                    .and_then(|value| parse_bool(value)),
2170                index: attributes.get("index").and_then(|value| value.parse().ok()),
2171                bounds: attributes
2172                    .get("bounds")
2173                    .and_then(|value| parse_bounds(value)),
2174                parent_index: stack.last().copied(),
2175            };
2176            let index = nodes.len();
2177            nodes.push(node);
2178            if !self_closing {
2179                stack.push(index);
2180            }
2181        }
2182        cursor = end + 1;
2183    }
2184    Ok(nodes)
2185}
2186
2187fn parse_xml_attributes(tag: &str) -> std::collections::BTreeMap<String, String> {
2188    let mut attributes = std::collections::BTreeMap::new();
2189    let bytes = tag.as_bytes();
2190    let mut index = 0usize;
2191    while index < bytes.len() {
2192        while index < bytes.len() && !bytes[index].is_ascii_alphabetic() {
2193            index += 1;
2194        }
2195        let key_start = index;
2196        while index < bytes.len()
2197            && (bytes[index].is_ascii_alphanumeric() || matches!(bytes[index], b'-' | b'_'))
2198        {
2199            index += 1;
2200        }
2201        if key_start == index {
2202            continue;
2203        }
2204        let key = &tag[key_start..index];
2205        while index < bytes.len() && bytes[index].is_ascii_whitespace() {
2206            index += 1;
2207        }
2208        if index >= bytes.len() || bytes[index] != b'=' {
2209            continue;
2210        }
2211        index += 1;
2212        while index < bytes.len() && bytes[index].is_ascii_whitespace() {
2213            index += 1;
2214        }
2215        if index >= bytes.len() || bytes[index] != b'"' {
2216            continue;
2217        }
2218        index += 1;
2219        let value_start = index;
2220        while index < bytes.len() && bytes[index] != b'"' {
2221            index += 1;
2222        }
2223        if index > value_start {
2224            attributes.insert(
2225                key.to_string(),
2226                decode_xml_entities(&tag[value_start..index]),
2227            );
2228        }
2229        if index < bytes.len() {
2230            index += 1;
2231        }
2232    }
2233    attributes
2234}
2235
2236fn decode_xml_entities(value: &str) -> String {
2237    value
2238        .replace("&quot;", "\"")
2239        .replace("&apos;", "'")
2240        .replace("&lt;", "<")
2241        .replace("&gt;", ">")
2242        .replace("&amp;", "&")
2243}
2244
2245fn parse_bool(value: &str) -> Option<bool> {
2246    match value.trim().to_ascii_lowercase().as_str() {
2247        "true" => Some(true),
2248        "false" => Some(false),
2249        _ => None,
2250    }
2251}
2252
2253fn parse_bounds(value: &str) -> Option<AndroidBounds> {
2254    let trimmed = value.trim();
2255    let trimmed = trimmed.strip_prefix('[')?;
2256    let (left_top, right_bottom) = trimmed.split_once("][")?;
2257    let right_bottom = right_bottom.strip_suffix(']')?;
2258    let (left, top) = left_top.split_once(',')?;
2259    let (right, bottom) = right_bottom.split_once(',')?;
2260    Some(AndroidBounds {
2261        left: left.parse().ok()?,
2262        top: top.parse().ok()?,
2263        right: right.parse().ok()?,
2264        bottom: bottom.parse().ok()?,
2265    })
2266}
2267
2268fn matching_nodes_by_selector<'a>(
2269    nodes: &'a [AndroidUiNode],
2270    selector: &str,
2271) -> Result<Vec<&'a AndroidUiNode>, String> {
2272    let segments = parse_selector_segments(selector)?;
2273    if segments.len() == 1 && segments[0].value.starts_with("/hierarchy/") {
2274        return accessibility::resolve_absolute_path(nodes, &segments[0].value)
2275            .map(|index| index.map(|i| vec![&nodes[i]]).unwrap_or_default());
2276    }
2277    let criteria = segments
2278        .iter()
2279        .map(selector_segment_to_criteria)
2280        .collect::<Result<Vec<_>, _>>()?;
2281    let Some(last) = criteria.last() else {
2282        return Ok(Vec::new());
2283    };
2284    let mut matches = Vec::new();
2285    for (index, node) in nodes.iter().enumerate() {
2286        if !node_matches_criteria(node, last) {
2287            continue;
2288        }
2289        if ancestor_chain_matches(nodes, index, &criteria[..criteria.len().saturating_sub(1)]) {
2290            matches.push(node);
2291        }
2292    }
2293    if let Some(instance) = last.instance {
2294        return Ok(matches.get(instance).copied().into_iter().collect());
2295    }
2296    Ok(matches)
2297}
2298
2299#[cfg(test)]
2300fn find_node_by_selector<'a>(
2301    nodes: &'a [AndroidUiNode],
2302    selector: &str,
2303) -> Result<&'a AndroidUiNode, String> {
2304    matching_nodes_by_selector(nodes, selector)?
2305        .into_iter()
2306        .next()
2307        .ok_or_else(|| format!("no Android element matched selector {selector:?}"))
2308}
2309
2310fn node_is_visible(node: &AndroidUiNode) -> bool {
2311    if node.enabled == Some(false) {
2312        return false;
2313    }
2314    let Some(bounds) = node.bounds else {
2315        return false;
2316    };
2317    bounds.right > bounds.left && bounds.bottom > bounds.top
2318}
2319
2320fn android_keyevent_for(key: &str) -> Option<&'static str> {
2321    match key.trim().to_ascii_lowercase().as_str() {
2322        "enter" => Some("KEYCODE_ENTER"),
2323        "tab" => Some("KEYCODE_TAB"),
2324        "backspace" | "delete" => Some("KEYCODE_DEL"),
2325        "escape" | "esc" => Some("KEYCODE_ESCAPE"),
2326        "space" => Some("KEYCODE_SPACE"),
2327        "back" => Some("KEYCODE_BACK"),
2328        "home" => Some("KEYCODE_HOME"),
2329        "arrowleft" | "left" => Some("KEYCODE_DPAD_LEFT"),
2330        "arrowright" | "right" => Some("KEYCODE_DPAD_RIGHT"),
2331        "arrowup" | "up" => Some("KEYCODE_DPAD_UP"),
2332        "arrowdown" | "down" => Some("KEYCODE_DPAD_DOWN"),
2333        "center" => Some("KEYCODE_DPAD_CENTER"),
2334        _ => None,
2335    }
2336}
2337
2338fn ancestor_chain_matches(
2339    nodes: &[AndroidUiNode],
2340    node_index: usize,
2341    criteria_chain: &[NodeCriteria],
2342) -> bool {
2343    if criteria_chain.is_empty() {
2344        return true;
2345    }
2346    let mut current_parent = nodes[node_index].parent_index;
2347    for criteria in criteria_chain.iter().rev() {
2348        let mut matched_parent = None;
2349        let mut probe = current_parent;
2350        while let Some(index) = probe {
2351            if node_matches_criteria(&nodes[index], criteria) {
2352                matched_parent = Some(index);
2353                break;
2354            }
2355            probe = nodes[index].parent_index;
2356        }
2357        let Some(index) = matched_parent else {
2358            return false;
2359        };
2360        current_parent = nodes[index].parent_index;
2361    }
2362    true
2363}
2364
2365fn node_matches_criteria(node: &AndroidUiNode, criteria: &NodeCriteria) -> bool {
2366    if let Some(role) = criteria.semantic_role.as_deref()
2367        && android_semantic_role(node) != role
2368    {
2369        return false;
2370    }
2371    if let Some(matcher) = criteria.semantic_name.as_ref()
2372        && !semantic_matches_any(
2373            matcher,
2374            [node.content_desc.as_deref(), node.text.as_deref()],
2375        )
2376    {
2377        return false;
2378    }
2379    if let Some(matcher) = criteria.semantic_text.as_ref()
2380        && !semantic_text_matches(node.text.as_deref(), matcher)
2381    {
2382        return false;
2383    }
2384    if let Some(matcher) = criteria.semantic_label.as_ref()
2385        && !semantic_matches_any(
2386            matcher,
2387            [node.content_desc.as_deref(), node.text.as_deref()],
2388        )
2389    {
2390        return false;
2391    }
2392    if let Some(matcher) = criteria.semantic_test_id.as_ref()
2393        && !semantic_matches_any(
2394            matcher,
2395            [
2396                node.resource_id.as_deref(),
2397                node.resource_id
2398                    .as_deref()
2399                    .and_then(|value| value.rsplit('/').next()),
2400            ],
2401        )
2402    {
2403        return false;
2404    }
2405    if let Some(disabled) = criteria.semantic_disabled
2406        && node.enabled != Some(!disabled)
2407    {
2408        return false;
2409    }
2410    if let Some(expected) = criteria.class_name.as_deref()
2411        && node.class_name.as_deref() != Some(expected)
2412    {
2413        return false;
2414    }
2415    if let Some(pattern) = criteria.class_name_matches.as_deref()
2416        && !matches_regex(node.class_name.as_deref(), pattern)
2417    {
2418        return false;
2419    }
2420    if let Some(expected) = criteria.resource_id_exact.as_deref()
2421        && node.resource_id.as_deref() != Some(expected)
2422    {
2423        return false;
2424    }
2425    if let Some(expected_suffix) = criteria.resource_id_suffix.as_deref() {
2426        let Some(resource_id) = node.resource_id.as_deref() else {
2427            return false;
2428        };
2429        if !resource_id_matches_suffix(resource_id, expected_suffix) {
2430            return false;
2431        }
2432    }
2433    if let Some(pattern) = criteria.resource_id_matches.as_deref()
2434        && !matches_regex(node.resource_id.as_deref(), pattern)
2435    {
2436        return false;
2437    }
2438    if let Some(expected) = criteria.package_name.as_deref()
2439        && node.package_name.as_deref() != Some(expected)
2440    {
2441        return false;
2442    }
2443    if let Some(pattern) = criteria.package_name_matches.as_deref()
2444        && !matches_regex(node.package_name.as_deref(), pattern)
2445    {
2446        return false;
2447    }
2448    if let Some(expected) = criteria.text.as_deref()
2449        && node.text.as_deref() != Some(expected)
2450    {
2451        return false;
2452    }
2453    if let Some(expected) = criteria.text_contains.as_deref()
2454        && !contains_text(node.text.as_deref(), expected)
2455    {
2456        return false;
2457    }
2458    if let Some(expected) = criteria.text_starts_with.as_deref()
2459        && !starts_with_text(node.text.as_deref(), expected)
2460    {
2461        return false;
2462    }
2463    if let Some(pattern) = criteria.text_matches.as_deref()
2464        && !matches_regex(node.text.as_deref(), pattern)
2465    {
2466        return false;
2467    }
2468    if let Some(expected) = criteria.content_desc.as_deref()
2469        && node.content_desc.as_deref() != Some(expected)
2470    {
2471        return false;
2472    }
2473    if let Some(expected) = criteria.content_desc_contains.as_deref()
2474        && !contains_text(node.content_desc.as_deref(), expected)
2475    {
2476        return false;
2477    }
2478    if let Some(expected) = criteria.content_desc_starts_with.as_deref()
2479        && !starts_with_text(node.content_desc.as_deref(), expected)
2480    {
2481        return false;
2482    }
2483    if let Some(pattern) = criteria.content_desc_matches.as_deref()
2484        && !matches_regex(node.content_desc.as_deref(), pattern)
2485    {
2486        return false;
2487    }
2488    if let Some(expected) = criteria.checkable
2489        && node.checkable != Some(expected)
2490    {
2491        return false;
2492    }
2493    if let Some(expected) = criteria.checked
2494        && node.checked != Some(expected)
2495    {
2496        return false;
2497    }
2498    if let Some(expected) = criteria.clickable
2499        && node.clickable != Some(expected)
2500    {
2501        return false;
2502    }
2503    if let Some(expected) = criteria.long_clickable
2504        && node.long_clickable != Some(expected)
2505    {
2506        return false;
2507    }
2508    if let Some(expected) = criteria.scrollable
2509        && node.scrollable != Some(expected)
2510    {
2511        return false;
2512    }
2513    if let Some(expected) = criteria.enabled
2514        && node.enabled != Some(expected)
2515    {
2516        return false;
2517    }
2518    if let Some(expected) = criteria.focusable
2519        && node.focusable != Some(expected)
2520    {
2521        return false;
2522    }
2523    if let Some(expected) = criteria.focused
2524        && node.focused != Some(expected)
2525    {
2526        return false;
2527    }
2528    if let Some(expected) = criteria.selected
2529        && node.selected != Some(expected)
2530    {
2531        return false;
2532    }
2533    if let Some(expected) = criteria.index
2534        && node.index != Some(expected)
2535    {
2536        return false;
2537    }
2538    true
2539}
2540
2541fn android_semantic_role(node: &AndroidUiNode) -> &'static str {
2542    match node
2543        .class_name
2544        .as_deref()
2545        .unwrap_or("")
2546        .rsplit('.')
2547        .next()
2548        .unwrap_or("")
2549    {
2550        "Button" | "ImageButton" => "button",
2551        "EditText" => "textbox",
2552        "CheckBox" => "checkbox",
2553        "RadioButton" => "radio",
2554        "Switch" | "SwitchCompat" | "ToggleButton" => "switch",
2555        "SeekBar" => "slider",
2556        "ProgressBar" => "progressbar",
2557        "Spinner" => "combobox",
2558        "ListView" | "RecyclerView" => "list",
2559        "ImageView" => "img",
2560        "TextView" => "text",
2561        _ => "generic",
2562    }
2563}
2564
2565fn semantic_matches_any<'a>(
2566    matcher: &SemanticTextMatcher,
2567    values: impl IntoIterator<Item = Option<&'a str>>,
2568) -> bool {
2569    values
2570        .into_iter()
2571        .any(|value| semantic_text_matches(value, matcher))
2572}
2573
2574fn semantic_text_matches(actual: Option<&str>, matcher: &SemanticTextMatcher) -> bool {
2575    let Some(actual) = actual else { return false };
2576    if matcher.regex {
2577        let flags: String = matcher
2578            .flags
2579            .chars()
2580            .filter(|flag| matches!(flag, 'i' | 'm' | 's'))
2581            .collect();
2582        let pattern = if flags.is_empty() {
2583            matcher.value.clone()
2584        } else {
2585            format!("(?{flags}:{})", matcher.value)
2586        };
2587        return Regex::new(&pattern).is_ok_and(|regex| regex.is_match(actual));
2588    }
2589    if matcher.exact {
2590        actual == matcher.value
2591    } else {
2592        actual
2593            .to_lowercase()
2594            .contains(&matcher.value.to_lowercase())
2595    }
2596}
2597
2598fn parse_selector_segments(selector: &str) -> Result<Vec<SelectorSegment>, String> {
2599    let mut segments = Vec::new();
2600    let normalized = normalize_selector_for_transport(selector);
2601    let trimmed = normalized.trim();
2602    let mut index = 0usize;
2603    while index < trimmed.len() {
2604        let remainder = &trimmed[index..];
2605        let (flavor, prefix_len) = if remainder.starts_with("css=") {
2606            (SelectorFlavorInternal::Css, 4usize)
2607        } else if remainder.starts_with("xpath=") {
2608            (SelectorFlavorInternal::XPath, 6usize)
2609        } else if remainder.starts_with("uia=") {
2610            (SelectorFlavorInternal::UiAutomator, 4usize)
2611        } else if remainder.starts_with("aw=") {
2612            (SelectorFlavorInternal::Semantic, 3usize)
2613        } else {
2614            return Err(format!("unsupported selector segment in `{trimmed}`"));
2615        };
2616        index += prefix_len;
2617        let bytes = trimmed.as_bytes();
2618        if bytes.get(index).copied() != Some(b'"') {
2619            return Err(format!(
2620                "selector segment must use JSON string syntax: `{trimmed}`"
2621            ));
2622        }
2623        let start = index;
2624        index += 1;
2625        let mut escaped = false;
2626        while index < trimmed.len() {
2627            let byte = bytes[index];
2628            if escaped {
2629                escaped = false;
2630            } else if byte == b'\\' {
2631                escaped = true;
2632            } else if byte == b'"' {
2633                index += 1;
2634                break;
2635            }
2636            index += 1;
2637        }
2638        let json_body = &trimmed[start..index];
2639        let value = serde_json::from_str::<String>(json_body)
2640            .map_err(|error| format!("failed to decode selector segment {json_body}: {error}"))?;
2641        segments.push(SelectorSegment { flavor, value });
2642        while index < trimmed.len() && trimmed.as_bytes()[index].is_ascii_whitespace() {
2643            index += 1;
2644        }
2645    }
2646    if segments.is_empty() {
2647        return Err("selector must not be empty".to_string());
2648    }
2649    Ok(segments)
2650}
2651
2652fn selector_segment_to_criteria(segment: &SelectorSegment) -> Result<NodeCriteria, String> {
2653    match segment.flavor {
2654        SelectorFlavorInternal::Css => parse_css_criteria(&segment.value),
2655        SelectorFlavorInternal::XPath => parse_xpath_criteria(&segment.value),
2656        SelectorFlavorInternal::UiAutomator => parse_uiautomator_criteria(&segment.value),
2657        SelectorFlavorInternal::Semantic => parse_semantic_criteria(&segment.value),
2658    }
2659}
2660
2661fn parse_semantic_criteria(selector: &str) -> Result<NodeCriteria, String> {
2662    let spec: serde_json::Value = serde_json::from_str(selector)
2663        .map_err(|error| format!("invalid semantic mobile selector: {error}"))?;
2664    let kind = spec["kind"]
2665        .as_str()
2666        .ok_or("semantic mobile selector requires `kind`")?;
2667    let exact = spec["exact"].as_bool().unwrap_or(false);
2668    let matcher = |key: &str| parse_semantic_matcher(&spec[key], exact);
2669    let mut criteria = NodeCriteria::default();
2670    match kind {
2671        "role" => {
2672            criteria.semantic_role = Some(
2673                spec["role"]
2674                    .as_str()
2675                    .ok_or("getByRole requires `role`")?
2676                    .to_ascii_lowercase(),
2677            );
2678            if !spec["name"].is_null() {
2679                criteria.semantic_name = Some(matcher("name")?);
2680            }
2681            criteria.checked = spec["checked"].as_bool();
2682            criteria.selected = spec["selected"].as_bool();
2683            criteria.semantic_disabled = spec["disabled"].as_bool();
2684        }
2685        "text" => criteria.semantic_text = Some(matcher("text")?),
2686        "label" => criteria.semantic_label = Some(matcher("text")?),
2687        "testId" => criteria.semantic_test_id = Some(parse_semantic_matcher(&spec["text"], true)?),
2688        other => {
2689            return Err(format!(
2690                "unsupported semantic mobile selector kind `{other}`"
2691            ));
2692        }
2693    }
2694    Ok(criteria)
2695}
2696
2697fn parse_semantic_matcher(
2698    value: &serde_json::Value,
2699    exact: bool,
2700) -> Result<SemanticTextMatcher, String> {
2701    if let Some(value) = value.as_str() {
2702        return Ok(SemanticTextMatcher {
2703            value: value.to_string(),
2704            regex: false,
2705            flags: String::new(),
2706            exact,
2707        });
2708    }
2709    let pattern = value["regex"]
2710        .as_str()
2711        .ok_or("semantic text matcher must be a string or regular expression")?;
2712    let flags = value["flags"].as_str().unwrap_or("");
2713    Ok(SemanticTextMatcher {
2714        value: pattern.to_string(),
2715        regex: true,
2716        flags: flags.to_string(),
2717        exact: false,
2718    })
2719}
2720
2721fn parse_uiautomator_criteria(selector: &str) -> Result<NodeCriteria, String> {
2722    let mut criteria = NodeCriteria::default();
2723    for token in split_uiautomator_tokens(selector) {
2724        let (raw_key, raw_value) = split_uiautomator_token(&token)?;
2725        let key = raw_key.to_ascii_lowercase();
2726        match key.as_str() {
2727            "text" => criteria.text = Some(raw_value.to_string()),
2728            "textcontains" => criteria.text_contains = Some(raw_value.to_string()),
2729            "textstartswith" => criteria.text_starts_with = Some(raw_value.to_string()),
2730            "textmatches" => criteria.text_matches = Some(raw_value.to_string()),
2731            "classname" => criteria.class_name = Some(raw_value.to_string()),
2732            "classnamematches" => criteria.class_name_matches = Some(raw_value.to_string()),
2733            "description" | "desc" => criteria.content_desc = Some(raw_value.to_string()),
2734            "descriptioncontains" | "desccontains" => {
2735                criteria.content_desc_contains = Some(raw_value.to_string())
2736            }
2737            "descriptionstartswith" | "descstartswith" => {
2738                criteria.content_desc_starts_with = Some(raw_value.to_string())
2739            }
2740            "descriptionmatches" | "descmatches" => {
2741                criteria.content_desc_matches = Some(raw_value.to_string())
2742            }
2743            "packagename" | "package" => criteria.package_name = Some(raw_value.to_string()),
2744            "packagenamematches" => criteria.package_name_matches = Some(raw_value.to_string()),
2745            "resourceid" => criteria.resource_id_exact = Some(raw_value.to_string()),
2746            "resourceidmatches" => criteria.resource_id_matches = Some(raw_value.to_string()),
2747            "checkable" => criteria.checkable = Some(parse_selector_bool(raw_value, &token)?),
2748            "checked" => criteria.checked = Some(parse_selector_bool(raw_value, &token)?),
2749            "clickable" => criteria.clickable = Some(parse_selector_bool(raw_value, &token)?),
2750            "longclickable" => {
2751                criteria.long_clickable = Some(parse_selector_bool(raw_value, &token)?)
2752            }
2753            "scrollable" => criteria.scrollable = Some(parse_selector_bool(raw_value, &token)?),
2754            "enabled" => criteria.enabled = Some(parse_selector_bool(raw_value, &token)?),
2755            "focusable" => criteria.focusable = Some(parse_selector_bool(raw_value, &token)?),
2756            "focused" => criteria.focused = Some(parse_selector_bool(raw_value, &token)?),
2757            "selected" => criteria.selected = Some(parse_selector_bool(raw_value, &token)?),
2758            "index" => criteria.index = Some(parse_selector_usize(raw_value, &token)?),
2759            "instance" => criteria.instance = Some(parse_selector_usize(raw_value, &token)?),
2760            other => return Err(format!("unsupported UiAutomator selector key `{other}`")),
2761        }
2762    }
2763    Ok(criteria)
2764}
2765
2766fn split_uiautomator_tokens(selector: &str) -> Vec<String> {
2767    let mut tokens = Vec::new();
2768    let mut current = String::new();
2769    let mut in_single = false;
2770    let mut in_double = false;
2771    for ch in selector.chars() {
2772        match ch {
2773            '\'' if !in_double => {
2774                in_single = !in_single;
2775                current.push(ch);
2776            }
2777            '"' if !in_single => {
2778                in_double = !in_double;
2779                current.push(ch);
2780            }
2781            ',' | ';' if !in_single && !in_double => {
2782                let trimmed = current.trim();
2783                if !trimmed.is_empty() {
2784                    tokens.push(trimmed.to_string());
2785                }
2786                current.clear();
2787            }
2788            _ => current.push(ch),
2789        }
2790    }
2791    let trimmed = current.trim();
2792    if !trimmed.is_empty() {
2793        tokens.push(trimmed.to_string());
2794    }
2795    tokens
2796}
2797
2798fn split_uiautomator_token(token: &str) -> Result<(&str, &str), String> {
2799    let Some((key, value)) = token.split_once(['=', ':']) else {
2800        return Err(format!("invalid UiAutomator selector token `{token}`"));
2801    };
2802    Ok((key.trim(), value.trim()))
2803}
2804
2805fn parse_selector_bool(value: &str, token: &str) -> Result<bool, String> {
2806    parse_bool(value).ok_or_else(|| format!("invalid boolean value in `{token}`"))
2807}
2808
2809fn parse_selector_usize(value: &str, token: &str) -> Result<usize, String> {
2810    value
2811        .parse()
2812        .map_err(|_| format!("invalid integer value in `{token}`"))
2813}
2814
2815fn parse_css_criteria(selector: &str) -> Result<NodeCriteria, String> {
2816    let mut criteria = NodeCriteria::default();
2817    let mut remainder = selector.trim();
2818    while let Some(start) = remainder.find('[') {
2819        let end = remainder[start + 1..]
2820            .find(']')
2821            .map(|index| start + 1 + index)
2822            .ok_or_else(|| format!("unterminated CSS attribute selector in `{selector}`"))?;
2823        let attribute = &remainder[start + 1..end];
2824        let (name, value) = parse_attribute_filter(attribute)?;
2825        match name {
2826            "text" => criteria.text = Some(value),
2827            "content-desc" => criteria.content_desc = Some(value),
2828            "resource-id" => criteria.resource_id_exact = Some(value),
2829            _ => {
2830                return Err(format!(
2831                    "unsupported CSS attribute selector `{name}` in `{selector}`"
2832                ));
2833            }
2834        }
2835        let mut next = String::with_capacity(remainder.len());
2836        next.push_str(remainder[..start].trim_end());
2837        next.push(' ');
2838        next.push_str(remainder[end + 1..].trim_start());
2839        let owned = next.trim().to_string();
2840        remainder = Box::leak(owned.into_boxed_str());
2841    }
2842
2843    let class_or_id = remainder.trim();
2844    if let Some(resource_id) = class_or_id.strip_prefix('#') {
2845        if resource_id.contains(':') {
2846            criteria.resource_id_exact = Some(resource_id.to_string());
2847        } else {
2848            criteria.resource_id_suffix = Some(format!(":id/{resource_id}"));
2849        }
2850    } else if let Some(class_name) = class_or_id.strip_prefix('.') {
2851        if !class_name.is_empty() {
2852            criteria.class_name = Some(class_name.to_string());
2853        }
2854    } else if !class_or_id.is_empty() {
2855        criteria.class_name = Some(class_or_id.to_string());
2856    }
2857    Ok(criteria)
2858}
2859
2860fn parse_xpath_criteria(selector: &str) -> Result<NodeCriteria, String> {
2861    let trimmed = selector.trim();
2862    let mut criteria = NodeCriteria::default();
2863    let mut path = trimmed;
2864    if let Some(stripped) = path.strip_prefix(".//") {
2865        path = stripped;
2866    } else if let Some(stripped) = path.strip_prefix("//") {
2867        path = stripped;
2868    }
2869
2870    let path = path.trim();
2871    let (node_pattern, predicates) = if let Some(start) = path.find('[') {
2872        let end = path
2873            .rfind(']')
2874            .ok_or_else(|| format!("unterminated XPath in `{selector}`"))?;
2875        (&path[..start], Some(&path[start + 1..end]))
2876    } else {
2877        (path, None)
2878    };
2879
2880    let node_pattern = node_pattern.trim();
2881    if !node_pattern.is_empty() && node_pattern != "*" {
2882        criteria.class_name = Some(node_pattern.to_string());
2883    }
2884
2885    if let Some(predicates) = predicates {
2886        for predicate in predicates.split(" and ") {
2887            let predicate = predicate.trim().trim_matches(|ch| ch == '(' || ch == ')');
2888            if let Some(value) = parse_xpath_attr_equals(predicate, "@text") {
2889                criteria.text = Some(value);
2890                continue;
2891            }
2892            if let Some(value) = parse_xpath_attr_equals(predicate, "@content-desc") {
2893                criteria.content_desc = Some(value);
2894                continue;
2895            }
2896            if let Some(value) = parse_xpath_attr_equals(predicate, "@class") {
2897                criteria.class_name = Some(value);
2898                continue;
2899            }
2900            if let Some(value) = parse_xpath_attr_equals(predicate, "@resource-id") {
2901                if value.contains(':') {
2902                    criteria.resource_id_exact = Some(value);
2903                } else {
2904                    criteria.resource_id_suffix = Some(format!(":id/{value}"));
2905                }
2906                continue;
2907            }
2908            if let Some(value) = parse_xpath_resource_suffix(predicate) {
2909                criteria.resource_id_suffix = Some(value);
2910                continue;
2911            }
2912        }
2913    }
2914
2915    Ok(criteria)
2916}
2917
2918fn parse_attribute_filter(attribute: &str) -> Result<(&str, String), String> {
2919    let (name, value) = attribute
2920        .split_once('=')
2921        .ok_or_else(|| format!("unsupported CSS attribute selector `{attribute}`"))?;
2922    Ok((name.trim(), parse_quoted_value(value.trim())?))
2923}
2924
2925fn parse_xpath_attr_equals(predicate: &str, attr_name: &str) -> Option<String> {
2926    let remainder = predicate.strip_prefix(attr_name)?;
2927    let remainder = remainder.trim_start();
2928    let remainder = remainder.strip_prefix('=')?.trim_start();
2929    parse_quoted_value(remainder).ok()
2930}
2931
2932fn parse_xpath_resource_suffix(predicate: &str) -> Option<String> {
2933    let suffix_marker = ":id/";
2934    let start = predicate.find(suffix_marker)?;
2935    let candidate = &predicate[start - 1..];
2936    parse_quoted_value(candidate).ok()
2937}
2938
2939fn resource_id_matches_suffix(resource_id: &str, expected_suffix: &str) -> bool {
2940    if resource_id.ends_with(expected_suffix) {
2941        return true;
2942    }
2943
2944    let bare_expected = expected_suffix
2945        .rsplit('/')
2946        .next()
2947        .filter(|value| !value.is_empty());
2948    matches!(bare_expected, Some(expected) if resource_id == expected)
2949}
2950
2951fn contains_text(actual: Option<&str>, expected: &str) -> bool {
2952    actual.is_some_and(|value| value.contains(expected))
2953}
2954
2955fn starts_with_text(actual: Option<&str>, expected: &str) -> bool {
2956    actual.is_some_and(|value| value.starts_with(expected))
2957}
2958
2959fn matches_regex(actual: Option<&str>, pattern: &str) -> bool {
2960    let Some(actual) = actual else {
2961        return false;
2962    };
2963    Regex::new(pattern).is_ok_and(|regex| regex.is_match(actual))
2964}
2965
2966fn parse_quoted_value(input: &str) -> Result<String, String> {
2967    let trimmed = input.trim();
2968    let quote = trimmed
2969        .chars()
2970        .next()
2971        .filter(|ch| *ch == '\'' || *ch == '"')
2972        .ok_or_else(|| format!("expected quoted value in `{input}`"))?;
2973    let end = trimmed[1..]
2974        .find(quote)
2975        .ok_or_else(|| format!("unterminated quoted value in `{input}`"))?;
2976    Ok(trimmed[1..1 + end].to_string())
2977}
2978
2979fn encode_adb_text(value: &str) -> String {
2980    let mut encoded = String::new();
2981    for ch in value.chars() {
2982        match ch {
2983            ' ' => encoded.push_str("%s"),
2984            '"' => encoded.push_str("\\\""),
2985            '\'' => encoded.push_str("\\'"),
2986            '&' | '|' | ';' | '<' | '>' | '(' | ')' | '$' | '\\' => {
2987                encoded.push('\\');
2988                encoded.push(ch);
2989            }
2990            _ => encoded.push(ch),
2991        }
2992    }
2993    encoded
2994}
2995
2996#[cfg(test)]
2997mod tests {
2998    use super::*;
2999
3000    #[test]
3001    fn recognizes_standard_android_picker_packages() {
3002        assert!(is_android_picker(Some("com.google.android.documentsui")));
3003        assert!(is_android_picker(Some("com.vendor.filepicker")));
3004        assert!(is_android_picker(Some("com.android.files")));
3005        assert!(!is_android_picker(Some("com.example.app")));
3006        assert!(!is_android_picker(None));
3007    }
3008
3009    #[test]
3010    fn detects_new_and_replaced_public_downloads() {
3011        let before = HashMap::from([
3012            (
3013                "/sdcard/Download/existing.pdf".to_string(),
3014                "10:1".to_string(),
3015            ),
3016            (
3017                "/sdcard/Download/replaced.pdf".to_string(),
3018                "20:1".to_string(),
3019            ),
3020        ]);
3021        let unchanged = before.clone();
3022        assert_eq!(changed_public_download(&before, &unchanged), None);
3023
3024        let mut replaced = before.clone();
3025        replaced.insert(
3026            "/sdcard/Download/replaced.pdf".to_string(),
3027            "21:2".to_string(),
3028        );
3029        assert_eq!(
3030            changed_public_download(&before, &replaced).as_deref(),
3031            Some("/sdcard/Download/replaced.pdf")
3032        );
3033
3034        let mut added = before.clone();
3035        added.insert("/sdcard/Download/new.pdf".to_string(), "1:2".to_string());
3036        assert!(changed_public_download(&before, &added).is_some());
3037    }
3038
3039    #[tokio::test]
3040    async fn boots_android_runtime() {
3041        assert_eq!(boot().await, "android ready");
3042    }
3043
3044    #[test]
3045    fn defaults_to_uiautomator2() {
3046        let android = profile();
3047        assert_eq!(
3048            android.default_backend,
3049            MobileAutomationBackend::UiAutomator2
3050        );
3051        assert_eq!(android.backends, ANDROID_BACKENDS);
3052        assert!(android.capabilities.supports_native_views);
3053        assert!(android.capabilities.supports_webviews);
3054    }
3055
3056    #[test]
3057    fn exposes_android_runtime_milestones() {
3058        let readiness = runtime_readiness();
3059        assert_eq!(readiness.maturity, RuntimeMaturity::Scaffolding);
3060        assert!(
3061            readiness
3062                .missing_runtime_artifacts
3063                .iter()
3064                .any(|item| item.contains("server-side mobile session routing"))
3065        );
3066    }
3067
3068    #[test]
3069    fn connect_flow_matches_web_shape() {
3070        let options = ConnectOptions {
3071            platform: MobilePlatform::Android,
3072            device: Some("emulator-5554".to_string()),
3073            adb_endpoint: None,
3074            agent_endpoint: None,
3075            preserve_app_state: true,
3076            timeout_ms: Some(5_000),
3077        };
3078
3079        let command = connect_command(options.clone());
3080
3081        match command {
3082            MobileCommand::Connect(connect) => {
3083                assert_eq!(connect.platform, MobilePlatform::Android);
3084                assert_eq!(connect.device.as_deref(), Some("emulator-5554"));
3085            }
3086            _ => panic!("expected mobile connect command"),
3087        }
3088    }
3089
3090    #[test]
3091    fn launch_flow_keeps_apk_work_separate() {
3092        let browser_session = MobileBrowserSessionHandle {
3093            platform: MobilePlatform::Android,
3094            automation: MobileAutomationSessionInfo {
3095                backend: "uiautomator2".to_string(),
3096                session_id: "uiautomator2:emulator-5554".to_string(),
3097                note: "ready".to_string(),
3098            },
3099            device: DeviceTarget {
3100                platform: MobilePlatform::Android,
3101                device_id: "emulator-5554".to_string(),
3102                connection_kind: DeviceConnectionKind::Emulator,
3103            },
3104        };
3105
3106        let command = launch_command(
3107            browser_session,
3108            LaunchOptions {
3109                apk_path: Some("/tmp/app.apk".to_string()),
3110                app_id: Some("dev.allwright.sample".to_string()),
3111                launch_activity: Some(".MainActivity".to_string()),
3112                stop_before_launch: true,
3113                timeout_ms: Some(15_000),
3114            },
3115        );
3116
3117        match command {
3118            MobileCommand::LaunchApp { options, .. } => {
3119                assert_eq!(options.apk_path.as_deref(), Some("/tmp/app.apk"));
3120                assert_eq!(options.app_id.as_deref(), Some("dev.allwright.sample"));
3121                assert!(options.stop_before_launch);
3122            }
3123            _ => panic!("expected launch command"),
3124        }
3125    }
3126
3127    #[test]
3128    fn parse_adb_devices_extracts_friendly_names() {
3129        let devices = parse_adb_devices(
3130            "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",
3131        );
3132
3133        assert_eq!(devices.len(), 2);
3134        assert_eq!(devices[0].name.as_deref(), Some("Pixel 8 API 35"));
3135        assert_eq!(devices[1].name.as_deref(), Some("QA Galaxy S24"));
3136        assert_eq!(devices[0].connection_kind, DeviceConnectionKind::Emulator);
3137        assert_eq!(devices[1].connection_kind, DeviceConnectionKind::Usb);
3138    }
3139
3140    #[test]
3141    fn connect_uses_requested_device_when_provided() {
3142        let options = ConnectOptions {
3143            platform: MobilePlatform::Android,
3144            device: Some("R5CX123".to_string()),
3145            adb_endpoint: None,
3146            agent_endpoint: None,
3147            preserve_app_state: false,
3148            timeout_ms: None,
3149        };
3150        let devices = vec![
3151            AdbDevice {
3152                serial: "emulator-5554".to_string(),
3153                name: Some("Pixel 8 API 35".to_string()),
3154                connection_kind: DeviceConnectionKind::Emulator,
3155                state: "device".to_string(),
3156            },
3157            AdbDevice {
3158                serial: "R5CX123".to_string(),
3159                name: Some("QA Galaxy S24".to_string()),
3160                connection_kind: DeviceConnectionKind::Usb,
3161                state: "device".to_string(),
3162            },
3163        ];
3164
3165        let selected = select_device_for_connect(&options, &devices).expect("device should match");
3166        assert_eq!(selected.serial, "R5CX123");
3167        assert_eq!(selected.connection_kind, DeviceConnectionKind::Usb);
3168    }
3169
3170    #[test]
3171    fn connect_defaults_to_first_available_device() {
3172        let options = ConnectOptions {
3173            platform: MobilePlatform::Android,
3174            device: None,
3175            adb_endpoint: None,
3176            agent_endpoint: None,
3177            preserve_app_state: false,
3178            timeout_ms: None,
3179        };
3180        let devices = vec![
3181            AdbDevice {
3182                serial: "emulator-5554".to_string(),
3183                name: Some("Pixel 8 API 35".to_string()),
3184                connection_kind: DeviceConnectionKind::Emulator,
3185                state: "device".to_string(),
3186            },
3187            AdbDevice {
3188                serial: "R5CX123".to_string(),
3189                name: Some("QA Galaxy S24".to_string()),
3190                connection_kind: DeviceConnectionKind::Usb,
3191                state: "device".to_string(),
3192            },
3193        ];
3194
3195        let selected = select_device_for_connect(&options, &devices).expect("first device");
3196        assert_eq!(selected.serial, "emulator-5554");
3197    }
3198
3199    #[test]
3200    fn connect_matches_friendly_device_name() {
3201        let options = ConnectOptions {
3202            platform: MobilePlatform::Android,
3203            device: Some("qa galaxy s24".to_string()),
3204            adb_endpoint: None,
3205            agent_endpoint: None,
3206            preserve_app_state: false,
3207            timeout_ms: None,
3208        };
3209        let devices = vec![
3210            AdbDevice {
3211                serial: "emulator-5554".to_string(),
3212                name: Some("Pixel 8 API 35".to_string()),
3213                connection_kind: DeviceConnectionKind::Emulator,
3214                state: "device".to_string(),
3215            },
3216            AdbDevice {
3217                serial: "R5CX123".to_string(),
3218                name: Some("QA Galaxy S24".to_string()),
3219                connection_kind: DeviceConnectionKind::Usb,
3220                state: "device".to_string(),
3221            },
3222        ];
3223
3224        let selected = select_device_for_connect(&options, &devices).expect("friendly name match");
3225        assert_eq!(selected.serial, "R5CX123");
3226    }
3227
3228    #[test]
3229    fn connect_fails_when_requested_device_is_missing() {
3230        let options = ConnectOptions {
3231            platform: MobilePlatform::Android,
3232            device: Some("missing-device".to_string()),
3233            adb_endpoint: None,
3234            agent_endpoint: None,
3235            preserve_app_state: false,
3236            timeout_ms: None,
3237        };
3238        let devices = vec![AdbDevice {
3239            serial: "emulator-5554".to_string(),
3240            name: Some("Pixel 8 API 35".to_string()),
3241            connection_kind: DeviceConnectionKind::Emulator,
3242            state: "device".to_string(),
3243        }];
3244
3245        let error = select_device_for_connect(&options, &devices).expect_err("missing device");
3246        assert_eq!(
3247            error,
3248            ConnectSelectionError::RequestedDeviceNotFound {
3249                requested: "missing-device".to_string()
3250            }
3251        );
3252    }
3253
3254    #[test]
3255    fn parse_package_name_from_aapt_output_extracts_package() {
3256        let package_name = parse_package_name_from_aapt_badging(
3257            "package: name='dev.allwright.sample' versionCode='1' versionName='1.0'\n",
3258        )
3259        .expect("package name should parse");
3260        assert_eq!(package_name, "dev.allwright.sample");
3261    }
3262
3263    #[test]
3264    fn detects_remote_apk_sources() {
3265        assert!(is_remote_apk_source("https://example.com/app.apk"));
3266        assert!(is_remote_apk_source("http://example.com/app.apk"));
3267        assert!(!is_remote_apk_source("/tmp/app.apk"));
3268    }
3269
3270    #[test]
3271    fn resolves_local_apk_source() {
3272        let apk_path = env::temp_dir().join("allwright-mobile-android-local-test.apk");
3273        fs::write(&apk_path, b"apk").expect("write local apk fixture");
3274
3275        let resolved =
3276            resolve_apk_source(apk_path.to_string_lossy().as_ref()).expect("resolve local apk");
3277        assert_eq!(resolved.path, apk_path);
3278
3279        fs::remove_file(&apk_path).expect("remove local apk fixture");
3280    }
3281
3282    #[test]
3283    fn foreground_app_parses_component_after_window_prefix() {
3284        let app = parse_foreground_app_from_dumpsys("mCurrentFocus=Window{42 u0 test.app/.Main}");
3285        assert_eq!(app.current_package.as_deref(), Some("test.app"));
3286        assert_eq!(app.current_activity.as_deref(), Some(".Main"));
3287    }
3288
3289    #[test]
3290    fn dump_source_keeps_page_context_separate() {
3291        let page_session = MobilePageSessionHandle {
3292            page_id: "emulator-5554:dev.allwright.sample".to_string(),
3293            package_name: Some("dev.allwright.sample".to_string()),
3294            activity_name: Some(".MainActivity".to_string()),
3295            webview_context: None,
3296        };
3297        assert_eq!(page_session.page_id, "emulator-5554:dev.allwright.sample");
3298    }
3299
3300    #[test]
3301    fn parses_uiautomator_selector_strategies() {
3302        let criteria = parse_uiautomator_criteria(
3303            "textContains=Account,descriptionStartsWith=Open,classNameMatches=android\\.widget\\..*,clickable=true,index=2,instance=1,resourceIdMatches=.*bottom_nav.*,packageName=com.example.airticket",
3304        )
3305        .expect("uia criteria should parse");
3306
3307        assert_eq!(criteria.text_contains.as_deref(), Some("Account"));
3308        assert_eq!(criteria.content_desc_starts_with.as_deref(), Some("Open"));
3309        assert_eq!(
3310            criteria.class_name_matches.as_deref(),
3311            Some("android\\.widget\\..*")
3312        );
3313        assert_eq!(criteria.clickable, Some(true));
3314        assert_eq!(criteria.index, Some(2));
3315        assert_eq!(criteria.instance, Some(1));
3316        assert_eq!(
3317            criteria.resource_id_matches.as_deref(),
3318            Some(".*bottom_nav.*")
3319        );
3320        assert_eq!(
3321            criteria.package_name.as_deref(),
3322            Some("com.example.airticket")
3323        );
3324    }
3325
3326    #[test]
3327    fn matches_uiautomator_selectors_against_dumped_nodes() {
3328        let nodes = parse_android_ui_nodes(sample_ui_hierarchy()).expect("xml should parse");
3329
3330        let account = find_node_by_selector(&nodes, "text=Account").expect("text selector");
3331        assert_eq!(
3332            account.resource_id.as_deref(),
3333            Some("com.example.airticket:id/bottom_nav_account")
3334        );
3335
3336        let by_desc =
3337            find_node_by_selector(&nodes, "descriptionContains=Account").expect("desc selector");
3338        assert_eq!(
3339            by_desc.resource_id.as_deref(),
3340            Some("com.example.airticket:id/bottom_nav_account")
3341        );
3342
3343        let by_resource =
3344            find_node_by_selector(&nodes, "resourceIdMatches=.*bottom_nav_.*,selected=true")
3345                .expect("resource regex selector");
3346        assert_eq!(
3347            by_resource.resource_id.as_deref(),
3348            Some("com.example.airticket:id/bottom_nav_account")
3349        );
3350
3351        let by_class = find_node_by_selector(
3352            &nodes,
3353            "classNameMatches=android\\.widget\\..*,packageNameMatches=com\\.example\\..*,clickable=true,instance=1",
3354        )
3355        .expect("class/package/instance selector");
3356        assert_eq!(
3357            by_class.resource_id.as_deref(),
3358            Some("com.example.airticket:id/bottom_nav_account")
3359        );
3360
3361        let by_css_id = find_node_by_selector(&nodes, r##"css="#bottom_nav_account""##)
3362            .expect("css id selector");
3363        assert_eq!(
3364            by_css_id.resource_id.as_deref(),
3365            Some("com.example.airticket:id/bottom_nav_account")
3366        );
3367
3368        let bare_nodes =
3369            parse_android_ui_nodes(sample_ui_hierarchy_with_bare_resource_ids()).expect("xml");
3370        let bare_by_css_id = find_node_by_selector(&bare_nodes, r##"css="#signup_first_name""##)
3371            .expect("bare css id");
3372        assert_eq!(
3373            bare_by_css_id.resource_id.as_deref(),
3374            Some("signup_first_name")
3375        );
3376    }
3377
3378    #[test]
3379    fn exposes_native_capture_attributes_and_bounds() {
3380        let nodes = parse_android_ui_nodes(sample_ui_hierarchy()).expect("xml should parse");
3381        let node = find_node_by_selector(&nodes, "resourceIdMatches=.*bottom_nav_.*,selected=true")
3382            .expect("selected navigation node");
3383        assert_eq!(
3384            android_node_attribute(node, "text").as_deref(),
3385            Some("Account")
3386        );
3387        assert_eq!(
3388            android_node_attribute(node, "selected").as_deref(),
3389            Some("true")
3390        );
3391        assert!(android_node_attribute(node, "not-a-native-attribute").is_none());
3392        let bounds = node.bounds.expect("sample node has bounds");
3393        assert!(bounds.right > bounds.left);
3394        assert!(bounds.bottom > bounds.top);
3395
3396        let input = find_node_by_selector(&nodes, "resourceId=com.example.airticket:id/email")
3397            .expect("email input");
3398        assert_eq!(
3399            android_node_attribute(input, "editable").as_deref(),
3400            Some("true")
3401        );
3402        assert_eq!(
3403            android_node_attribute(input, "focused").as_deref(),
3404            Some("true")
3405        );
3406    }
3407
3408    #[test]
3409    fn matches_playwright_style_semantic_mobile_selectors() {
3410        let nodes = parse_android_ui_nodes(sample_ui_hierarchy()).expect("xml should parse");
3411        let selector = |spec: serde_json::Value| {
3412            format!("aw={}", serde_json::to_string(&spec.to_string()).unwrap())
3413        };
3414
3415        let account = find_node_by_selector(
3416            &nodes,
3417            &selector(serde_json::json!({"kind":"role", "role":"text", "name":"account", "selected":true})),
3418        )
3419        .expect("semantic role selector");
3420        assert_eq!(account.text.as_deref(), Some("Account"));
3421
3422        let by_text = find_node_by_selector(
3423            &nodes,
3424            &selector(serde_json::json!({"kind":"text", "text":{"regex":"^Acc", "flags":"i"}})),
3425        )
3426        .expect("semantic text selector");
3427        assert_eq!(by_text.text.as_deref(), Some("Account"));
3428
3429        let by_test_id = find_node_by_selector(
3430            &nodes,
3431            &selector(serde_json::json!({"kind":"testId", "text":"bottom_nav_account"})),
3432        )
3433        .expect("semantic test id selector");
3434        assert_eq!(
3435            by_test_id.resource_id.as_deref(),
3436            Some("com.example.airticket:id/bottom_nav_account")
3437        );
3438    }
3439
3440    #[test]
3441    fn stitches_scrolled_screenshots_without_duplicate_overlap() {
3442        let first =
3443            RgbaImage::from_fn(4, 10, |x, y| image::Rgba([(x * 11) as u8, y as u8, 0, 255]));
3444        let second = RgbaImage::from_fn(4, 10, |x, y| {
3445            image::Rgba([(x * 11) as u8, (y + 4) as u8, 0, 255])
3446        });
3447
3448        let overlap = find_screenshot_overlap(&first, &second).expect("screenshots align");
3449        assert_eq!(overlap, 6);
3450
3451        let mut stitched = first;
3452        append_screenshot(&mut stitched, &second, overlap).expect("screenshots stitch");
3453        assert_eq!(stitched.dimensions(), (4, 14));
3454        assert_eq!(stitched.get_pixel(0, 13), second.get_pixel(0, 9));
3455    }
3456
3457    fn sample_ui_hierarchy() -> &'static str {
3458        r#"<?xml version='1.0' encoding='UTF-8' standalone='yes' ?>
3459<hierarchy rotation="0">
3460  <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]">
3461    <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]" />
3462    <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]" />
3463    <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]" />
3464  </node>
3465</hierarchy>"#
3466    }
3467
3468    fn sample_ui_hierarchy_with_bare_resource_ids() -> &'static str {
3469        r#"<?xml version='1.0' encoding='UTF-8' standalone='yes' ?>
3470<hierarchy rotation="0">
3471  <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]">
3472    <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]" />
3473  </node>
3474</hierarchy>"#
3475    }
3476}