Skip to main content

allwright_surface_mobile_android/
lib.rs

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