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