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