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(""", "\"")
914 .replace("'", "'")
915 .replace("<", "<")
916 .replace(">", ">")
917 .replace("&", "&")
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}