Skip to main content

allwright_surface_mobile_android/
lib.rs

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