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