Skip to main content

allwright_surface_mobile_android/
lib.rs

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