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