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 Connect(ConnectOptions),
262 LaunchApp {
263 browser_session: MobileBrowserSessionHandle,
264 options: LaunchOptions,
265 },
266 OpenPage {
267 browser_session: MobileBrowserSessionHandle,
268 },
269 ClosePage {
270 browser_session: MobileBrowserSessionHandle,
271 page_session: MobilePageSessionHandle,
272 },
273 ClickElement {
274 browser_session: MobileBrowserSessionHandle,
275 page_session: MobilePageSessionHandle,
276 selector: String,
277 timeout_ms: Option<u32>,
278 },
279 CountElements {
280 browser_session: MobileBrowserSessionHandle,
281 page_session: MobilePageSessionHandle,
282 selector: String,
283 timeout_ms: Option<u32>,
284 },
285 FocusElement {
286 browser_session: MobileBrowserSessionHandle,
287 page_session: MobilePageSessionHandle,
288 selector: String,
289 timeout_ms: Option<u32>,
290 },
291 FillElement {
292 browser_session: MobileBrowserSessionHandle,
293 page_session: MobilePageSessionHandle,
294 selector: String,
295 value: String,
296 timeout_ms: Option<u32>,
297 },
298 PressKey {
299 browser_session: MobileBrowserSessionHandle,
300 page_session: MobilePageSessionHandle,
301 selector: String,
302 key: String,
303 text: Option<String>,
304 timeout_ms: Option<u32>,
305 },
306 GetText {
307 browser_session: MobileBrowserSessionHandle,
308 page_session: MobilePageSessionHandle,
309 selector: String,
310 timeout_ms: Option<u32>,
311 },
312 GetInnerText {
313 browser_session: MobileBrowserSessionHandle,
314 page_session: MobilePageSessionHandle,
315 selector: String,
316 timeout_ms: Option<u32>,
317 },
318 WaitForSelector {
319 browser_session: MobileBrowserSessionHandle,
320 page_session: MobilePageSessionHandle,
321 selector: String,
322 visible: bool,
323 timeout_ms: Option<u32>,
324 },
325 Screenshot {
326 browser_session: MobileBrowserSessionHandle,
327 page_session: MobilePageSessionHandle,
328 timeout_ms: Option<u32>,
329 full_page: bool,
330 },
331}
332
333#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
334#[serde(tag = "result", rename_all = "snake_case")]
335pub enum MobileCommandResult {
336 Connect(MobileConnectInfo),
337 LaunchApp(MobilePageInfo),
338 OpenPage(MobilePageInfo),
339 ClosePage,
340 ClickElement(MobileClickInfo),
341 CountElements(MobileElementCountInfo),
342 FocusElement(MobileElementInfo),
343 FillElement(MobileFillInfo),
344 PressKey(MobilePressInfo),
345 GetText(MobileTextInfo),
346 GetInnerText(MobileTextInfo),
347 WaitForSelector(MobileWaitForSelectorInfo),
348 Screenshot(MobileScreenshotInfo),
349}
350
351pub fn shared_descriptor() -> SurfacePluginDescriptor {
352 SurfacePluginDescriptor {
353 id: SURFACE_ID,
354 family: SurfaceFamily::Mobile,
355 version: env!("CARGO_PKG_VERSION"),
356 description: "Shared mobile surface abstractions for Android and iOS plugins.",
357 }
358}
359
360pub async fn boot_surface(label: &str, delay_ms: u64) -> String {
361 sleep(Duration::from_millis(delay_ms)).await;
362 format!("{label} ready")
363}
364
365pub async fn boot() -> String {
366 boot_surface("mobile", 25).await
367}
368
369fn parse_explicit_selector_prefix(selector: &str) -> Option<(SelectorFlavor, usize)> {
370 let lowered = selector.to_ascii_lowercase();
371 if lowered.starts_with("xpath=") || lowered.starts_with("xpath:") {
372 return Some((SelectorFlavor::XPath, 6));
373 }
374 if lowered.starts_with("uia=") || lowered.starts_with("uia:") {
375 return Some((SelectorFlavor::UiAutomator, 4));
376 }
377 if let Some(prefix_len) = uiautomator_selector_prefix_len(&lowered) {
378 return Some((SelectorFlavor::UiAutomator, prefix_len));
379 }
380 if lowered.starts_with("text=") || lowered.starts_with("text:") {
381 return Some((SelectorFlavor::UiAutomator, 5));
382 }
383 if lowered.starts_with("id=") || lowered.starts_with("id:") {
384 return Some((SelectorFlavor::Css, 3));
385 }
386 if lowered.starts_with("css=") || lowered.starts_with("css:") {
387 return Some((SelectorFlavor::Css, 4));
388 }
389 None
390}
391
392fn uiautomator_selector_prefix_len(lowered: &str) -> Option<usize> {
393 UIAUTOMATOR_SELECTOR_KEYS.iter().find_map(|key| {
394 if lowered.starts_with(key) {
395 let separator = lowered.as_bytes().get(key.len()).copied()?;
396 if separator == b'=' || separator == b':' {
397 return Some(key.len() + 1);
398 }
399 }
400 None
401 })
402}
403
404fn find_json_string_end(value: &str) -> Option<usize> {
405 let bytes = value.as_bytes();
406 if bytes.first().copied()? != b'"' {
407 return None;
408 }
409
410 let mut index = 1usize;
411 let mut escaped = false;
412 while index < bytes.len() {
413 let byte = bytes[index];
414 if escaped {
415 escaped = false;
416 index += 1;
417 continue;
418 }
419 match byte {
420 b'\\' => escaped = true,
421 b'"' => return Some(index + 1),
422 _ => {}
423 }
424 index += 1;
425 }
426 None
427}
428
429fn is_normalized_transport_selector(selector: &str) -> bool {
430 let trimmed = selector.trim();
431 if trimmed.is_empty() {
432 return false;
433 }
434
435 let mut index = 0usize;
436 while index < trimmed.len() {
437 let Some((_, prefix_len)) = parse_explicit_selector_prefix(&trimmed[index..]) else {
438 return false;
439 };
440
441 index += prefix_len;
442 let remainder = &trimmed[index..];
443 if !remainder.starts_with('"') {
444 return false;
445 }
446
447 let Some(json_end) = find_json_string_end(remainder) else {
448 return false;
449 };
450 index += json_end;
451
452 if index == trimmed.len() {
453 return true;
454 }
455
456 let whitespace_len = trimmed[index..]
457 .chars()
458 .take_while(|char| char.is_ascii_whitespace())
459 .count();
460 if whitespace_len == 0 {
461 return false;
462 }
463 index += whitespace_len;
464
465 if parse_explicit_selector_prefix(&trimmed[index..]).is_none() {
466 return false;
467 }
468 }
469
470 true
471}
472
473fn decode_selector_body(body: &str) -> String {
474 let candidate = body.trim();
475 if candidate.len() >= 2 && candidate.starts_with('"') && candidate.ends_with('"') {
476 if let Ok(decoded) = serde_json::from_str::<String>(candidate) {
477 return unescape_shell_escaped_selector(&decoded);
478 }
479 }
480 unescape_shell_escaped_selector(candidate)
481}
482
483fn unescape_shell_escaped_selector(value: &str) -> String {
484 let mut result = String::with_capacity(value.len());
485 let mut chars = value.chars().peekable();
486 while let Some(ch) = chars.next() {
487 if ch == '\\' {
488 match chars.peek().copied() {
489 Some('_' | ' ' | '#' | ':' | '[' | ']' | '(' | ')' | '"' | '\'') => {
490 result.push(chars.next().expect("peeked char should exist"));
491 continue;
492 }
493 _ => {}
494 }
495 }
496 result.push(ch);
497 }
498 result
499}
500
501pub fn parse_selector_for_transport(selector: &str) -> (SelectorFlavor, String) {
502 let trimmed = selector.trim();
503 let lowered = trimmed.to_ascii_lowercase();
504 if lowered.starts_with("xpath=") || lowered.starts_with("xpath:") {
505 return (SelectorFlavor::XPath, decode_selector_body(&trimmed[6..]));
506 }
507 if lowered.starts_with("uia=") || lowered.starts_with("uia:") {
508 return (
509 SelectorFlavor::UiAutomator,
510 decode_selector_body(&trimmed[4..]),
511 );
512 }
513 if let Some(prefix_len) = uiautomator_selector_prefix_len(&lowered) {
514 return (
515 SelectorFlavor::UiAutomator,
516 trimmed[..prefix_len - 1].to_string()
517 + "="
518 + &decode_selector_body(&trimmed[prefix_len..]),
519 );
520 }
521 if lowered.starts_with("text=") || lowered.starts_with("text:") {
522 let body = decode_selector_body(&trimmed[5..]);
523 return (SelectorFlavor::UiAutomator, format!("text={body}"));
524 }
525 if lowered.starts_with("id=") || lowered.starts_with("id:") {
526 let body = decode_selector_body(&trimmed[3..]);
527 let normalized = if body.starts_with('#') {
528 body
529 } else {
530 format!("#{body}")
531 };
532 return (SelectorFlavor::Css, normalized);
533 }
534 if lowered.starts_with("css=") || lowered.starts_with("css:") {
535 return (SelectorFlavor::Css, decode_selector_body(&trimmed[4..]));
536 }
537 if trimmed.starts_with("//")
538 || trimmed.starts_with(".//")
539 || trimmed.starts_with("../")
540 || trimmed.starts_with('/')
541 || trimmed.starts_with('(')
542 {
543 return (SelectorFlavor::XPath, trimmed.to_string());
544 }
545 (SelectorFlavor::Css, trimmed.to_string())
546}
547
548pub fn normalize_selector_for_transport(selector: &str) -> String {
549 let trimmed = selector.trim();
550 if trimmed.is_empty() {
551 return String::new();
552 }
553 if is_normalized_transport_selector(trimmed) {
554 return trimmed.to_string();
555 }
556 let (flavor, body) = parse_selector_for_transport(selector);
557 format!(
558 "{}={}",
559 flavor.as_str(),
560 serde_json::to_string(&body).unwrap_or_else(|_| format!("{body:?}"))
561 )
562}
563
564pub fn chain_selector_for_transport(parent: &str, child: &str) -> String {
565 let parent_selector = if parent.trim().is_empty() {
566 String::new()
567 } else {
568 normalize_selector_for_transport(parent)
569 };
570 let child_selector = if child.trim().is_empty() {
571 String::new()
572 } else {
573 normalize_selector_for_transport(child)
574 };
575 if parent_selector.is_empty() {
576 return child_selector;
577 }
578 if child_selector.is_empty() {
579 return parent_selector;
580 }
581 format!("{parent_selector} {child_selector}")
582}
583
584#[cfg(test)]
585mod tests {
586 use super::*;
587
588 #[tokio::test]
589 async fn boots_mobile_runtime() {
590 assert_eq!(boot().await, "mobile ready");
591 }
592
593 #[tokio::test]
594 async fn boots_named_mobile_surface() {
595 assert_eq!(boot_surface("android", 1).await, "android ready");
596 }
597
598 #[test]
599 fn normalizes_xpath_and_css_like_web_clients() {
600 assert_eq!(
601 normalize_selector_for_transport("xpath=//android.widget.TextView"),
602 "xpath=\"//android.widget.TextView\""
603 );
604 assert_eq!(normalize_selector_for_transport("#login"), "css=\"#login\"");
605 assert_eq!(
606 normalize_selector_for_transport("Id=bottom_nav_account"),
607 "css=\"#bottom_nav_account\""
608 );
609 assert_eq!(
610 normalize_selector_for_transport(r"Id=bottom\_nav\_account"),
611 "css=\"#bottom_nav_account\""
612 );
613 assert_eq!(
614 normalize_selector_for_transport("text=Account"),
615 "uia=\"text=Account\""
616 );
617 assert_eq!(
618 normalize_selector_for_transport("textContains=Account"),
619 "uia=\"textContains=Account\""
620 );
621 assert_eq!(
622 normalize_selector_for_transport("resourceId=com.example:id/login"),
623 "uia=\"resourceId=com.example:id/login\""
624 );
625 assert_eq!(
626 normalize_selector_for_transport("descriptionContains=Account"),
627 "uia=\"descriptionContains=Account\""
628 );
629 assert_eq!(
630 normalize_selector_for_transport("selected=true"),
631 "uia=\"selected=true\""
632 );
633 assert_eq!(
634 normalize_selector_for_transport("classNameMatches=android\\.widget\\..*"),
635 "uia=\"classNameMatches=android\\\\.widget\\\\..*\""
636 );
637 }
638
639 #[test]
640 fn chains_mobile_locators_like_web_locators() {
641 let parent = MobileLocator::normalize("xpath=//android.view.ViewGroup");
642 let child = parent.chain("css=.cta");
643 assert_eq!(
644 child.selector,
645 "xpath=\"//android.view.ViewGroup\" css=\".cta\""
646 );
647 }
648
649 #[test]
650 fn mobile_connect_command_returns_web_like_session_shape() {
651 let command = MobileCommand::Connect(ConnectOptions {
652 platform: MobilePlatform::Android,
653 device: Some("emulator-5554".to_string()),
654 adb_endpoint: None,
655 preserve_app_state: true,
656 timeout_ms: Some(5_000),
657 });
658
659 match command {
660 MobileCommand::Connect(options) => {
661 assert_eq!(options.platform, MobilePlatform::Android);
662 assert_eq!(options.device.as_deref(), Some("emulator-5554"));
663 }
664 _ => panic!("expected connect command"),
665 }
666 }
667
668 #[test]
669 fn mobile_launch_command_keeps_launch_shape_separate() {
670 let browser_session = MobileBrowserSessionHandle {
671 platform: MobilePlatform::Android,
672 automation: MobileAutomationSessionInfo {
673 backend: "uiautomator2".to_string(),
674 session_id: "uiautomator2:emulator-5554".to_string(),
675 note: "ready".to_string(),
676 },
677 device: DeviceTarget {
678 platform: MobilePlatform::Android,
679 device_id: "emulator-5554".to_string(),
680 connection_kind: DeviceConnectionKind::Emulator,
681 },
682 };
683
684 let command = MobileCommand::LaunchApp {
685 browser_session,
686 options: LaunchOptions {
687 apk_path: Some("/tmp/app.apk".to_string()),
688 app_id: Some("dev.allwright.sample".to_string()),
689 launch_activity: Some(".MainActivity".to_string()),
690 stop_before_launch: true,
691 timeout_ms: Some(15_000),
692 },
693 };
694
695 match command {
696 MobileCommand::LaunchApp { options, .. } => {
697 assert_eq!(options.apk_path.as_deref(), Some("/tmp/app.apk"));
698 assert!(options.stop_before_launch);
699 }
700 _ => panic!("expected launch command"),
701 }
702 }
703}