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    MobileFillInfo, MobilePageInfo, MobilePageSessionHandle, MobilePlatform,
9    MobileRuntimeReadiness, MobileSurfaceProfile, RuntimeMaturity, boot_surface,
10    normalize_selector_for_transport,
11};
12use regex::Regex;
13use std::env;
14use std::ffi::{CStr, CString, c_char};
15use std::fmt;
16use std::path::Path;
17use std::process::Command;
18
19#[derive(Debug, Clone, Copy, Default)]
20pub struct MobileAndroidPlugin;
21
22const ANDROID_BACKENDS: &[MobileAutomationBackend] = &[MobileAutomationBackend::UiAutomator2];
23const ANDROID_APP_KINDS: &[MobileAppKind] = &[
24    MobileAppKind::Native,
25    MobileAppKind::Hybrid,
26    MobileAppKind::BrowserWrapped,
27];
28const ANDROID_MISSING_RUNTIME_ARTIFACTS: &[&str] = &[
29    "server-side mobile session routing in allwright-core",
30    "expanded command coverage beyond connect, launch, click, and fill",
31];
32const ANDROID_NEXT_MILESTONES: &[&str] = &[
33    "route Android mobile commands through the core engine session server",
34    "add text and waitForSelector on top of the native ADB-driven runtime",
35    "expand runtime packaging validation across macOS, Linux, and Windows release assets",
36];
37
38#[derive(Debug, Clone, PartialEq, Eq)]
39pub struct AdbDevice {
40    pub serial: String,
41    pub name: Option<String>,
42    pub connection_kind: DeviceConnectionKind,
43    pub state: String,
44}
45
46#[derive(Debug, Clone, PartialEq, Eq)]
47pub enum ConnectSelectionError {
48    NoDevicesAvailable,
49    RequestedDeviceNotFound { requested: String },
50}
51
52impl fmt::Display for ConnectSelectionError {
53    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
54        match self {
55            Self::NoDevicesAvailable => write!(f, "no Android devices were discovered over ADB"),
56            Self::RequestedDeviceNotFound { requested } => {
57                write!(
58                    f,
59                    "requested Android device `{requested}` was not found over ADB"
60                )
61            }
62        }
63    }
64}
65
66#[derive(Debug, Clone, PartialEq, Eq)]
67pub struct UiAutomator2ConnectInfo {
68    pub device_name: Option<String>,
69    pub current_package: Option<String>,
70    pub current_activity: Option<String>,
71}
72
73#[derive(Debug, Clone, PartialEq, Eq)]
74pub struct UiAutomator2ClickInfo {
75    pub resolved_selector: String,
76    pub note: String,
77}
78
79#[derive(Debug, Clone, PartialEq, Eq)]
80pub struct UiAutomator2SourceInfo {
81    pub source: String,
82    pub current_package: Option<String>,
83    pub current_activity: Option<String>,
84}
85
86#[derive(Debug, Clone, PartialEq, Eq)]
87pub struct UiAutomator2FillInfo {
88    pub resolved_selector: String,
89    pub value: String,
90    pub note: String,
91}
92
93#[derive(Debug, Clone, PartialEq, Eq)]
94struct ForegroundAppInfo {
95    current_package: Option<String>,
96    current_activity: Option<String>,
97}
98
99#[derive(Debug, Clone, PartialEq, Eq)]
100struct AndroidUiNode {
101    class_name: Option<String>,
102    resource_id: Option<String>,
103    package_name: Option<String>,
104    text: Option<String>,
105    content_desc: Option<String>,
106    checkable: Option<bool>,
107    checked: Option<bool>,
108    clickable: Option<bool>,
109    long_clickable: Option<bool>,
110    scrollable: Option<bool>,
111    enabled: Option<bool>,
112    focusable: Option<bool>,
113    focused: Option<bool>,
114    selected: Option<bool>,
115    index: Option<usize>,
116    bounds: Option<AndroidBounds>,
117    parent_index: Option<usize>,
118}
119
120#[derive(Debug, Clone, Copy, PartialEq, Eq)]
121struct AndroidBounds {
122    left: i32,
123    top: i32,
124    right: i32,
125    bottom: i32,
126}
127
128impl AndroidBounds {
129    fn center(self) -> (i32, i32) {
130        ((self.left + self.right) / 2, (self.top + self.bottom) / 2)
131    }
132}
133
134#[derive(Debug, Clone, PartialEq, Eq)]
135enum SelectorFlavorInternal {
136    Css,
137    XPath,
138    UiAutomator,
139}
140
141#[derive(Debug, Clone, PartialEq, Eq)]
142struct SelectorSegment {
143    flavor: SelectorFlavorInternal,
144    value: String,
145}
146
147#[derive(Debug, Clone, Default, PartialEq, Eq)]
148struct NodeCriteria {
149    class_name: Option<String>,
150    class_name_matches: Option<String>,
151    resource_id_exact: Option<String>,
152    resource_id_suffix: Option<String>,
153    resource_id_matches: Option<String>,
154    package_name: Option<String>,
155    package_name_matches: Option<String>,
156    text: Option<String>,
157    text_contains: Option<String>,
158    text_starts_with: Option<String>,
159    text_matches: Option<String>,
160    content_desc: Option<String>,
161    content_desc_contains: Option<String>,
162    content_desc_starts_with: Option<String>,
163    content_desc_matches: Option<String>,
164    checkable: Option<bool>,
165    checked: Option<bool>,
166    clickable: Option<bool>,
167    long_clickable: Option<bool>,
168    scrollable: Option<bool>,
169    enabled: Option<bool>,
170    focusable: Option<bool>,
171    focused: Option<bool>,
172    selected: Option<bool>,
173    index: Option<usize>,
174    instance: Option<usize>,
175}
176
177impl SurfacePlugin for MobileAndroidPlugin {
178    fn descriptor(&self) -> SurfacePluginDescriptor {
179        descriptor()
180    }
181}
182
183pub fn descriptor() -> SurfacePluginDescriptor {
184    SurfacePluginDescriptor {
185        id: "mobile-android",
186        family: SurfaceFamily::Mobile,
187        version: env!("CARGO_PKG_VERSION"),
188        description: "Android mobile surface plugin for the allwright engine.",
189    }
190}
191
192pub fn profile() -> MobileSurfaceProfile {
193    MobileSurfaceProfile {
194        plugin_id: "mobile-android",
195        display_name: "Android",
196        family: SurfaceFamily::Mobile,
197        backends: ANDROID_BACKENDS,
198        default_backend: MobileAutomationBackend::UiAutomator2,
199        supported_app_kinds: ANDROID_APP_KINDS,
200        capabilities: MobileCapabilitySet {
201            supports_native_views: true,
202            supports_webviews: true,
203            supports_deep_links: true,
204            supports_shell_commands: true,
205            supports_device_logs: true,
206        },
207        bootstrap_hint: "Connect a device or emulator over ADB, then bootstrap a UiAutomator2 session before launching and clicking through the app.",
208    }
209}
210
211pub fn runtime_readiness() -> MobileRuntimeReadiness {
212    MobileRuntimeReadiness {
213        maturity: RuntimeMaturity::Scaffolding,
214        missing_runtime_artifacts: ANDROID_MISSING_RUNTIME_ARTIFACTS,
215        next_milestones: ANDROID_NEXT_MILESTONES,
216    }
217}
218
219pub fn connect_command(options: ConnectOptions) -> MobileCommand {
220    MobileCommand::Connect(options)
221}
222
223pub fn launch_command(
224    browser_session: MobileBrowserSessionHandle,
225    options: LaunchOptions,
226) -> MobileCommand {
227    MobileCommand::LaunchApp {
228        browser_session,
229        options,
230    }
231}
232
233pub fn list_adb_devices() -> Result<Vec<AdbDevice>, String> {
234    let output = run_adb(&["devices", "-l"])?;
235    Ok(parse_adb_devices(&output))
236}
237
238pub fn select_device_for_connect(
239    options: &ConnectOptions,
240    devices: &[AdbDevice],
241) -> Result<AdbDevice, ConnectSelectionError> {
242    let available = devices
243        .iter()
244        .filter(|device| device.state == "device")
245        .cloned()
246        .collect::<Vec<_>>();
247    if available.is_empty() {
248        return Err(ConnectSelectionError::NoDevicesAvailable);
249    }
250
251    if let Some(requested) = options.device.as_deref() {
252        return available
253            .iter()
254            .find(|device| {
255                device.serial == requested
256                    || device
257                        .name
258                        .as_deref()
259                        .is_some_and(|name| name.eq_ignore_ascii_case(requested))
260            })
261            .cloned()
262            .ok_or_else(|| ConnectSelectionError::RequestedDeviceNotFound {
263                requested: requested.to_string(),
264            });
265    }
266
267    available
268        .first()
269        .cloned()
270        .ok_or(ConnectSelectionError::NoDevicesAvailable)
271}
272
273pub fn connect(options: &ConnectOptions) -> Result<MobileConnectInfo, String> {
274    if options.platform != MobilePlatform::Android {
275        return Err("mobile-android connect only supports the Android platform".to_string());
276    }
277
278    if let Some(endpoint) = options.adb_endpoint.as_deref() {
279        let trimmed = endpoint.trim();
280        if !trimmed.is_empty() {
281            let _ = run_adb(&["connect", trimmed])?;
282        }
283    }
284
285    let devices = list_adb_devices()?;
286    let selected =
287        select_device_for_connect(options, &devices).map_err(|error| error.to_string())?;
288    let foreground = current_foreground_app(&selected.serial)?;
289
290    Ok(MobileConnectInfo {
291        browser: "android".to_string(),
292        note: format!(
293            "connected Android device `{}` over ADB and attached the native Android runtime",
294            selected.name.as_deref().unwrap_or(selected.serial.as_str())
295        ),
296        browser_session: MobileBrowserSessionHandle {
297            platform: MobilePlatform::Android,
298            automation: MobileAutomationSessionInfo {
299                backend: "android-adb".to_string(),
300                session_id: format!("android-adb:{}", selected.serial),
301                note: "session established through the Android surface plugin".to_string(),
302            },
303            device: DeviceTarget {
304                platform: MobilePlatform::Android,
305                device_id: selected.serial.clone(),
306                connection_kind: selected.connection_kind,
307            },
308        },
309        initial_page: MobilePageInfo {
310            note: "attached to the device foreground context".to_string(),
311            page_session: MobilePageSessionHandle {
312                page_id: format!("{}:foreground", selected.serial),
313                package_name: foreground.current_package,
314                activity_name: foreground.current_activity,
315                webview_context: None,
316            },
317        },
318    })
319}
320
321pub fn launch_app(
322    browser_session: &MobileBrowserSessionHandle,
323    options: &LaunchOptions,
324) -> Result<MobilePageInfo, String> {
325    let device_id = browser_session.device.device_id.as_str();
326
327    if let Some(apk_path) = options.apk_path.as_deref() {
328        let apk = Path::new(apk_path);
329        if !apk.is_file() {
330            return Err(format!("APK path does not exist: {}", apk.display()));
331        }
332        let apk_owned = apk.to_string_lossy().into_owned();
333        let _ = run_adb_for_device(device_id, &["install", "-r", &apk_owned])?;
334    }
335
336    let package_name = match options.app_id.clone() {
337        Some(package_name) => package_name,
338        None => {
339            if let Some(apk_path) = options.apk_path.as_deref() {
340                resolve_package_name_from_apk(apk_path)?
341            } else {
342                return Err(
343                    "launch_app requires `app_id`, or an APK path whose package name can be resolved"
344                        .to_string(),
345                );
346            }
347        }
348    };
349
350    if options.stop_before_launch {
351        let _ = run_adb_for_device(device_id, &["shell", "am", "force-stop", &package_name])?;
352    }
353
354    if let Some(activity) = options.launch_activity.as_deref() {
355        let component = format!("{package_name}/{activity}");
356        let _ = run_adb_for_device(device_id, &["shell", "am", "start", "-W", "-n", &component])?;
357    } else {
358        let _ = run_adb_for_device(
359            device_id,
360            &[
361                "shell",
362                "monkey",
363                "-p",
364                &package_name,
365                "-c",
366                "android.intent.category.LAUNCHER",
367                "1",
368            ],
369        )?;
370    }
371
372    let current = current_foreground_app(device_id)?;
373    let activity_name = current
374        .current_activity
375        .or_else(|| options.launch_activity.clone());
376
377    Ok(MobilePageInfo {
378        note: format!(
379            "launched Android app `{package_name}` on {}",
380            browser_session.device.device_id
381        ),
382        page_session: MobilePageSessionHandle {
383            page_id: format!("{}:{package_name}", browser_session.device.device_id),
384            package_name: Some(package_name),
385            activity_name,
386            webview_context: None,
387        },
388    })
389}
390
391pub fn click_element(
392    browser_session: &MobileBrowserSessionHandle,
393    page_session: &MobilePageSessionHandle,
394    selector: &str,
395    timeout_ms: Option<u32>,
396) -> Result<MobileClickInfo, String> {
397    let normalized = normalize_selector_for_transport(selector);
398    if normalized.trim().is_empty() {
399        return Err("click_element requires a non-empty selector".to_string());
400    }
401
402    let clicked = adb_click(&browser_session.device.device_id, &normalized, timeout_ms)?;
403    Ok(MobileClickInfo {
404        selector: clicked.resolved_selector,
405        note: format!("{}; page={}", clicked.note, page_session.page_id),
406        session_id: browser_session.automation.session_id.clone(),
407    })
408}
409
410pub fn fill_element(
411    browser_session: &MobileBrowserSessionHandle,
412    page_session: &MobilePageSessionHandle,
413    selector: &str,
414    value: &str,
415    timeout_ms: Option<u32>,
416) -> Result<MobileFillInfo, String> {
417    let normalized = normalize_selector_for_transport(selector);
418    if normalized.trim().is_empty() {
419        return Err("fill_element requires a non-empty selector".to_string());
420    }
421
422    let filled = adb_fill(
423        &browser_session.device.device_id,
424        &normalized,
425        value,
426        timeout_ms,
427    )?;
428    Ok(MobileFillInfo {
429        selector: filled.resolved_selector,
430        value: filled.value,
431        note: format!("{}; page={}", filled.note, page_session.page_id),
432    })
433}
434
435pub fn dump_source(
436    browser_session: &MobileBrowserSessionHandle,
437    _page_session: &MobilePageSessionHandle,
438) -> Result<UiAutomator2SourceInfo, String> {
439    let source = adb_dump_source(&browser_session.device.device_id)?;
440    Ok(UiAutomator2SourceInfo {
441        source: source.source,
442        current_package: source.current_package,
443        current_activity: source.current_activity,
444    })
445}
446
447pub fn connect_result_example(options: &ConnectOptions) -> Result<MobileCommandResult, String> {
448    Ok(MobileCommandResult::Connect(connect(options)?))
449}
450
451pub async fn boot() -> String {
452    boot_surface("android", 10).await
453}
454
455#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
456struct MobilePluginEnvelope {
457    ok: bool,
458    result: Option<MobileCommandResult>,
459    error: Option<String>,
460}
461
462fn plugin_response(result: Result<MobileCommandResult, String>) -> *mut c_char {
463    let payload = match result {
464        Ok(result) => MobilePluginEnvelope {
465            ok: true,
466            result: Some(result),
467            error: None,
468        },
469        Err(error) => MobilePluginEnvelope {
470            ok: false,
471            result: None,
472            error: Some(error),
473        },
474    };
475
476    match serde_json::to_string(&payload) {
477        Ok(json) => CString::new(json)
478            .map(CString::into_raw)
479            .unwrap_or_else(|_| CString::new("{\"ok\":false,\"result\":null,\"error\":\"failed to encode plugin response\"}").expect("static JSON").into_raw()),
480        Err(error) => CString::new(format!(
481            "{{\"ok\":false,\"result\":null,\"error\":\"failed to serialize plugin response: {error}\"}}"
482        ))
483        .map(CString::into_raw)
484        .unwrap_or_else(|_| CString::new("{\"ok\":false,\"result\":null,\"error\":\"failed to serialize plugin response\"}").expect("static JSON").into_raw()),
485    }
486}
487
488fn handle_plugin_command(command: MobileCommand) -> Result<MobileCommandResult, String> {
489    match command {
490        MobileCommand::Connect(options) => connect(&options).map(MobileCommandResult::Connect),
491        MobileCommand::LaunchApp {
492            browser_session,
493            options,
494        } => launch_app(&browser_session, &options).map(MobileCommandResult::LaunchApp),
495        MobileCommand::OpenPage { browser_session } => {
496            let info = MobilePageInfo {
497                note: "attached to the Android foreground app context".to_string(),
498                page_session: MobilePageSessionHandle {
499                    page_id: format!("{}:foreground", browser_session.device.device_id),
500                    package_name: None,
501                    activity_name: None,
502                    webview_context: None,
503                },
504            };
505            Ok(MobileCommandResult::OpenPage(info))
506        }
507        MobileCommand::ClosePage { .. } => Ok(MobileCommandResult::ClosePage),
508        MobileCommand::ClickElement {
509            browser_session,
510            page_session,
511            selector,
512            timeout_ms,
513        } => click_element(&browser_session, &page_session, &selector, timeout_ms)
514            .map(MobileCommandResult::ClickElement),
515        MobileCommand::CountElements { .. } => {
516            Err("mobile-android plugin does not implement `count_elements` yet".to_string())
517        }
518        MobileCommand::FillElement {
519            browser_session,
520            page_session,
521            selector,
522            value,
523            timeout_ms,
524        } => fill_element(
525            &browser_session,
526            &page_session,
527            &selector,
528            &value,
529            timeout_ms,
530        )
531        .map(MobileCommandResult::FillElement),
532        MobileCommand::GetText { .. } => {
533            Err("mobile-android plugin does not implement `get_text` yet".to_string())
534        }
535        MobileCommand::WaitForSelector { .. } => {
536            Err("mobile-android plugin does not implement `wait_for_selector` yet".to_string())
537        }
538    }
539}
540
541#[unsafe(no_mangle)]
542pub extern "C" fn allwright_plugin_api_version() -> u32 {
543    ALLWRIGHT_PLUGIN_API_VERSION
544}
545
546#[unsafe(no_mangle)]
547pub extern "C" fn allwright_plugin_id() -> *const c_char {
548    c"mobile-android".as_ptr()
549}
550
551#[unsafe(no_mangle)]
552pub unsafe extern "C" fn allwright_plugin_invoke(request_json: *const c_char) -> *mut c_char {
553    if request_json.is_null() {
554        return plugin_response(Err("plugin request pointer is null".to_string()));
555    }
556
557    let request = match unsafe { CStr::from_ptr(request_json) }.to_str() {
558        Ok(request) => request,
559        Err(error) => {
560            return plugin_response(Err(format!("plugin request is not valid UTF-8: {error}")));
561        }
562    };
563
564    let command: MobileCommand = match serde_json::from_str(request) {
565        Ok(command) => command,
566        Err(error) => {
567            return plugin_response(Err(format!(
568                "failed to parse mobile plugin request JSON: {error}"
569            )));
570        }
571    };
572
573    plugin_response(handle_plugin_command(command))
574}
575
576#[unsafe(no_mangle)]
577pub unsafe extern "C" fn allwright_plugin_free_string(value: *mut c_char) {
578    if !value.is_null() {
579        unsafe {
580            let _ = CString::from_raw(value);
581        }
582    }
583}
584
585fn parse_adb_devices(output: &str) -> Vec<AdbDevice> {
586    output
587        .lines()
588        .map(str::trim)
589        .filter(|line| !line.is_empty() && !line.starts_with("List of devices attached"))
590        .filter_map(|line| {
591            let mut parts = line.split_whitespace();
592            let serial = parts.next()?.to_string();
593            let state = parts.next()?.to_string();
594            let metadata = parts.collect::<Vec<_>>();
595            let mut name = None;
596            for item in metadata {
597                if let Some(model) = item.strip_prefix("model:") {
598                    name = Some(model.replace('_', " "));
599                    break;
600                }
601                if let Some(device_name) = item.strip_prefix("device:") {
602                    name = Some(device_name.replace('_', " "));
603                }
604            }
605
606            Some(AdbDevice {
607                connection_kind: classify_device_connection_kind(&serial),
608                serial,
609                name,
610                state,
611            })
612        })
613        .collect()
614}
615
616fn classify_device_connection_kind(serial: &str) -> DeviceConnectionKind {
617    if serial.starts_with("emulator-") {
618        return DeviceConnectionKind::Emulator;
619    }
620    if serial.contains(':') {
621        return DeviceConnectionKind::RemoteAdb;
622    }
623    DeviceConnectionKind::Usb
624}
625
626fn run_adb(args: &[&str]) -> Result<String, String> {
627    run_command(&adb_command_path(), args)
628}
629
630fn run_adb_for_device(device_id: &str, args: &[&str]) -> Result<String, String> {
631    let mut all_args = vec!["-s", device_id];
632    all_args.extend_from_slice(args);
633    run_command(&adb_command_path(), &all_args)
634}
635
636fn adb_command_path() -> String {
637    env::var("ALLWRIGHT_ANDROID_ADB").unwrap_or_else(|_| "adb".to_string())
638}
639
640fn run_command(command: &str, args: &[&str]) -> Result<String, String> {
641    let output = Command::new(command)
642        .args(args)
643        .output()
644        .map_err(|error| format!("failed to run `{command}`: {error}"))?;
645    if output.status.success() {
646        return Ok(String::from_utf8_lossy(&output.stdout).trim().to_string());
647    }
648
649    let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
650    let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
651    let detail = if !stderr.is_empty() { stderr } else { stdout };
652    Err(format!(
653        "`{command} {}` failed with status {}: {}",
654        args.join(" "),
655        output
656            .status
657            .code()
658            .map_or_else(|| "signal".to_string(), |code| code.to_string()),
659        detail
660    ))
661}
662
663fn resolve_package_name_from_apk(apk_path: &str) -> Result<String, String> {
664    if let Ok(output) = run_command("aapt", &["dump", "badging", apk_path]) {
665        if let Some(package_name) = parse_package_name_from_aapt_badging(&output) {
666            return Ok(package_name);
667        }
668    }
669
670    if let Ok(output) = run_command("apkanalyzer", &["manifest", "application-id", apk_path]) {
671        let package_name = output.trim();
672        if !package_name.is_empty() {
673            return Ok(package_name.to_string());
674        }
675    }
676
677    Err(
678        "could not resolve the app package name from the APK. Provide `--app-id`, or install `aapt`/`apkanalyzer`."
679            .to_string(),
680    )
681}
682
683fn parse_package_name_from_aapt_badging(output: &str) -> Option<String> {
684    output.lines().find_map(|line| {
685        let package_line = line.trim();
686        if !package_line.starts_with("package: ") {
687            return None;
688        }
689        let needle = "name='";
690        let start = package_line.find(needle)? + needle.len();
691        let remainder = &package_line[start..];
692        let end = remainder.find('\'')?;
693        Some(remainder[..end].to_string())
694    })
695}
696
697fn current_foreground_app(device_id: &str) -> Result<ForegroundAppInfo, String> {
698    let window_dump = run_adb_for_device(device_id, &["shell", "dumpsys", "window", "windows"])
699        .or_else(|_| run_adb_for_device(device_id, &["shell", "dumpsys", "window"]))?;
700    Ok(parse_foreground_app_from_dumpsys(&window_dump))
701}
702
703fn adb_click(
704    device_id: &str,
705    selector: &str,
706    timeout_ms: Option<u32>,
707) -> Result<UiAutomator2ClickInfo, String> {
708    let snapshot = resolve_selector_snapshot(device_id, selector, timeout_ms)?;
709    let bounds = snapshot
710        .node
711        .bounds
712        .ok_or_else(|| format!("selector resolved without bounds: {}", snapshot.selector))?;
713    let (x, y) = bounds.center();
714    let _ = run_adb_for_device(
715        device_id,
716        &["shell", "input", "tap", &x.to_string(), &y.to_string()],
717    )?;
718    Ok(UiAutomator2ClickInfo {
719        resolved_selector: snapshot.selector,
720        note: format!(
721            "clicked Android element via native ADB input on {}/{}",
722            snapshot
723                .foreground
724                .current_package
725                .as_deref()
726                .unwrap_or("<unknown>"),
727            snapshot
728                .foreground
729                .current_activity
730                .as_deref()
731                .unwrap_or("<unknown>")
732        ),
733    })
734}
735
736fn adb_fill(
737    device_id: &str,
738    selector: &str,
739    fill_value: &str,
740    timeout_ms: Option<u32>,
741) -> Result<UiAutomator2FillInfo, String> {
742    let snapshot = resolve_selector_snapshot(device_id, selector, timeout_ms)?;
743    let bounds = snapshot
744        .node
745        .bounds
746        .ok_or_else(|| format!("selector resolved without bounds: {}", snapshot.selector))?;
747    let (x, y) = bounds.center();
748    let _ = run_adb_for_device(
749        device_id,
750        &["shell", "input", "tap", &x.to_string(), &y.to_string()],
751    )?;
752    let _ = run_adb_for_device(device_id, &["shell", "input", "keyevent", "KEYCODE_MOVE_END"])?;
753    let existing_text = snapshot.node.text.as_deref().unwrap_or("");
754    for _ in existing_text.chars() {
755        let _ = run_adb_for_device(device_id, &["shell", "input", "keyevent", "KEYCODE_DEL"])?;
756    }
757    let encoded = encode_adb_text(fill_value);
758    if !encoded.is_empty() {
759        let _ = run_adb_for_device(device_id, &["shell", "input", "text", &encoded])?;
760    }
761    Ok(UiAutomator2FillInfo {
762        resolved_selector: snapshot.selector,
763        value: fill_value.to_string(),
764        note: format!(
765            "filled Android element via native ADB input on {}/{}",
766            snapshot
767                .foreground
768                .current_package
769                .as_deref()
770                .unwrap_or("<unknown>"),
771            snapshot
772                .foreground
773                .current_activity
774                .as_deref()
775                .unwrap_or("<unknown>")
776        ),
777    })
778}
779
780fn adb_dump_source(device_id: &str) -> Result<UiAutomator2SourceInfo, String> {
781    let remote_path = "/data/local/tmp/allwright-window.xml";
782    let _ = run_adb_for_device(device_id, &["shell", "uiautomator", "dump", remote_path])?;
783    let source = run_adb_for_device(device_id, &["shell", "cat", remote_path])?;
784    let foreground = current_foreground_app(device_id)?;
785    Ok(UiAutomator2SourceInfo {
786        source,
787        current_package: foreground.current_package,
788        current_activity: foreground.current_activity,
789    })
790}
791
792struct ResolvedSelectorSnapshot {
793    selector: String,
794    node: AndroidUiNode,
795    foreground: ForegroundAppInfo,
796}
797
798fn resolve_selector_snapshot(
799    device_id: &str,
800    selector: &str,
801    _timeout_ms: Option<u32>,
802) -> Result<ResolvedSelectorSnapshot, String> {
803    let source = adb_dump_source(device_id)?;
804    let nodes = parse_android_ui_nodes(&source.source)?;
805    let node = find_node_by_selector(&nodes, selector).ok_or_else(|| {
806        let package_name = source.current_package.as_deref().unwrap_or("<unknown>");
807        let activity_name = source.current_activity.as_deref().unwrap_or("<unknown>");
808        format!(
809            "selector not found: {selector} (current app: {package_name}/{activity_name})"
810        )
811    })?;
812    Ok(ResolvedSelectorSnapshot {
813        selector: selector.to_string(),
814        node: node.clone(),
815        foreground: ForegroundAppInfo {
816            current_package: source.current_package,
817            current_activity: source.current_activity,
818        },
819    })
820}
821
822fn parse_foreground_app_from_dumpsys(output: &str) -> ForegroundAppInfo {
823    for line in output.lines() {
824        if !(line.contains("mCurrentFocus") || line.contains("mFocusedApp")) {
825            continue;
826        }
827        if let Some((package_name, activity_name)) = extract_component_from_line(line) {
828            return ForegroundAppInfo {
829                current_package: Some(package_name),
830                current_activity: Some(activity_name),
831            };
832        }
833    }
834    ForegroundAppInfo {
835        current_package: None,
836        current_activity: None,
837    }
838}
839
840fn extract_component_from_line(line: &str) -> Option<(String, String)> {
841    for token in line.split_whitespace() {
842        let cleaned = token.trim_matches(|ch: char| matches!(ch, '{' | '}' | ',' | ';'));
843        let slash_index = cleaned.find('/')?;
844        let package_name = cleaned[..slash_index].trim();
845        let activity_name = cleaned[slash_index + 1..].trim();
846        if package_name.is_empty()
847            || activity_name.is_empty()
848            || !package_name.contains('.')
849            || package_name.contains('=')
850        {
851            continue;
852        }
853        return Some((package_name.to_string(), activity_name.to_string()));
854    }
855    None
856}
857
858fn parse_android_ui_nodes(xml: &str) -> Result<Vec<AndroidUiNode>, String> {
859    let mut nodes = Vec::new();
860    let mut stack: Vec<usize> = Vec::new();
861    let mut cursor = 0usize;
862    while let Some(relative_start) = xml[cursor..].find('<') {
863        let start = cursor + relative_start;
864        let Some(relative_end) = xml[start..].find('>') else {
865            break;
866        };
867        let end = start + relative_end;
868        let tag = &xml[start + 1..end];
869        let trimmed = tag.trim();
870        if trimmed.starts_with("/node") {
871            let _ = stack.pop();
872        } else if trimmed.starts_with("node") {
873            let self_closing = trimmed.ends_with('/');
874            let attributes = parse_xml_attributes(trimmed);
875            let node = AndroidUiNode {
876                class_name: attributes.get("class").cloned(),
877                resource_id: attributes.get("resource-id").cloned(),
878                package_name: attributes.get("package").cloned(),
879                text: attributes.get("text").cloned(),
880                content_desc: attributes.get("content-desc").cloned(),
881                checkable: attributes.get("checkable").and_then(|value| parse_bool(value)),
882                checked: attributes.get("checked").and_then(|value| parse_bool(value)),
883                clickable: attributes.get("clickable").and_then(|value| parse_bool(value)),
884                long_clickable: attributes
885                    .get("long-clickable")
886                    .and_then(|value| parse_bool(value)),
887                scrollable: attributes.get("scrollable").and_then(|value| parse_bool(value)),
888                enabled: attributes.get("enabled").and_then(|value| parse_bool(value)),
889                focusable: attributes.get("focusable").and_then(|value| parse_bool(value)),
890                focused: attributes.get("focused").and_then(|value| parse_bool(value)),
891                selected: attributes.get("selected").and_then(|value| parse_bool(value)),
892                index: attributes.get("index").and_then(|value| value.parse().ok()),
893                bounds: attributes
894                    .get("bounds")
895                    .and_then(|value| parse_bounds(value)),
896                parent_index: stack.last().copied(),
897            };
898            let index = nodes.len();
899            nodes.push(node);
900            if !self_closing {
901                stack.push(index);
902            }
903        }
904        cursor = end + 1;
905    }
906    Ok(nodes)
907}
908
909fn parse_xml_attributes(tag: &str) -> std::collections::BTreeMap<String, String> {
910    let mut attributes = std::collections::BTreeMap::new();
911    let bytes = tag.as_bytes();
912    let mut index = 0usize;
913    while index < bytes.len() {
914        while index < bytes.len() && !bytes[index].is_ascii_alphabetic() {
915            index += 1;
916        }
917        let key_start = index;
918        while index < bytes.len()
919            && (bytes[index].is_ascii_alphanumeric() || matches!(bytes[index], b'-' | b'_'))
920        {
921            index += 1;
922        }
923        if key_start == index {
924            continue;
925        }
926        let key = &tag[key_start..index];
927        while index < bytes.len() && bytes[index].is_ascii_whitespace() {
928            index += 1;
929        }
930        if index >= bytes.len() || bytes[index] != b'=' {
931            continue;
932        }
933        index += 1;
934        while index < bytes.len() && bytes[index].is_ascii_whitespace() {
935            index += 1;
936        }
937        if index >= bytes.len() || bytes[index] != b'"' {
938            continue;
939        }
940        index += 1;
941        let value_start = index;
942        while index < bytes.len() && bytes[index] != b'"' {
943            index += 1;
944        }
945        if index > value_start {
946            attributes.insert(
947                key.to_string(),
948                decode_xml_entities(&tag[value_start..index]),
949            );
950        }
951        if index < bytes.len() {
952            index += 1;
953        }
954    }
955    attributes
956}
957
958fn decode_xml_entities(value: &str) -> String {
959    value
960        .replace("&quot;", "\"")
961        .replace("&apos;", "'")
962        .replace("&lt;", "<")
963        .replace("&gt;", ">")
964        .replace("&amp;", "&")
965}
966
967fn parse_bool(value: &str) -> Option<bool> {
968    match value.trim().to_ascii_lowercase().as_str() {
969        "true" => Some(true),
970        "false" => Some(false),
971        _ => None,
972    }
973}
974
975fn parse_bounds(value: &str) -> Option<AndroidBounds> {
976    let trimmed = value.trim();
977    let trimmed = trimmed.strip_prefix('[')?;
978    let (left_top, right_bottom) = trimmed.split_once("][")?;
979    let right_bottom = right_bottom.strip_suffix(']')?;
980    let (left, top) = left_top.split_once(',')?;
981    let (right, bottom) = right_bottom.split_once(',')?;
982    Some(AndroidBounds {
983        left: left.parse().ok()?,
984        top: top.parse().ok()?,
985        right: right.parse().ok()?,
986        bottom: bottom.parse().ok()?,
987    })
988}
989
990fn find_node_by_selector<'a>(
991    nodes: &'a [AndroidUiNode],
992    selector: &str,
993) -> Option<&'a AndroidUiNode> {
994    let segments = parse_selector_segments(selector).ok()?;
995    let criteria = segments
996        .iter()
997        .map(selector_segment_to_criteria)
998        .collect::<Result<Vec<_>, _>>()
999        .ok()?;
1000    let last = criteria.last()?;
1001    let mut matches = Vec::new();
1002    for (index, node) in nodes.iter().enumerate() {
1003        if !node_matches_criteria(node, last) {
1004            continue;
1005        }
1006        if ancestor_chain_matches(nodes, index, &criteria[..criteria.len().saturating_sub(1)]) {
1007            matches.push(node);
1008        }
1009    }
1010    if let Some(instance) = last.instance {
1011        return matches.get(instance).copied();
1012    }
1013    matches.into_iter().next()
1014}
1015
1016fn ancestor_chain_matches(
1017    nodes: &[AndroidUiNode],
1018    node_index: usize,
1019    criteria_chain: &[NodeCriteria],
1020) -> bool {
1021    if criteria_chain.is_empty() {
1022        return true;
1023    }
1024    let mut current_parent = nodes[node_index].parent_index;
1025    for criteria in criteria_chain.iter().rev() {
1026        let mut matched_parent = None;
1027        let mut probe = current_parent;
1028        while let Some(index) = probe {
1029            if node_matches_criteria(&nodes[index], criteria) {
1030                matched_parent = Some(index);
1031                break;
1032            }
1033            probe = nodes[index].parent_index;
1034        }
1035        let Some(index) = matched_parent else {
1036            return false;
1037        };
1038        current_parent = nodes[index].parent_index;
1039    }
1040    true
1041}
1042
1043fn node_matches_criteria(node: &AndroidUiNode, criteria: &NodeCriteria) -> bool {
1044    if let Some(expected) = criteria.class_name.as_deref()
1045        && node.class_name.as_deref() != Some(expected)
1046    {
1047        return false;
1048    }
1049    if let Some(pattern) = criteria.class_name_matches.as_deref()
1050        && !matches_regex(node.class_name.as_deref(), pattern)
1051    {
1052        return false;
1053    }
1054    if let Some(expected) = criteria.resource_id_exact.as_deref()
1055        && node.resource_id.as_deref() != Some(expected)
1056    {
1057        return false;
1058    }
1059    if let Some(expected_suffix) = criteria.resource_id_suffix.as_deref() {
1060        let Some(resource_id) = node.resource_id.as_deref() else {
1061            return false;
1062        };
1063        if !resource_id.ends_with(expected_suffix) {
1064            return false;
1065        }
1066    }
1067    if let Some(pattern) = criteria.resource_id_matches.as_deref()
1068        && !matches_regex(node.resource_id.as_deref(), pattern)
1069    {
1070        return false;
1071    }
1072    if let Some(expected) = criteria.package_name.as_deref()
1073        && node.package_name.as_deref() != Some(expected)
1074    {
1075        return false;
1076    }
1077    if let Some(pattern) = criteria.package_name_matches.as_deref()
1078        && !matches_regex(node.package_name.as_deref(), pattern)
1079    {
1080        return false;
1081    }
1082    if let Some(expected) = criteria.text.as_deref()
1083        && node.text.as_deref() != Some(expected)
1084    {
1085        return false;
1086    }
1087    if let Some(expected) = criteria.text_contains.as_deref()
1088        && !contains_text(node.text.as_deref(), expected)
1089    {
1090        return false;
1091    }
1092    if let Some(expected) = criteria.text_starts_with.as_deref()
1093        && !starts_with_text(node.text.as_deref(), expected)
1094    {
1095        return false;
1096    }
1097    if let Some(pattern) = criteria.text_matches.as_deref()
1098        && !matches_regex(node.text.as_deref(), pattern)
1099    {
1100        return false;
1101    }
1102    if let Some(expected) = criteria.content_desc.as_deref()
1103        && node.content_desc.as_deref() != Some(expected)
1104    {
1105        return false;
1106    }
1107    if let Some(expected) = criteria.content_desc_contains.as_deref()
1108        && !contains_text(node.content_desc.as_deref(), expected)
1109    {
1110        return false;
1111    }
1112    if let Some(expected) = criteria.content_desc_starts_with.as_deref()
1113        && !starts_with_text(node.content_desc.as_deref(), expected)
1114    {
1115        return false;
1116    }
1117    if let Some(pattern) = criteria.content_desc_matches.as_deref()
1118        && !matches_regex(node.content_desc.as_deref(), pattern)
1119    {
1120        return false;
1121    }
1122    if let Some(expected) = criteria.checkable && node.checkable != Some(expected) {
1123        return false;
1124    }
1125    if let Some(expected) = criteria.checked && node.checked != Some(expected) {
1126        return false;
1127    }
1128    if let Some(expected) = criteria.clickable && node.clickable != Some(expected) {
1129        return false;
1130    }
1131    if let Some(expected) = criteria.long_clickable && node.long_clickable != Some(expected) {
1132        return false;
1133    }
1134    if let Some(expected) = criteria.scrollable && node.scrollable != Some(expected) {
1135        return false;
1136    }
1137    if let Some(expected) = criteria.enabled && node.enabled != Some(expected) {
1138        return false;
1139    }
1140    if let Some(expected) = criteria.focusable && node.focusable != Some(expected) {
1141        return false;
1142    }
1143    if let Some(expected) = criteria.focused && node.focused != Some(expected) {
1144        return false;
1145    }
1146    if let Some(expected) = criteria.selected && node.selected != Some(expected) {
1147        return false;
1148    }
1149    if let Some(expected) = criteria.index && node.index != Some(expected) {
1150        return false;
1151    }
1152    true
1153}
1154
1155fn parse_selector_segments(selector: &str) -> Result<Vec<SelectorSegment>, String> {
1156    let mut segments = Vec::new();
1157    let normalized = normalize_selector_for_transport(selector);
1158    let trimmed = normalized.trim();
1159    let mut index = 0usize;
1160    while index < trimmed.len() {
1161        let remainder = &trimmed[index..];
1162        let (flavor, prefix_len) = if remainder.starts_with("css=") {
1163            (SelectorFlavorInternal::Css, 4usize)
1164        } else if remainder.starts_with("xpath=") {
1165            (SelectorFlavorInternal::XPath, 6usize)
1166        } else if remainder.starts_with("uia=") {
1167            (SelectorFlavorInternal::UiAutomator, 4usize)
1168        } else {
1169            return Err(format!("unsupported selector segment in `{trimmed}`"));
1170        };
1171        index += prefix_len;
1172        let bytes = trimmed.as_bytes();
1173        if bytes.get(index).copied() != Some(b'"') {
1174            return Err(format!("selector segment must use JSON string syntax: `{trimmed}`"));
1175        }
1176        let start = index;
1177        index += 1;
1178        let mut escaped = false;
1179        while index < trimmed.len() {
1180            let byte = bytes[index];
1181            if escaped {
1182                escaped = false;
1183            } else if byte == b'\\' {
1184                escaped = true;
1185            } else if byte == b'"' {
1186                index += 1;
1187                break;
1188            }
1189            index += 1;
1190        }
1191        let json_body = &trimmed[start..index];
1192        let value = serde_json::from_str::<String>(json_body)
1193            .map_err(|error| format!("failed to decode selector segment {json_body}: {error}"))?;
1194        segments.push(SelectorSegment { flavor, value });
1195        while index < trimmed.len() && trimmed.as_bytes()[index].is_ascii_whitespace() {
1196            index += 1;
1197        }
1198    }
1199    if segments.is_empty() {
1200        return Err("selector must not be empty".to_string());
1201    }
1202    Ok(segments)
1203}
1204
1205fn selector_segment_to_criteria(segment: &SelectorSegment) -> Result<NodeCriteria, String> {
1206    match segment.flavor {
1207        SelectorFlavorInternal::Css => parse_css_criteria(&segment.value),
1208        SelectorFlavorInternal::XPath => parse_xpath_criteria(&segment.value),
1209        SelectorFlavorInternal::UiAutomator => parse_uiautomator_criteria(&segment.value),
1210    }
1211}
1212
1213fn parse_uiautomator_criteria(selector: &str) -> Result<NodeCriteria, String> {
1214    let mut criteria = NodeCriteria::default();
1215    for token in split_uiautomator_tokens(selector) {
1216        let (raw_key, raw_value) = split_uiautomator_token(&token)?;
1217        let key = raw_key.to_ascii_lowercase();
1218        match key.as_str() {
1219            "text" => criteria.text = Some(raw_value.to_string()),
1220            "textcontains" => criteria.text_contains = Some(raw_value.to_string()),
1221            "textstartswith" => criteria.text_starts_with = Some(raw_value.to_string()),
1222            "textmatches" => criteria.text_matches = Some(raw_value.to_string()),
1223            "classname" => criteria.class_name = Some(raw_value.to_string()),
1224            "classnamematches" => criteria.class_name_matches = Some(raw_value.to_string()),
1225            "description" | "desc" => criteria.content_desc = Some(raw_value.to_string()),
1226            "descriptioncontains" | "desccontains" => {
1227                criteria.content_desc_contains = Some(raw_value.to_string())
1228            }
1229            "descriptionstartswith" | "descstartswith" => {
1230                criteria.content_desc_starts_with = Some(raw_value.to_string())
1231            }
1232            "descriptionmatches" | "descmatches" => {
1233                criteria.content_desc_matches = Some(raw_value.to_string())
1234            }
1235            "packagename" | "package" => criteria.package_name = Some(raw_value.to_string()),
1236            "packagenamematches" => criteria.package_name_matches = Some(raw_value.to_string()),
1237            "resourceid" => criteria.resource_id_exact = Some(raw_value.to_string()),
1238            "resourceidmatches" => criteria.resource_id_matches = Some(raw_value.to_string()),
1239            "checkable" => criteria.checkable = Some(parse_selector_bool(raw_value, &token)?),
1240            "checked" => criteria.checked = Some(parse_selector_bool(raw_value, &token)?),
1241            "clickable" => criteria.clickable = Some(parse_selector_bool(raw_value, &token)?),
1242            "longclickable" => {
1243                criteria.long_clickable = Some(parse_selector_bool(raw_value, &token)?)
1244            }
1245            "scrollable" => criteria.scrollable = Some(parse_selector_bool(raw_value, &token)?),
1246            "enabled" => criteria.enabled = Some(parse_selector_bool(raw_value, &token)?),
1247            "focusable" => criteria.focusable = Some(parse_selector_bool(raw_value, &token)?),
1248            "focused" => criteria.focused = Some(parse_selector_bool(raw_value, &token)?),
1249            "selected" => criteria.selected = Some(parse_selector_bool(raw_value, &token)?),
1250            "index" => criteria.index = Some(parse_selector_usize(raw_value, &token)?),
1251            "instance" => criteria.instance = Some(parse_selector_usize(raw_value, &token)?),
1252            other => return Err(format!("unsupported UiAutomator selector key `{other}`")),
1253        }
1254    }
1255    Ok(criteria)
1256}
1257
1258fn split_uiautomator_tokens(selector: &str) -> Vec<String> {
1259    let mut tokens = Vec::new();
1260    let mut current = String::new();
1261    let mut in_single = false;
1262    let mut in_double = false;
1263    for ch in selector.chars() {
1264        match ch {
1265            '\'' if !in_double => {
1266                in_single = !in_single;
1267                current.push(ch);
1268            }
1269            '"' if !in_single => {
1270                in_double = !in_double;
1271                current.push(ch);
1272            }
1273            ',' | ';' if !in_single && !in_double => {
1274                let trimmed = current.trim();
1275                if !trimmed.is_empty() {
1276                    tokens.push(trimmed.to_string());
1277                }
1278                current.clear();
1279            }
1280            _ => current.push(ch),
1281        }
1282    }
1283    let trimmed = current.trim();
1284    if !trimmed.is_empty() {
1285        tokens.push(trimmed.to_string());
1286    }
1287    tokens
1288}
1289
1290fn split_uiautomator_token(token: &str) -> Result<(&str, &str), String> {
1291    let Some((key, value)) = token.split_once(['=', ':']) else {
1292        return Err(format!("invalid UiAutomator selector token `{token}`"));
1293    };
1294    Ok((key.trim(), value.trim()))
1295}
1296
1297fn parse_selector_bool(value: &str, token: &str) -> Result<bool, String> {
1298    parse_bool(value).ok_or_else(|| format!("invalid boolean value in `{token}`"))
1299}
1300
1301fn parse_selector_usize(value: &str, token: &str) -> Result<usize, String> {
1302    value
1303        .parse()
1304        .map_err(|_| format!("invalid integer value in `{token}`"))
1305}
1306
1307fn parse_css_criteria(selector: &str) -> Result<NodeCriteria, String> {
1308    let mut criteria = NodeCriteria::default();
1309    let mut remainder = selector.trim();
1310    while let Some(start) = remainder.find('[') {
1311        let end = remainder[start + 1..]
1312            .find(']')
1313            .map(|index| start + 1 + index)
1314            .ok_or_else(|| format!("unterminated CSS attribute selector in `{selector}`"))?;
1315        let attribute = &remainder[start + 1..end];
1316        let (name, value) = parse_attribute_filter(attribute)?;
1317        match name {
1318            "text" => criteria.text = Some(value),
1319            "content-desc" => criteria.content_desc = Some(value),
1320            "resource-id" => criteria.resource_id_exact = Some(value),
1321            _ => {
1322                return Err(format!(
1323                    "unsupported CSS attribute selector `{name}` in `{selector}`"
1324                ));
1325            }
1326        }
1327        let mut next = String::with_capacity(remainder.len());
1328        next.push_str(remainder[..start].trim_end());
1329        next.push(' ');
1330        next.push_str(remainder[end + 1..].trim_start());
1331        let owned = next.trim().to_string();
1332        remainder = Box::leak(owned.into_boxed_str());
1333    }
1334
1335    let class_or_id = remainder.trim();
1336    if let Some(resource_id) = class_or_id.strip_prefix('#') {
1337        if resource_id.contains(':') {
1338            criteria.resource_id_exact = Some(resource_id.to_string());
1339        } else {
1340            criteria.resource_id_suffix = Some(format!(":id/{resource_id}"));
1341        }
1342    } else if let Some(class_name) = class_or_id.strip_prefix('.') {
1343        if !class_name.is_empty() {
1344            criteria.class_name = Some(class_name.to_string());
1345        }
1346    } else if !class_or_id.is_empty() {
1347        criteria.class_name = Some(class_or_id.to_string());
1348    }
1349    Ok(criteria)
1350}
1351
1352fn parse_xpath_criteria(selector: &str) -> Result<NodeCriteria, String> {
1353    let trimmed = selector.trim();
1354    let mut criteria = NodeCriteria::default();
1355    let mut path = trimmed;
1356    if let Some(stripped) = path.strip_prefix(".//") {
1357        path = stripped;
1358    } else if let Some(stripped) = path.strip_prefix("//") {
1359        path = stripped;
1360    }
1361
1362    let path = path.trim();
1363    let (node_pattern, predicates) = if let Some(start) = path.find('[') {
1364        let end = path.rfind(']').ok_or_else(|| format!("unterminated XPath in `{selector}`"))?;
1365        (&path[..start], Some(&path[start + 1..end]))
1366    } else {
1367        (path, None)
1368    };
1369
1370    let node_pattern = node_pattern.trim();
1371    if !node_pattern.is_empty() && node_pattern != "*" {
1372        criteria.class_name = Some(node_pattern.to_string());
1373    }
1374
1375    if let Some(predicates) = predicates {
1376        for predicate in predicates.split(" and ") {
1377            let predicate = predicate.trim().trim_matches(|ch| ch == '(' || ch == ')');
1378            if let Some(value) = parse_xpath_attr_equals(predicate, "@text") {
1379                criteria.text = Some(value);
1380                continue;
1381            }
1382            if let Some(value) = parse_xpath_attr_equals(predicate, "@content-desc") {
1383                criteria.content_desc = Some(value);
1384                continue;
1385            }
1386            if let Some(value) = parse_xpath_attr_equals(predicate, "@class") {
1387                criteria.class_name = Some(value);
1388                continue;
1389            }
1390            if let Some(value) = parse_xpath_attr_equals(predicate, "@resource-id") {
1391                if value.contains(':') {
1392                    criteria.resource_id_exact = Some(value);
1393                } else {
1394                    criteria.resource_id_suffix = Some(format!(":id/{value}"));
1395                }
1396                continue;
1397            }
1398            if let Some(value) = parse_xpath_resource_suffix(predicate) {
1399                criteria.resource_id_suffix = Some(value);
1400                continue;
1401            }
1402        }
1403    }
1404
1405    Ok(criteria)
1406}
1407
1408fn parse_attribute_filter(attribute: &str) -> Result<(&str, String), String> {
1409    let (name, value) = attribute
1410        .split_once('=')
1411        .ok_or_else(|| format!("unsupported CSS attribute selector `{attribute}`"))?;
1412    Ok((name.trim(), parse_quoted_value(value.trim())?))
1413}
1414
1415fn parse_xpath_attr_equals(predicate: &str, attr_name: &str) -> Option<String> {
1416    let remainder = predicate.strip_prefix(attr_name)?;
1417    let remainder = remainder.trim_start();
1418    let remainder = remainder.strip_prefix('=')?.trim_start();
1419    parse_quoted_value(remainder).ok()
1420}
1421
1422fn parse_xpath_resource_suffix(predicate: &str) -> Option<String> {
1423    let suffix_marker = ":id/";
1424    let start = predicate.find(suffix_marker)?;
1425    let candidate = &predicate[start - 1..];
1426    parse_quoted_value(candidate).ok()
1427}
1428
1429fn contains_text(actual: Option<&str>, expected: &str) -> bool {
1430    actual.is_some_and(|value| value.contains(expected))
1431}
1432
1433fn starts_with_text(actual: Option<&str>, expected: &str) -> bool {
1434    actual.is_some_and(|value| value.starts_with(expected))
1435}
1436
1437fn matches_regex(actual: Option<&str>, pattern: &str) -> bool {
1438    let Some(actual) = actual else {
1439        return false;
1440    };
1441    Regex::new(pattern).is_ok_and(|regex| regex.is_match(actual))
1442}
1443
1444fn parse_quoted_value(input: &str) -> Result<String, String> {
1445    let trimmed = input.trim();
1446    let quote = trimmed
1447        .chars()
1448        .next()
1449        .filter(|ch| *ch == '\'' || *ch == '"')
1450        .ok_or_else(|| format!("expected quoted value in `{input}`"))?;
1451    let end = trimmed[1..]
1452        .find(quote)
1453        .ok_or_else(|| format!("unterminated quoted value in `{input}`"))?;
1454    Ok(trimmed[1..1 + end].to_string())
1455}
1456
1457fn encode_adb_text(value: &str) -> String {
1458    let mut encoded = String::new();
1459    for ch in value.chars() {
1460        match ch {
1461            ' ' => encoded.push_str("%s"),
1462            '"' => encoded.push_str("\\\""),
1463            '\'' => encoded.push_str("\\'"),
1464            '&' | '|' | ';' | '<' | '>' | '(' | ')' | '$' | '\\' => {
1465                encoded.push('\\');
1466                encoded.push(ch);
1467            }
1468            _ => encoded.push(ch),
1469        }
1470    }
1471    encoded
1472}
1473
1474#[cfg(test)]
1475mod tests {
1476    use super::*;
1477
1478    #[tokio::test]
1479    async fn boots_android_runtime() {
1480        assert_eq!(boot().await, "android ready");
1481    }
1482
1483    #[test]
1484    fn defaults_to_uiautomator2() {
1485        let android = profile();
1486        assert_eq!(
1487            android.default_backend,
1488            MobileAutomationBackend::UiAutomator2
1489        );
1490        assert_eq!(android.backends, ANDROID_BACKENDS);
1491        assert!(android.capabilities.supports_native_views);
1492        assert!(android.capabilities.supports_webviews);
1493    }
1494
1495    #[test]
1496    fn exposes_android_runtime_milestones() {
1497        let readiness = runtime_readiness();
1498        assert_eq!(readiness.maturity, RuntimeMaturity::Scaffolding);
1499        assert!(
1500            readiness
1501                .missing_runtime_artifacts
1502                .iter()
1503                .any(|item| item.contains("server-side mobile session routing"))
1504        );
1505    }
1506
1507    #[test]
1508    fn connect_flow_matches_web_shape() {
1509        let options = ConnectOptions {
1510            platform: MobilePlatform::Android,
1511            device: Some("emulator-5554".to_string()),
1512            adb_endpoint: None,
1513            preserve_app_state: true,
1514            timeout_ms: Some(5_000),
1515        };
1516
1517        let command = connect_command(options.clone());
1518
1519        match command {
1520            MobileCommand::Connect(connect) => {
1521                assert_eq!(connect.platform, MobilePlatform::Android);
1522                assert_eq!(connect.device.as_deref(), Some("emulator-5554"));
1523            }
1524            _ => panic!("expected mobile connect command"),
1525        }
1526    }
1527
1528    #[test]
1529    fn launch_flow_keeps_apk_work_separate() {
1530        let browser_session = MobileBrowserSessionHandle {
1531            platform: MobilePlatform::Android,
1532            automation: MobileAutomationSessionInfo {
1533                backend: "uiautomator2".to_string(),
1534                session_id: "uiautomator2:emulator-5554".to_string(),
1535                note: "ready".to_string(),
1536            },
1537            device: DeviceTarget {
1538                platform: MobilePlatform::Android,
1539                device_id: "emulator-5554".to_string(),
1540                connection_kind: DeviceConnectionKind::Emulator,
1541            },
1542        };
1543
1544        let command = launch_command(
1545            browser_session,
1546            LaunchOptions {
1547                apk_path: Some("/tmp/app.apk".to_string()),
1548                app_id: Some("dev.allwright.sample".to_string()),
1549                launch_activity: Some(".MainActivity".to_string()),
1550                stop_before_launch: true,
1551                timeout_ms: Some(15_000),
1552            },
1553        );
1554
1555        match command {
1556            MobileCommand::LaunchApp { options, .. } => {
1557                assert_eq!(options.apk_path.as_deref(), Some("/tmp/app.apk"));
1558                assert_eq!(options.app_id.as_deref(), Some("dev.allwright.sample"));
1559                assert!(options.stop_before_launch);
1560            }
1561            _ => panic!("expected launch command"),
1562        }
1563    }
1564
1565    #[test]
1566    fn parse_adb_devices_extracts_friendly_names() {
1567        let devices = parse_adb_devices(
1568            "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",
1569        );
1570
1571        assert_eq!(devices.len(), 2);
1572        assert_eq!(devices[0].name.as_deref(), Some("Pixel 8 API 35"));
1573        assert_eq!(devices[1].name.as_deref(), Some("QA Galaxy S24"));
1574        assert_eq!(devices[0].connection_kind, DeviceConnectionKind::Emulator);
1575        assert_eq!(devices[1].connection_kind, DeviceConnectionKind::Usb);
1576    }
1577
1578    #[test]
1579    fn connect_uses_requested_device_when_provided() {
1580        let options = ConnectOptions {
1581            platform: MobilePlatform::Android,
1582            device: Some("R5CX123".to_string()),
1583            adb_endpoint: None,
1584            preserve_app_state: false,
1585            timeout_ms: None,
1586        };
1587        let devices = vec![
1588            AdbDevice {
1589                serial: "emulator-5554".to_string(),
1590                name: Some("Pixel 8 API 35".to_string()),
1591                connection_kind: DeviceConnectionKind::Emulator,
1592                state: "device".to_string(),
1593            },
1594            AdbDevice {
1595                serial: "R5CX123".to_string(),
1596                name: Some("QA Galaxy S24".to_string()),
1597                connection_kind: DeviceConnectionKind::Usb,
1598                state: "device".to_string(),
1599            },
1600        ];
1601
1602        let selected = select_device_for_connect(&options, &devices).expect("device should match");
1603        assert_eq!(selected.serial, "R5CX123");
1604        assert_eq!(selected.connection_kind, DeviceConnectionKind::Usb);
1605    }
1606
1607    #[test]
1608    fn connect_defaults_to_first_available_device() {
1609        let options = ConnectOptions {
1610            platform: MobilePlatform::Android,
1611            device: None,
1612            adb_endpoint: None,
1613            preserve_app_state: false,
1614            timeout_ms: None,
1615        };
1616        let devices = vec![
1617            AdbDevice {
1618                serial: "emulator-5554".to_string(),
1619                name: Some("Pixel 8 API 35".to_string()),
1620                connection_kind: DeviceConnectionKind::Emulator,
1621                state: "device".to_string(),
1622            },
1623            AdbDevice {
1624                serial: "R5CX123".to_string(),
1625                name: Some("QA Galaxy S24".to_string()),
1626                connection_kind: DeviceConnectionKind::Usb,
1627                state: "device".to_string(),
1628            },
1629        ];
1630
1631        let selected = select_device_for_connect(&options, &devices).expect("first device");
1632        assert_eq!(selected.serial, "emulator-5554");
1633    }
1634
1635    #[test]
1636    fn connect_matches_friendly_device_name() {
1637        let options = ConnectOptions {
1638            platform: MobilePlatform::Android,
1639            device: Some("qa galaxy s24".to_string()),
1640            adb_endpoint: None,
1641            preserve_app_state: false,
1642            timeout_ms: None,
1643        };
1644        let devices = vec![
1645            AdbDevice {
1646                serial: "emulator-5554".to_string(),
1647                name: Some("Pixel 8 API 35".to_string()),
1648                connection_kind: DeviceConnectionKind::Emulator,
1649                state: "device".to_string(),
1650            },
1651            AdbDevice {
1652                serial: "R5CX123".to_string(),
1653                name: Some("QA Galaxy S24".to_string()),
1654                connection_kind: DeviceConnectionKind::Usb,
1655                state: "device".to_string(),
1656            },
1657        ];
1658
1659        let selected = select_device_for_connect(&options, &devices).expect("friendly name match");
1660        assert_eq!(selected.serial, "R5CX123");
1661    }
1662
1663    #[test]
1664    fn connect_fails_when_requested_device_is_missing() {
1665        let options = ConnectOptions {
1666            platform: MobilePlatform::Android,
1667            device: Some("missing-device".to_string()),
1668            adb_endpoint: None,
1669            preserve_app_state: false,
1670            timeout_ms: None,
1671        };
1672        let devices = vec![AdbDevice {
1673            serial: "emulator-5554".to_string(),
1674            name: Some("Pixel 8 API 35".to_string()),
1675            connection_kind: DeviceConnectionKind::Emulator,
1676            state: "device".to_string(),
1677        }];
1678
1679        let error = select_device_for_connect(&options, &devices).expect_err("missing device");
1680        assert_eq!(
1681            error,
1682            ConnectSelectionError::RequestedDeviceNotFound {
1683                requested: "missing-device".to_string()
1684            }
1685        );
1686    }
1687
1688    #[test]
1689    fn parse_package_name_from_aapt_output_extracts_package() {
1690        let package_name = parse_package_name_from_aapt_badging(
1691            "package: name='dev.allwright.sample' versionCode='1' versionName='1.0'\n",
1692        )
1693        .expect("package name should parse");
1694        assert_eq!(package_name, "dev.allwright.sample");
1695    }
1696
1697    #[test]
1698    fn dump_source_keeps_page_context_separate() {
1699        let page_session = MobilePageSessionHandle {
1700            page_id: "emulator-5554:dev.allwright.sample".to_string(),
1701            package_name: Some("dev.allwright.sample".to_string()),
1702            activity_name: Some(".MainActivity".to_string()),
1703            webview_context: None,
1704        };
1705        assert_eq!(page_session.page_id, "emulator-5554:dev.allwright.sample");
1706    }
1707
1708    #[test]
1709    fn parses_uiautomator_selector_strategies() {
1710        let criteria = parse_uiautomator_criteria(
1711            "textContains=Account,descriptionStartsWith=Open,classNameMatches=android\\.widget\\..*,clickable=true,index=2,instance=1,resourceIdMatches=.*bottom_nav.*,packageName=com.example.airticket",
1712        )
1713        .expect("uia criteria should parse");
1714
1715        assert_eq!(criteria.text_contains.as_deref(), Some("Account"));
1716        assert_eq!(criteria.content_desc_starts_with.as_deref(), Some("Open"));
1717        assert_eq!(
1718            criteria.class_name_matches.as_deref(),
1719            Some("android\\.widget\\..*")
1720        );
1721        assert_eq!(criteria.clickable, Some(true));
1722        assert_eq!(criteria.index, Some(2));
1723        assert_eq!(criteria.instance, Some(1));
1724        assert_eq!(
1725            criteria.resource_id_matches.as_deref(),
1726            Some(".*bottom_nav.*")
1727        );
1728        assert_eq!(
1729            criteria.package_name.as_deref(),
1730            Some("com.example.airticket")
1731        );
1732    }
1733
1734    #[test]
1735    fn matches_uiautomator_selectors_against_dumped_nodes() {
1736        let nodes = parse_android_ui_nodes(sample_ui_hierarchy()).expect("xml should parse");
1737
1738        let account = find_node_by_selector(&nodes, "text=Account").expect("text selector");
1739        assert_eq!(
1740            account.resource_id.as_deref(),
1741            Some("com.example.airticket:id/bottom_nav_account")
1742        );
1743
1744        let by_desc =
1745            find_node_by_selector(&nodes, "descriptionContains=Account").expect("desc selector");
1746        assert_eq!(
1747            by_desc.resource_id.as_deref(),
1748            Some("com.example.airticket:id/bottom_nav_account")
1749        );
1750
1751        let by_resource = find_node_by_selector(
1752            &nodes,
1753            "resourceIdMatches=.*bottom_nav_.*,selected=true",
1754        )
1755        .expect("resource regex selector");
1756        assert_eq!(
1757            by_resource.resource_id.as_deref(),
1758            Some("com.example.airticket:id/bottom_nav_account")
1759        );
1760
1761        let by_class = find_node_by_selector(
1762            &nodes,
1763            "classNameMatches=android\\.widget\\..*,packageNameMatches=com\\.example\\..*,clickable=true,instance=1",
1764        )
1765        .expect("class/package/instance selector");
1766        assert_eq!(
1767            by_class.resource_id.as_deref(),
1768            Some("com.example.airticket:id/bottom_nav_account")
1769        );
1770    }
1771
1772    fn sample_ui_hierarchy() -> &'static str {
1773        r#"<?xml version='1.0' encoding='UTF-8' standalone='yes' ?>
1774<hierarchy rotation="0">
1775  <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]">
1776    <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]" />
1777    <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]" />
1778    <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]" />
1779  </node>
1780</hierarchy>"#
1781    }
1782}