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