1use allwright_plugin_sdk::SurfaceFamily;
2use allwright_plugin_sdk::SurfacePluginDescriptor;
3use serde::{Deserialize, Serialize};
4use tokio::time::{Duration, sleep};
5
6pub const SURFACE_ID: &str = "mobile";
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9pub enum MobileAutomationBackend {
10 UiAutomator2,
11 Espresso,
12 WebViewBridge,
13}
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum MobileAppKind {
17 Native,
18 Hybrid,
19 BrowserWrapped,
20}
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum RuntimeMaturity {
24 Planned,
25 Scaffolding,
26 RuntimeReady,
27 Installable,
28}
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub struct MobileCapabilitySet {
32 pub supports_native_views: bool,
33 pub supports_webviews: bool,
34 pub supports_deep_links: bool,
35 pub supports_shell_commands: bool,
36 pub supports_device_logs: bool,
37}
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub struct MobileSurfaceProfile {
41 pub plugin_id: &'static str,
42 pub display_name: &'static str,
43 pub family: SurfaceFamily,
44 pub backends: &'static [MobileAutomationBackend],
45 pub default_backend: MobileAutomationBackend,
46 pub supported_app_kinds: &'static [MobileAppKind],
47 pub capabilities: MobileCapabilitySet,
48 pub bootstrap_hint: &'static str,
49}
50
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52pub struct MobileRuntimeReadiness {
53 pub maturity: RuntimeMaturity,
54 pub missing_runtime_artifacts: &'static [&'static str],
55 pub next_milestones: &'static [&'static str],
56}
57
58#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
59#[serde(rename_all = "snake_case")]
60pub enum MobilePlatform {
61 Android,
62 Ios,
63}
64
65#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
66#[serde(rename_all = "snake_case")]
67pub enum DeviceConnectionKind {
68 Usb,
69 Emulator,
70 RemoteAdb,
71}
72
73#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
74pub struct DeviceTarget {
75 pub platform: MobilePlatform,
76 pub device_id: String,
77 pub connection_kind: DeviceConnectionKind,
78}
79
80#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
81pub struct ConnectOptions {
82 pub platform: MobilePlatform,
83 pub device: Option<String>,
84 pub adb_endpoint: Option<String>,
85 pub preserve_app_state: bool,
86 pub timeout_ms: Option<u32>,
87}
88
89#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
90pub struct LaunchOptions {
91 pub apk_path: Option<String>,
92 pub app_id: Option<String>,
93 pub launch_activity: Option<String>,
94 pub stop_before_launch: bool,
95 pub timeout_ms: Option<u32>,
96}
97
98#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
99pub struct MobileAutomationSessionInfo {
100 pub backend: String,
101 pub session_id: String,
102 pub note: String,
103}
104
105#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
106pub struct MobileBrowserSessionHandle {
107 pub platform: MobilePlatform,
108 pub automation: MobileAutomationSessionInfo,
109 pub device: DeviceTarget,
110}
111
112#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
113pub struct MobilePageSessionHandle {
114 pub page_id: String,
115 pub package_name: Option<String>,
116 pub activity_name: Option<String>,
117 pub webview_context: Option<String>,
118}
119
120#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
121pub struct MobilePageInfo {
122 pub note: String,
123 pub page_session: MobilePageSessionHandle,
124}
125
126#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
127pub struct MobileConnectInfo {
128 pub browser: String,
129 pub note: String,
130 pub browser_session: MobileBrowserSessionHandle,
131 pub initial_page: MobilePageInfo,
132}
133
134#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
135#[serde(rename_all = "snake_case")]
136pub enum SelectorFlavor {
137 Css,
138 XPath,
139 UiAutomator,
140}
141
142impl SelectorFlavor {
143 fn as_str(self) -> &'static str {
144 match self {
145 Self::Css => "css",
146 Self::XPath => "xpath",
147 Self::UiAutomator => "uia",
148 }
149 }
150}
151
152const UIAUTOMATOR_SELECTOR_KEYS: &[&str] = &[
153 "text",
154 "textcontains",
155 "textmatches",
156 "textstartswith",
157 "classname",
158 "classnamematches",
159 "description",
160 "desc",
161 "descriptioncontains",
162 "desccontains",
163 "descriptionmatches",
164 "descmatches",
165 "descriptionstartswith",
166 "descstartswith",
167 "checkable",
168 "checked",
169 "clickable",
170 "longclickable",
171 "scrollable",
172 "enabled",
173 "focusable",
174 "focused",
175 "selected",
176 "packagename",
177 "package",
178 "packagenamematches",
179 "resourceid",
180 "resourceidmatches",
181 "index",
182 "instance",
183];
184
185#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
186pub struct MobileLocator {
187 pub selector: String,
188}
189
190impl MobileLocator {
191 pub fn normalize(selector: &str) -> Self {
192 Self {
193 selector: normalize_selector_for_transport(selector),
194 }
195 }
196
197 pub fn chain(&self, child_selector: &str) -> Self {
198 Self {
199 selector: chain_selector_for_transport(&self.selector, child_selector),
200 }
201 }
202}
203
204#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
205pub struct MobileClickInfo {
206 pub selector: String,
207 pub note: String,
208 pub session_id: String,
209}
210
211#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
212pub struct MobileElementCountInfo {
213 pub selector: String,
214 pub count: u32,
215 pub note: String,
216}
217
218#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
219pub struct MobileFillInfo {
220 pub selector: String,
221 pub value: String,
222 pub note: String,
223}
224
225#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
226pub struct MobileElementInfo {
227 pub selector: String,
228 pub note: String,
229}
230
231#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
232pub struct MobilePressInfo {
233 pub selector: String,
234 pub key: String,
235 pub note: String,
236}
237
238#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
239pub struct MobileTextInfo {
240 pub selector: String,
241 pub text: String,
242 pub note: String,
243}
244
245#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
246pub struct MobileWaitForSelectorInfo {
247 pub selector: String,
248 pub visible: bool,
249 pub note: String,
250}
251
252#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
253pub struct MobileScreenshotInfo {
254 pub png_data: Vec<u8>,
255 pub note: String,
256}
257
258#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
259#[serde(tag = "command", rename_all = "snake_case")]
260pub enum MobileCommand {
261 AccessibilitySnapshot {
262 browser_session: MobileBrowserSessionHandle,
263 page_session: MobilePageSessionHandle,
264 format: String,
265 mode: String,
266 },
267 Connect(ConnectOptions),
268 LaunchApp {
269 browser_session: MobileBrowserSessionHandle,
270 options: LaunchOptions,
271 },
272 OpenPage {
273 browser_session: MobileBrowserSessionHandle,
274 },
275 ClosePage {
276 browser_session: MobileBrowserSessionHandle,
277 page_session: MobilePageSessionHandle,
278 },
279 ClickElement {
280 browser_session: MobileBrowserSessionHandle,
281 page_session: MobilePageSessionHandle,
282 selector: String,
283 timeout_ms: Option<u32>,
284 },
285 CountElements {
286 browser_session: MobileBrowserSessionHandle,
287 page_session: MobilePageSessionHandle,
288 selector: String,
289 timeout_ms: Option<u32>,
290 },
291 FocusElement {
292 browser_session: MobileBrowserSessionHandle,
293 page_session: MobilePageSessionHandle,
294 selector: String,
295 timeout_ms: Option<u32>,
296 },
297 FillElement {
298 browser_session: MobileBrowserSessionHandle,
299 page_session: MobilePageSessionHandle,
300 selector: String,
301 value: String,
302 timeout_ms: Option<u32>,
303 },
304 PressKey {
305 browser_session: MobileBrowserSessionHandle,
306 page_session: MobilePageSessionHandle,
307 selector: String,
308 key: String,
309 text: Option<String>,
310 timeout_ms: Option<u32>,
311 },
312 GetText {
313 browser_session: MobileBrowserSessionHandle,
314 page_session: MobilePageSessionHandle,
315 selector: String,
316 timeout_ms: Option<u32>,
317 },
318 GetInnerText {
319 browser_session: MobileBrowserSessionHandle,
320 page_session: MobilePageSessionHandle,
321 selector: String,
322 timeout_ms: Option<u32>,
323 },
324 WaitForSelector {
325 browser_session: MobileBrowserSessionHandle,
326 page_session: MobilePageSessionHandle,
327 selector: String,
328 visible: bool,
329 timeout_ms: Option<u32>,
330 },
331 Screenshot {
332 browser_session: MobileBrowserSessionHandle,
333 page_session: MobilePageSessionHandle,
334 timeout_ms: Option<u32>,
335 full_page: bool,
336 },
337}
338
339#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
340#[serde(tag = "result", rename_all = "snake_case")]
341pub enum MobileCommandResult {
342 AccessibilitySnapshot(allwright_plugin_sdk::AccessibilitySnapshotInfo),
343 Connect(MobileConnectInfo),
344 LaunchApp(MobilePageInfo),
345 OpenPage(MobilePageInfo),
346 ClosePage,
347 ClickElement(MobileClickInfo),
348 CountElements(MobileElementCountInfo),
349 FocusElement(MobileElementInfo),
350 FillElement(MobileFillInfo),
351 PressKey(MobilePressInfo),
352 GetText(MobileTextInfo),
353 GetInnerText(MobileTextInfo),
354 WaitForSelector(MobileWaitForSelectorInfo),
355 Screenshot(MobileScreenshotInfo),
356}
357
358pub fn shared_descriptor() -> SurfacePluginDescriptor {
359 SurfacePluginDescriptor {
360 id: SURFACE_ID,
361 family: SurfaceFamily::Mobile,
362 version: env!("CARGO_PKG_VERSION"),
363 description: "Shared mobile surface abstractions for Android and iOS plugins.",
364 }
365}
366
367pub async fn boot_surface(label: &str, delay_ms: u64) -> String {
368 sleep(Duration::from_millis(delay_ms)).await;
369 format!("{label} ready")
370}
371
372pub async fn boot() -> String {
373 boot_surface("mobile", 25).await
374}
375
376fn parse_explicit_selector_prefix(selector: &str) -> Option<(SelectorFlavor, usize)> {
377 let lowered = selector.to_ascii_lowercase();
378 if lowered.starts_with("xpath=") || lowered.starts_with("xpath:") {
379 return Some((SelectorFlavor::XPath, 6));
380 }
381 if lowered.starts_with("uia=") || lowered.starts_with("uia:") {
382 return Some((SelectorFlavor::UiAutomator, 4));
383 }
384 if let Some(prefix_len) = uiautomator_selector_prefix_len(&lowered) {
385 return Some((SelectorFlavor::UiAutomator, prefix_len));
386 }
387 if lowered.starts_with("text=") || lowered.starts_with("text:") {
388 return Some((SelectorFlavor::UiAutomator, 5));
389 }
390 if lowered.starts_with("id=") || lowered.starts_with("id:") {
391 return Some((SelectorFlavor::Css, 3));
392 }
393 if lowered.starts_with("css=") || lowered.starts_with("css:") {
394 return Some((SelectorFlavor::Css, 4));
395 }
396 None
397}
398
399fn uiautomator_selector_prefix_len(lowered: &str) -> Option<usize> {
400 UIAUTOMATOR_SELECTOR_KEYS.iter().find_map(|key| {
401 if lowered.starts_with(key) {
402 let separator = lowered.as_bytes().get(key.len()).copied()?;
403 if separator == b'=' || separator == b':' {
404 return Some(key.len() + 1);
405 }
406 }
407 None
408 })
409}
410
411fn find_json_string_end(value: &str) -> Option<usize> {
412 let bytes = value.as_bytes();
413 if bytes.first().copied()? != b'"' {
414 return None;
415 }
416
417 let mut index = 1usize;
418 let mut escaped = false;
419 while index < bytes.len() {
420 let byte = bytes[index];
421 if escaped {
422 escaped = false;
423 index += 1;
424 continue;
425 }
426 match byte {
427 b'\\' => escaped = true,
428 b'"' => return Some(index + 1),
429 _ => {}
430 }
431 index += 1;
432 }
433 None
434}
435
436fn is_normalized_transport_selector(selector: &str) -> bool {
437 let trimmed = selector.trim();
438 if trimmed.is_empty() {
439 return false;
440 }
441
442 let mut index = 0usize;
443 while index < trimmed.len() {
444 let Some((_, prefix_len)) = parse_explicit_selector_prefix(&trimmed[index..]) else {
445 return false;
446 };
447
448 index += prefix_len;
449 let remainder = &trimmed[index..];
450 if !remainder.starts_with('"') {
451 return false;
452 }
453
454 let Some(json_end) = find_json_string_end(remainder) else {
455 return false;
456 };
457 index += json_end;
458
459 if index == trimmed.len() {
460 return true;
461 }
462
463 let whitespace_len = trimmed[index..]
464 .chars()
465 .take_while(|char| char.is_ascii_whitespace())
466 .count();
467 if whitespace_len == 0 {
468 return false;
469 }
470 index += whitespace_len;
471
472 if parse_explicit_selector_prefix(&trimmed[index..]).is_none() {
473 return false;
474 }
475 }
476
477 true
478}
479
480fn decode_selector_body(body: &str) -> String {
481 let candidate = body.trim();
482 if candidate.len() >= 2 && candidate.starts_with('"') && candidate.ends_with('"') {
483 if let Ok(decoded) = serde_json::from_str::<String>(candidate) {
484 return unescape_shell_escaped_selector(&decoded);
485 }
486 }
487 unescape_shell_escaped_selector(candidate)
488}
489
490fn unescape_shell_escaped_selector(value: &str) -> String {
491 let mut result = String::with_capacity(value.len());
492 let mut chars = value.chars().peekable();
493 while let Some(ch) = chars.next() {
494 if ch == '\\' {
495 match chars.peek().copied() {
496 Some('_' | ' ' | '#' | ':' | '[' | ']' | '(' | ')' | '"' | '\'') => {
497 result.push(chars.next().expect("peeked char should exist"));
498 continue;
499 }
500 _ => {}
501 }
502 }
503 result.push(ch);
504 }
505 result
506}
507
508pub fn parse_selector_for_transport(selector: &str) -> (SelectorFlavor, String) {
509 let trimmed = selector.trim();
510 let lowered = trimmed.to_ascii_lowercase();
511 if lowered.starts_with("xpath=") || lowered.starts_with("xpath:") {
512 return (SelectorFlavor::XPath, decode_selector_body(&trimmed[6..]));
513 }
514 if lowered.starts_with("uia=") || lowered.starts_with("uia:") {
515 return (
516 SelectorFlavor::UiAutomator,
517 decode_selector_body(&trimmed[4..]),
518 );
519 }
520 if let Some(prefix_len) = uiautomator_selector_prefix_len(&lowered) {
521 return (
522 SelectorFlavor::UiAutomator,
523 trimmed[..prefix_len - 1].to_string()
524 + "="
525 + &decode_selector_body(&trimmed[prefix_len..]),
526 );
527 }
528 if lowered.starts_with("text=") || lowered.starts_with("text:") {
529 let body = decode_selector_body(&trimmed[5..]);
530 return (SelectorFlavor::UiAutomator, format!("text={body}"));
531 }
532 if lowered.starts_with("id=") || lowered.starts_with("id:") {
533 let body = decode_selector_body(&trimmed[3..]);
534 let normalized = if body.starts_with('#') {
535 body
536 } else {
537 format!("#{body}")
538 };
539 return (SelectorFlavor::Css, normalized);
540 }
541 if lowered.starts_with("css=") || lowered.starts_with("css:") {
542 return (SelectorFlavor::Css, decode_selector_body(&trimmed[4..]));
543 }
544 if trimmed.starts_with("//")
545 || trimmed.starts_with(".//")
546 || trimmed.starts_with("../")
547 || trimmed.starts_with('/')
548 || trimmed.starts_with('(')
549 {
550 return (SelectorFlavor::XPath, trimmed.to_string());
551 }
552 (SelectorFlavor::Css, trimmed.to_string())
553}
554
555pub fn normalize_selector_for_transport(selector: &str) -> String {
556 let trimmed = selector.trim();
557 if trimmed.is_empty() {
558 return String::new();
559 }
560 if is_normalized_transport_selector(trimmed) {
561 return trimmed.to_string();
562 }
563 let (flavor, body) = parse_selector_for_transport(selector);
564 format!(
565 "{}={}",
566 flavor.as_str(),
567 serde_json::to_string(&body).unwrap_or_else(|_| format!("{body:?}"))
568 )
569}
570
571pub fn chain_selector_for_transport(parent: &str, child: &str) -> String {
572 let parent_selector = if parent.trim().is_empty() {
573 String::new()
574 } else {
575 normalize_selector_for_transport(parent)
576 };
577 let child_selector = if child.trim().is_empty() {
578 String::new()
579 } else {
580 normalize_selector_for_transport(child)
581 };
582 if parent_selector.is_empty() {
583 return child_selector;
584 }
585 if child_selector.is_empty() {
586 return parent_selector;
587 }
588 format!("{parent_selector} {child_selector}")
589}
590
591#[cfg(test)]
592mod tests {
593 use super::*;
594
595 #[tokio::test]
596 async fn boots_mobile_runtime() {
597 assert_eq!(boot().await, "mobile ready");
598 }
599
600 #[tokio::test]
601 async fn boots_named_mobile_surface() {
602 assert_eq!(boot_surface("android", 1).await, "android ready");
603 }
604
605 #[test]
606 fn normalizes_xpath_and_css_like_web_clients() {
607 assert_eq!(
608 normalize_selector_for_transport("xpath=//android.widget.TextView"),
609 "xpath=\"//android.widget.TextView\""
610 );
611 assert_eq!(normalize_selector_for_transport("#login"), "css=\"#login\"");
612 assert_eq!(
613 normalize_selector_for_transport("Id=bottom_nav_account"),
614 "css=\"#bottom_nav_account\""
615 );
616 assert_eq!(
617 normalize_selector_for_transport(r"Id=bottom\_nav\_account"),
618 "css=\"#bottom_nav_account\""
619 );
620 assert_eq!(
621 normalize_selector_for_transport("text=Account"),
622 "uia=\"text=Account\""
623 );
624 assert_eq!(
625 normalize_selector_for_transport("textContains=Account"),
626 "uia=\"textContains=Account\""
627 );
628 assert_eq!(
629 normalize_selector_for_transport("resourceId=com.example:id/login"),
630 "uia=\"resourceId=com.example:id/login\""
631 );
632 assert_eq!(
633 normalize_selector_for_transport("descriptionContains=Account"),
634 "uia=\"descriptionContains=Account\""
635 );
636 assert_eq!(
637 normalize_selector_for_transport("selected=true"),
638 "uia=\"selected=true\""
639 );
640 assert_eq!(
641 normalize_selector_for_transport("classNameMatches=android\\.widget\\..*"),
642 "uia=\"classNameMatches=android\\\\.widget\\\\..*\""
643 );
644 }
645
646 #[test]
647 fn chains_mobile_locators_like_web_locators() {
648 let parent = MobileLocator::normalize("xpath=//android.view.ViewGroup");
649 let child = parent.chain("css=.cta");
650 assert_eq!(
651 child.selector,
652 "xpath=\"//android.view.ViewGroup\" css=\".cta\""
653 );
654 }
655
656 #[test]
657 fn mobile_connect_command_returns_web_like_session_shape() {
658 let command = MobileCommand::Connect(ConnectOptions {
659 platform: MobilePlatform::Android,
660 device: Some("emulator-5554".to_string()),
661 adb_endpoint: None,
662 preserve_app_state: true,
663 timeout_ms: Some(5_000),
664 });
665
666 match command {
667 MobileCommand::Connect(options) => {
668 assert_eq!(options.platform, MobilePlatform::Android);
669 assert_eq!(options.device.as_deref(), Some("emulator-5554"));
670 }
671 _ => panic!("expected connect command"),
672 }
673 }
674
675 #[test]
676 fn mobile_launch_command_keeps_launch_shape_separate() {
677 let browser_session = MobileBrowserSessionHandle {
678 platform: MobilePlatform::Android,
679 automation: MobileAutomationSessionInfo {
680 backend: "uiautomator2".to_string(),
681 session_id: "uiautomator2:emulator-5554".to_string(),
682 note: "ready".to_string(),
683 },
684 device: DeviceTarget {
685 platform: MobilePlatform::Android,
686 device_id: "emulator-5554".to_string(),
687 connection_kind: DeviceConnectionKind::Emulator,
688 },
689 };
690
691 let command = MobileCommand::LaunchApp {
692 browser_session,
693 options: LaunchOptions {
694 apk_path: Some("/tmp/app.apk".to_string()),
695 app_id: Some("dev.allwright.sample".to_string()),
696 launch_activity: Some(".MainActivity".to_string()),
697 stop_before_launch: true,
698 timeout_ms: Some(15_000),
699 },
700 };
701
702 match command {
703 MobileCommand::LaunchApp { options, .. } => {
704 assert_eq!(options.apk_path.as_deref(), Some("/tmp/app.apk"));
705 assert!(options.stop_before_launch);
706 }
707 _ => panic!("expected launch command"),
708 }
709 }
710}