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}