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