Skip to main content

allwright_surface_mobile_android/
lib.rs

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