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 serde_json::Value;
13use std::env;
14use std::ffi::{CStr, CString, c_char};
15use std::fmt;
16use std::path::{Path, PathBuf};
17use std::process::Command;
18
19#[derive(Debug, Clone, Copy, Default)]
20pub struct MobileAndroidPlugin;
21
22const ANDROID_BACKENDS: &[MobileAutomationBackend] = &[MobileAutomationBackend::UiAutomator2];
23const ANDROID_APP_KINDS: &[MobileAppKind] = &[
24    MobileAppKind::Native,
25    MobileAppKind::Hybrid,
26    MobileAppKind::BrowserWrapped,
27];
28const ANDROID_MISSING_RUNTIME_ARTIFACTS: &[&str] = &[
29    "server-side mobile session routing in allwright-core",
30    "expanded command coverage beyond connect, launch, and click",
31];
32const ANDROID_NEXT_MILESTONES: &[&str] = &[
33    "route Android mobile commands through the core engine session server",
34    "add fill, text, and waitForSelector on top of the same UiAutomator2 bridge",
35    "expand runtime packaging validation across macOS, Linux, and Windows release assets",
36];
37
38#[derive(Debug, Clone, PartialEq, Eq)]
39pub struct AdbDevice {
40    pub serial: String,
41    pub name: Option<String>,
42    pub connection_kind: DeviceConnectionKind,
43    pub state: String,
44}
45
46#[derive(Debug, Clone, PartialEq, Eq)]
47pub enum ConnectSelectionError {
48    NoDevicesAvailable,
49    RequestedDeviceNotFound { requested: String },
50}
51
52impl fmt::Display for ConnectSelectionError {
53    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
54        match self {
55            Self::NoDevicesAvailable => write!(f, "no Android devices were discovered over ADB"),
56            Self::RequestedDeviceNotFound { requested } => {
57                write!(
58                    f,
59                    "requested Android device `{requested}` was not found over ADB"
60                )
61            }
62        }
63    }
64}
65
66#[derive(Debug, Clone, PartialEq, Eq)]
67pub struct UiAutomator2ConnectInfo {
68    pub device_name: Option<String>,
69    pub current_package: Option<String>,
70    pub current_activity: Option<String>,
71}
72
73#[derive(Debug, Clone, PartialEq, Eq)]
74pub struct UiAutomator2ClickInfo {
75    pub resolved_selector: String,
76    pub note: String,
77}
78
79#[derive(Debug, Clone, PartialEq, Eq)]
80pub struct UiAutomator2SourceInfo {
81    pub source: String,
82    pub current_package: Option<String>,
83    pub current_activity: Option<String>,
84}
85
86#[derive(Debug, Clone, PartialEq, Eq)]
87pub struct UiAutomator2FillInfo {
88    pub resolved_selector: String,
89    pub value: String,
90    pub note: String,
91}
92
93impl SurfacePlugin for MobileAndroidPlugin {
94    fn descriptor(&self) -> SurfacePluginDescriptor {
95        descriptor()
96    }
97}
98
99pub fn descriptor() -> SurfacePluginDescriptor {
100    SurfacePluginDescriptor {
101        id: "mobile-android",
102        family: SurfaceFamily::Mobile,
103        version: env!("CARGO_PKG_VERSION"),
104        description: "Android mobile surface plugin for the allwright engine.",
105    }
106}
107
108pub fn profile() -> MobileSurfaceProfile {
109    MobileSurfaceProfile {
110        plugin_id: "mobile-android",
111        display_name: "Android",
112        family: SurfaceFamily::Mobile,
113        backends: ANDROID_BACKENDS,
114        default_backend: MobileAutomationBackend::UiAutomator2,
115        supported_app_kinds: ANDROID_APP_KINDS,
116        capabilities: MobileCapabilitySet {
117            supports_native_views: true,
118            supports_webviews: true,
119            supports_deep_links: true,
120            supports_shell_commands: true,
121            supports_device_logs: true,
122        },
123        bootstrap_hint: "Connect a device or emulator over ADB, then bootstrap a UiAutomator2 session before launching and clicking through the app.",
124    }
125}
126
127pub fn runtime_readiness() -> MobileRuntimeReadiness {
128    MobileRuntimeReadiness {
129        maturity: RuntimeMaturity::Scaffolding,
130        missing_runtime_artifacts: ANDROID_MISSING_RUNTIME_ARTIFACTS,
131        next_milestones: ANDROID_NEXT_MILESTONES,
132    }
133}
134
135pub fn connect_command(options: ConnectOptions) -> MobileCommand {
136    MobileCommand::Connect(options)
137}
138
139pub fn launch_command(
140    browser_session: MobileBrowserSessionHandle,
141    options: LaunchOptions,
142) -> MobileCommand {
143    MobileCommand::LaunchApp {
144        browser_session,
145        options,
146    }
147}
148
149pub fn list_adb_devices() -> Result<Vec<AdbDevice>, String> {
150    let output = run_adb(&["devices", "-l"])?;
151    Ok(parse_adb_devices(&output))
152}
153
154pub fn select_device_for_connect(
155    options: &ConnectOptions,
156    devices: &[AdbDevice],
157) -> Result<AdbDevice, ConnectSelectionError> {
158    let available = devices
159        .iter()
160        .filter(|device| device.state == "device")
161        .cloned()
162        .collect::<Vec<_>>();
163    if available.is_empty() {
164        return Err(ConnectSelectionError::NoDevicesAvailable);
165    }
166
167    if let Some(requested) = options.device.as_deref() {
168        return available
169            .iter()
170            .find(|device| {
171                device.serial == requested
172                    || device
173                        .name
174                        .as_deref()
175                        .is_some_and(|name| name.eq_ignore_ascii_case(requested))
176            })
177            .cloned()
178            .ok_or_else(|| ConnectSelectionError::RequestedDeviceNotFound {
179                requested: requested.to_string(),
180            });
181    }
182
183    available
184        .first()
185        .cloned()
186        .ok_or(ConnectSelectionError::NoDevicesAvailable)
187}
188
189pub fn connect(options: &ConnectOptions) -> Result<MobileConnectInfo, String> {
190    if options.platform != MobilePlatform::Android {
191        return Err("mobile-android connect only supports the Android platform".to_string());
192    }
193
194    if let Some(endpoint) = options.adb_endpoint.as_deref() {
195        let trimmed = endpoint.trim();
196        if !trimmed.is_empty() {
197            let _ = run_adb(&["connect", trimmed])?;
198        }
199    }
200
201    let devices = list_adb_devices()?;
202    let selected =
203        select_device_for_connect(options, &devices).map_err(|error| error.to_string())?;
204    let u2 = uiautomator2_connect(&selected.serial)?;
205
206    Ok(MobileConnectInfo {
207        browser: "android".to_string(),
208        note: format!(
209            "connected Android device `{}` over ADB and bootstrapped UiAutomator2",
210            selected.name.as_deref().unwrap_or(selected.serial.as_str())
211        ),
212        browser_session: MobileBrowserSessionHandle {
213            platform: MobilePlatform::Android,
214            automation: MobileAutomationSessionInfo {
215                backend: "uiautomator2".to_string(),
216                session_id: format!("uiautomator2:{}", selected.serial),
217                note: "session established through the Android surface plugin".to_string(),
218            },
219            device: DeviceTarget {
220                platform: MobilePlatform::Android,
221                device_id: selected.serial.clone(),
222                connection_kind: selected.connection_kind,
223            },
224        },
225        initial_page: MobilePageInfo {
226            note: "attached to the device foreground context".to_string(),
227            page_session: MobilePageSessionHandle {
228                page_id: format!("{}:foreground", selected.serial),
229                package_name: u2.current_package,
230                activity_name: u2.current_activity,
231                webview_context: None,
232            },
233        },
234    })
235}
236
237pub fn launch_app(
238    browser_session: &MobileBrowserSessionHandle,
239    options: &LaunchOptions,
240) -> Result<MobilePageInfo, String> {
241    let device_id = browser_session.device.device_id.as_str();
242
243    if let Some(apk_path) = options.apk_path.as_deref() {
244        let apk = Path::new(apk_path);
245        if !apk.is_file() {
246            return Err(format!("APK path does not exist: {}", apk.display()));
247        }
248        let apk_owned = apk.to_string_lossy().into_owned();
249        let _ = run_adb_for_device(device_id, &["install", "-r", &apk_owned])?;
250    }
251
252    let package_name = match options.app_id.clone() {
253        Some(package_name) => package_name,
254        None => {
255            if let Some(apk_path) = options.apk_path.as_deref() {
256                resolve_package_name_from_apk(apk_path)?
257            } else {
258                return Err(
259                    "launch_app requires `app_id`, or an APK path whose package name can be resolved"
260                        .to_string(),
261                );
262            }
263        }
264    };
265
266    if options.stop_before_launch {
267        let _ = run_adb_for_device(device_id, &["shell", "am", "force-stop", &package_name])?;
268    }
269
270    if let Some(activity) = options.launch_activity.as_deref() {
271        let component = format!("{package_name}/{activity}");
272        let _ = run_adb_for_device(device_id, &["shell", "am", "start", "-W", "-n", &component])?;
273    } else {
274        let _ = run_adb_for_device(
275            device_id,
276            &[
277                "shell",
278                "monkey",
279                "-p",
280                &package_name,
281                "-c",
282                "android.intent.category.LAUNCHER",
283                "1",
284            ],
285        )?;
286    }
287
288    let current = uiautomator2_connect(device_id)?;
289    let activity_name = current
290        .current_activity
291        .or_else(|| options.launch_activity.clone());
292
293    Ok(MobilePageInfo {
294        note: format!(
295            "launched Android app `{package_name}` on {}",
296            browser_session.device.device_id
297        ),
298        page_session: MobilePageSessionHandle {
299            page_id: format!("{}:{package_name}", browser_session.device.device_id),
300            package_name: Some(package_name),
301            activity_name,
302            webview_context: None,
303        },
304    })
305}
306
307pub fn click_element(
308    browser_session: &MobileBrowserSessionHandle,
309    page_session: &MobilePageSessionHandle,
310    selector: &str,
311    timeout_ms: Option<u32>,
312) -> Result<MobileClickInfo, String> {
313    let normalized = normalize_selector_for_transport(selector);
314    if normalized.trim().is_empty() {
315        return Err("click_element requires a non-empty selector".to_string());
316    }
317
318    let clicked = uiautomator2_click(&browser_session.device.device_id, &normalized, timeout_ms)?;
319    Ok(MobileClickInfo {
320        selector: clicked.resolved_selector,
321        note: format!("{}; page={}", clicked.note, page_session.page_id),
322        session_id: browser_session.automation.session_id.clone(),
323    })
324}
325
326pub fn fill_element(
327    browser_session: &MobileBrowserSessionHandle,
328    page_session: &MobilePageSessionHandle,
329    selector: &str,
330    value: &str,
331    timeout_ms: Option<u32>,
332) -> Result<MobileFillInfo, String> {
333    let normalized = normalize_selector_for_transport(selector);
334    if normalized.trim().is_empty() {
335        return Err("fill_element requires a non-empty selector".to_string());
336    }
337
338    let filled = uiautomator2_fill(
339        &browser_session.device.device_id,
340        &normalized,
341        value,
342        timeout_ms,
343    )?;
344    Ok(MobileFillInfo {
345        selector: filled.resolved_selector,
346        value: filled.value,
347        note: format!("{}; page={}", filled.note, page_session.page_id),
348    })
349}
350
351pub fn dump_source(
352    browser_session: &MobileBrowserSessionHandle,
353    _page_session: &MobilePageSessionHandle,
354) -> Result<UiAutomator2SourceInfo, String> {
355    let source = uiautomator2_source(&browser_session.device.device_id)?;
356    Ok(UiAutomator2SourceInfo {
357        source: source.source,
358        current_package: source.current_package,
359        current_activity: source.current_activity,
360    })
361}
362
363pub fn connect_result_example(options: &ConnectOptions) -> Result<MobileCommandResult, String> {
364    Ok(MobileCommandResult::Connect(connect(options)?))
365}
366
367pub async fn boot() -> String {
368    boot_surface("android", 10).await
369}
370
371#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
372struct MobilePluginEnvelope {
373    ok: bool,
374    result: Option<MobileCommandResult>,
375    error: Option<String>,
376}
377
378fn plugin_response(result: Result<MobileCommandResult, String>) -> *mut c_char {
379    let payload = match result {
380        Ok(result) => MobilePluginEnvelope {
381            ok: true,
382            result: Some(result),
383            error: None,
384        },
385        Err(error) => MobilePluginEnvelope {
386            ok: false,
387            result: None,
388            error: Some(error),
389        },
390    };
391
392    match serde_json::to_string(&payload) {
393        Ok(json) => CString::new(json)
394            .map(CString::into_raw)
395            .unwrap_or_else(|_| CString::new("{\"ok\":false,\"result\":null,\"error\":\"failed to encode plugin response\"}").expect("static JSON").into_raw()),
396        Err(error) => CString::new(format!(
397            "{{\"ok\":false,\"result\":null,\"error\":\"failed to serialize plugin response: {error}\"}}"
398        ))
399        .map(CString::into_raw)
400        .unwrap_or_else(|_| CString::new("{\"ok\":false,\"result\":null,\"error\":\"failed to serialize plugin response\"}").expect("static JSON").into_raw()),
401    }
402}
403
404fn handle_plugin_command(command: MobileCommand) -> Result<MobileCommandResult, String> {
405    match command {
406        MobileCommand::Connect(options) => connect(&options).map(MobileCommandResult::Connect),
407        MobileCommand::LaunchApp {
408            browser_session,
409            options,
410        } => launch_app(&browser_session, &options).map(MobileCommandResult::LaunchApp),
411        MobileCommand::OpenPage { browser_session } => {
412            let info = MobilePageInfo {
413                note: "attached to the Android foreground app context".to_string(),
414                page_session: MobilePageSessionHandle {
415                    page_id: format!("{}:foreground", browser_session.device.device_id),
416                    package_name: None,
417                    activity_name: None,
418                    webview_context: None,
419                },
420            };
421            Ok(MobileCommandResult::OpenPage(info))
422        }
423        MobileCommand::ClosePage { .. } => Ok(MobileCommandResult::ClosePage),
424        MobileCommand::ClickElement {
425            browser_session,
426            page_session,
427            selector,
428            timeout_ms,
429        } => click_element(&browser_session, &page_session, &selector, timeout_ms)
430            .map(MobileCommandResult::ClickElement),
431        MobileCommand::CountElements { .. } => {
432            Err("mobile-android plugin does not implement `count_elements` yet".to_string())
433        }
434        MobileCommand::FillElement {
435            browser_session,
436            page_session,
437            selector,
438            value,
439            timeout_ms,
440        } => fill_element(
441            &browser_session,
442            &page_session,
443            &selector,
444            &value,
445            timeout_ms,
446        )
447        .map(MobileCommandResult::FillElement),
448        MobileCommand::GetText { .. } => {
449            Err("mobile-android plugin does not implement `get_text` yet".to_string())
450        }
451        MobileCommand::WaitForSelector { .. } => {
452            Err("mobile-android plugin does not implement `wait_for_selector` yet".to_string())
453        }
454    }
455}
456
457#[unsafe(no_mangle)]
458pub extern "C" fn allwright_plugin_api_version() -> u32 {
459    ALLWRIGHT_PLUGIN_API_VERSION
460}
461
462#[unsafe(no_mangle)]
463pub extern "C" fn allwright_plugin_id() -> *const c_char {
464    c"mobile-android".as_ptr()
465}
466
467#[unsafe(no_mangle)]
468pub unsafe extern "C" fn allwright_plugin_invoke(request_json: *const c_char) -> *mut c_char {
469    if request_json.is_null() {
470        return plugin_response(Err("plugin request pointer is null".to_string()));
471    }
472
473    let request = match unsafe { CStr::from_ptr(request_json) }.to_str() {
474        Ok(request) => request,
475        Err(error) => {
476            return plugin_response(Err(format!("plugin request is not valid UTF-8: {error}")));
477        }
478    };
479
480    let command: MobileCommand = match serde_json::from_str(request) {
481        Ok(command) => command,
482        Err(error) => {
483            return plugin_response(Err(format!(
484                "failed to parse mobile plugin request JSON: {error}"
485            )));
486        }
487    };
488
489    plugin_response(handle_plugin_command(command))
490}
491
492#[unsafe(no_mangle)]
493pub unsafe extern "C" fn allwright_plugin_free_string(value: *mut c_char) {
494    if !value.is_null() {
495        unsafe {
496            let _ = CString::from_raw(value);
497        }
498    }
499}
500
501fn parse_adb_devices(output: &str) -> Vec<AdbDevice> {
502    output
503        .lines()
504        .map(str::trim)
505        .filter(|line| !line.is_empty() && !line.starts_with("List of devices attached"))
506        .filter_map(|line| {
507            let mut parts = line.split_whitespace();
508            let serial = parts.next()?.to_string();
509            let state = parts.next()?.to_string();
510            let metadata = parts.collect::<Vec<_>>();
511            let mut name = None;
512            for item in metadata {
513                if let Some(model) = item.strip_prefix("model:") {
514                    name = Some(model.replace('_', " "));
515                    break;
516                }
517                if let Some(device_name) = item.strip_prefix("device:") {
518                    name = Some(device_name.replace('_', " "));
519                }
520            }
521
522            Some(AdbDevice {
523                connection_kind: classify_device_connection_kind(&serial),
524                serial,
525                name,
526                state,
527            })
528        })
529        .collect()
530}
531
532fn classify_device_connection_kind(serial: &str) -> DeviceConnectionKind {
533    if serial.starts_with("emulator-") {
534        return DeviceConnectionKind::Emulator;
535    }
536    if serial.contains(':') {
537        return DeviceConnectionKind::RemoteAdb;
538    }
539    DeviceConnectionKind::Usb
540}
541
542fn run_adb(args: &[&str]) -> Result<String, String> {
543    run_command(&adb_command_path(), args)
544}
545
546fn run_adb_for_device(device_id: &str, args: &[&str]) -> Result<String, String> {
547    let mut all_args = vec!["-s", device_id];
548    all_args.extend_from_slice(args);
549    run_command(&adb_command_path(), &all_args)
550}
551
552fn adb_command_path() -> String {
553    env::var("ALLWRIGHT_ANDROID_ADB").unwrap_or_else(|_| "adb".to_string())
554}
555
556fn run_command(command: &str, args: &[&str]) -> Result<String, String> {
557    let output = Command::new(command)
558        .args(args)
559        .output()
560        .map_err(|error| format!("failed to run `{command}`: {error}"))?;
561    if output.status.success() {
562        return Ok(String::from_utf8_lossy(&output.stdout).trim().to_string());
563    }
564
565    let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
566    let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
567    let detail = if !stderr.is_empty() { stderr } else { stdout };
568    Err(format!(
569        "`{command} {}` failed with status {}: {}",
570        args.join(" "),
571        output
572            .status
573            .code()
574            .map_or_else(|| "signal".to_string(), |code| code.to_string()),
575        detail
576    ))
577}
578
579fn resolve_package_name_from_apk(apk_path: &str) -> Result<String, String> {
580    if let Ok(output) = run_command("aapt", &["dump", "badging", apk_path]) {
581        if let Some(package_name) = parse_package_name_from_aapt_badging(&output) {
582            return Ok(package_name);
583        }
584    }
585
586    if let Ok(output) = run_command("apkanalyzer", &["manifest", "application-id", apk_path]) {
587        let package_name = output.trim();
588        if !package_name.is_empty() {
589            return Ok(package_name.to_string());
590        }
591    }
592
593    Err(
594        "could not resolve the app package name from the APK. Provide `--app-id`, or install `aapt`/`apkanalyzer`."
595            .to_string(),
596    )
597}
598
599fn parse_package_name_from_aapt_badging(output: &str) -> Option<String> {
600    output.lines().find_map(|line| {
601        let package_line = line.trim();
602        if !package_line.starts_with("package: ") {
603            return None;
604        }
605        let needle = "name='";
606        let start = package_line.find(needle)? + needle.len();
607        let remainder = &package_line[start..];
608        let end = remainder.find('\'')?;
609        Some(remainder[..end].to_string())
610    })
611}
612
613fn uiautomator2_connect(device_id: &str) -> Result<UiAutomator2ConnectInfo, String> {
614    let value = run_uiautomator2_bridge("connect", &[device_id])?;
615    Ok(UiAutomator2ConnectInfo {
616        device_name: string_field(&value, "deviceName"),
617        current_package: string_field(&value, "currentPackage"),
618        current_activity: string_field(&value, "currentActivity"),
619    })
620}
621
622fn uiautomator2_click(
623    device_id: &str,
624    selector: &str,
625    timeout_ms: Option<u32>,
626) -> Result<UiAutomator2ClickInfo, String> {
627    let timeout = timeout_ms
628        .map(|value| format!("{:.3}", f64::from(value) / 1_000.0))
629        .unwrap_or_else(|| "10.0".to_string());
630    let value = run_uiautomator2_bridge("click", &[device_id, selector, "--timeout", &timeout])?;
631    Ok(UiAutomator2ClickInfo {
632        resolved_selector: string_field(&value, "resolvedSelector")
633            .unwrap_or_else(|| selector.to_string()),
634        note: string_field(&value, "note")
635            .unwrap_or_else(|| "clicked Android element through UiAutomator2".to_string()),
636    })
637}
638
639fn uiautomator2_fill(
640    device_id: &str,
641    selector: &str,
642    fill_value: &str,
643    timeout_ms: Option<u32>,
644) -> Result<UiAutomator2FillInfo, String> {
645    let timeout = timeout_ms
646        .map(|value| format!("{:.3}", f64::from(value) / 1_000.0))
647        .unwrap_or_else(|| "10.0".to_string());
648    let value = run_uiautomator2_bridge(
649        "fill",
650        &[device_id, selector, fill_value, "--timeout", &timeout],
651    )?;
652    Ok(UiAutomator2FillInfo {
653        resolved_selector: string_field(&value, "resolvedSelector")
654            .unwrap_or_else(|| selector.to_string()),
655        value: string_field(&value, "value").unwrap_or_else(|| fill_value.to_string()),
656        note: string_field(&value, "note")
657            .unwrap_or_else(|| "filled Android element through UiAutomator2".to_string()),
658    })
659}
660
661fn uiautomator2_source(device_id: &str) -> Result<UiAutomator2SourceInfo, String> {
662    let value = run_uiautomator2_bridge("source", &[device_id])?;
663    Ok(UiAutomator2SourceInfo {
664        source: string_field(&value, "source").unwrap_or_default(),
665        current_package: string_field(&value, "currentPackage"),
666        current_activity: string_field(&value, "currentActivity"),
667    })
668}
669
670fn run_uiautomator2_bridge(command: &str, args: &[&str]) -> Result<Value, String> {
671    let script = uiautomator2_bridge_script_path();
672    if !script.is_file() {
673        return Err(format!(
674            "missing UiAutomator2 bridge script at {}",
675            script.display()
676        ));
677    }
678
679    let python = env::var("ALLWRIGHT_ANDROID_PYTHON").unwrap_or_else(|_| "python3".to_string());
680    let mut command_line = Command::new(&python);
681    command_line.arg(&script).arg(command).args(args);
682    let output = command_line
683        .output()
684        .map_err(|error| format!("failed to run `{python}`: {error}"))?;
685
686    if !output.status.success() {
687        let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
688        let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
689        let detail = if !stderr.is_empty() { stderr } else { stdout };
690        return Err(format!("UiAutomator2 bridge `{command}` failed: {detail}"));
691    }
692
693    serde_json::from_slice::<Value>(&output.stdout)
694        .map_err(|error| format!("failed to decode UiAutomator2 bridge response: {error}"))
695}
696
697fn uiautomator2_bridge_script_path() -> PathBuf {
698    PathBuf::from(env!("CARGO_MANIFEST_DIR"))
699        .join("..")
700        .join("..")
701        .join("scripts")
702        .join("mobile_android_uiautomator2_bridge.py")
703}
704
705fn string_field(value: &Value, key: &str) -> Option<String> {
706    value.get(key).and_then(Value::as_str).map(str::to_string)
707}
708
709#[cfg(test)]
710mod tests {
711    use super::*;
712
713    #[tokio::test]
714    async fn boots_android_runtime() {
715        assert_eq!(boot().await, "android ready");
716    }
717
718    #[test]
719    fn defaults_to_uiautomator2() {
720        let android = profile();
721        assert_eq!(
722            android.default_backend,
723            MobileAutomationBackend::UiAutomator2
724        );
725        assert_eq!(android.backends, ANDROID_BACKENDS);
726        assert!(android.capabilities.supports_native_views);
727        assert!(android.capabilities.supports_webviews);
728    }
729
730    #[test]
731    fn exposes_android_runtime_milestones() {
732        let readiness = runtime_readiness();
733        assert_eq!(readiness.maturity, RuntimeMaturity::Scaffolding);
734        assert!(
735            readiness
736                .missing_runtime_artifacts
737                .iter()
738                .any(|item| item.contains("server-side mobile session routing"))
739        );
740    }
741
742    #[test]
743    fn connect_flow_matches_web_shape() {
744        let options = ConnectOptions {
745            platform: MobilePlatform::Android,
746            device: Some("emulator-5554".to_string()),
747            adb_endpoint: None,
748            preserve_app_state: true,
749            timeout_ms: Some(5_000),
750        };
751
752        let command = connect_command(options.clone());
753
754        match command {
755            MobileCommand::Connect(connect) => {
756                assert_eq!(connect.platform, MobilePlatform::Android);
757                assert_eq!(connect.device.as_deref(), Some("emulator-5554"));
758            }
759            _ => panic!("expected mobile connect command"),
760        }
761    }
762
763    #[test]
764    fn launch_flow_keeps_apk_work_separate() {
765        let browser_session = MobileBrowserSessionHandle {
766            platform: MobilePlatform::Android,
767            automation: MobileAutomationSessionInfo {
768                backend: "uiautomator2".to_string(),
769                session_id: "uiautomator2:emulator-5554".to_string(),
770                note: "ready".to_string(),
771            },
772            device: DeviceTarget {
773                platform: MobilePlatform::Android,
774                device_id: "emulator-5554".to_string(),
775                connection_kind: DeviceConnectionKind::Emulator,
776            },
777        };
778
779        let command = launch_command(
780            browser_session,
781            LaunchOptions {
782                apk_path: Some("/tmp/app.apk".to_string()),
783                app_id: Some("dev.allwright.sample".to_string()),
784                launch_activity: Some(".MainActivity".to_string()),
785                stop_before_launch: true,
786                timeout_ms: Some(15_000),
787            },
788        );
789
790        match command {
791            MobileCommand::LaunchApp { options, .. } => {
792                assert_eq!(options.apk_path.as_deref(), Some("/tmp/app.apk"));
793                assert_eq!(options.app_id.as_deref(), Some("dev.allwright.sample"));
794                assert!(options.stop_before_launch);
795            }
796            _ => panic!("expected launch command"),
797        }
798    }
799
800    #[test]
801    fn parse_adb_devices_extracts_friendly_names() {
802        let devices = parse_adb_devices(
803            "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",
804        );
805
806        assert_eq!(devices.len(), 2);
807        assert_eq!(devices[0].name.as_deref(), Some("Pixel 8 API 35"));
808        assert_eq!(devices[1].name.as_deref(), Some("QA Galaxy S24"));
809        assert_eq!(devices[0].connection_kind, DeviceConnectionKind::Emulator);
810        assert_eq!(devices[1].connection_kind, DeviceConnectionKind::Usb);
811    }
812
813    #[test]
814    fn connect_uses_requested_device_when_provided() {
815        let options = ConnectOptions {
816            platform: MobilePlatform::Android,
817            device: Some("R5CX123".to_string()),
818            adb_endpoint: None,
819            preserve_app_state: false,
820            timeout_ms: None,
821        };
822        let devices = vec![
823            AdbDevice {
824                serial: "emulator-5554".to_string(),
825                name: Some("Pixel 8 API 35".to_string()),
826                connection_kind: DeviceConnectionKind::Emulator,
827                state: "device".to_string(),
828            },
829            AdbDevice {
830                serial: "R5CX123".to_string(),
831                name: Some("QA Galaxy S24".to_string()),
832                connection_kind: DeviceConnectionKind::Usb,
833                state: "device".to_string(),
834            },
835        ];
836
837        let selected = select_device_for_connect(&options, &devices).expect("device should match");
838        assert_eq!(selected.serial, "R5CX123");
839        assert_eq!(selected.connection_kind, DeviceConnectionKind::Usb);
840    }
841
842    #[test]
843    fn connect_defaults_to_first_available_device() {
844        let options = ConnectOptions {
845            platform: MobilePlatform::Android,
846            device: None,
847            adb_endpoint: None,
848            preserve_app_state: false,
849            timeout_ms: None,
850        };
851        let devices = vec![
852            AdbDevice {
853                serial: "emulator-5554".to_string(),
854                name: Some("Pixel 8 API 35".to_string()),
855                connection_kind: DeviceConnectionKind::Emulator,
856                state: "device".to_string(),
857            },
858            AdbDevice {
859                serial: "R5CX123".to_string(),
860                name: Some("QA Galaxy S24".to_string()),
861                connection_kind: DeviceConnectionKind::Usb,
862                state: "device".to_string(),
863            },
864        ];
865
866        let selected = select_device_for_connect(&options, &devices).expect("first device");
867        assert_eq!(selected.serial, "emulator-5554");
868    }
869
870    #[test]
871    fn connect_matches_friendly_device_name() {
872        let options = ConnectOptions {
873            platform: MobilePlatform::Android,
874            device: Some("qa galaxy s24".to_string()),
875            adb_endpoint: None,
876            preserve_app_state: false,
877            timeout_ms: None,
878        };
879        let devices = vec![
880            AdbDevice {
881                serial: "emulator-5554".to_string(),
882                name: Some("Pixel 8 API 35".to_string()),
883                connection_kind: DeviceConnectionKind::Emulator,
884                state: "device".to_string(),
885            },
886            AdbDevice {
887                serial: "R5CX123".to_string(),
888                name: Some("QA Galaxy S24".to_string()),
889                connection_kind: DeviceConnectionKind::Usb,
890                state: "device".to_string(),
891            },
892        ];
893
894        let selected = select_device_for_connect(&options, &devices).expect("friendly name match");
895        assert_eq!(selected.serial, "R5CX123");
896    }
897
898    #[test]
899    fn connect_fails_when_requested_device_is_missing() {
900        let options = ConnectOptions {
901            platform: MobilePlatform::Android,
902            device: Some("missing-device".to_string()),
903            adb_endpoint: None,
904            preserve_app_state: false,
905            timeout_ms: None,
906        };
907        let devices = vec![AdbDevice {
908            serial: "emulator-5554".to_string(),
909            name: Some("Pixel 8 API 35".to_string()),
910            connection_kind: DeviceConnectionKind::Emulator,
911            state: "device".to_string(),
912        }];
913
914        let error = select_device_for_connect(&options, &devices).expect_err("missing device");
915        assert_eq!(
916            error,
917            ConnectSelectionError::RequestedDeviceNotFound {
918                requested: "missing-device".to_string()
919            }
920        );
921    }
922
923    #[test]
924    fn parse_package_name_from_aapt_output_extracts_package() {
925        let package_name = parse_package_name_from_aapt_badging(
926            "package: name='dev.allwright.sample' versionCode='1' versionName='1.0'\n",
927        )
928        .expect("package name should parse");
929        assert_eq!(package_name, "dev.allwright.sample");
930    }
931
932    #[test]
933    fn dump_source_keeps_page_context_separate() {
934        let page_session = MobilePageSessionHandle {
935            page_id: "emulator-5554:dev.allwright.sample".to_string(),
936            package_name: Some("dev.allwright.sample".to_string()),
937            activity_name: Some(".MainActivity".to_string()),
938            webview_context: None,
939        };
940        assert_eq!(page_session.page_id, "emulator-5554:dev.allwright.sample");
941    }
942}