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 (SelectorFlavor::UiAutomator, trimmed[..prefix_len - 1].to_string() + "=" + &decode_selector_body(&trimmed[prefix_len..]));
466 }
467 if lowered.starts_with("text=") || lowered.starts_with("text:") {
468 let body = decode_selector_body(&trimmed[5..]);
469 return (SelectorFlavor::UiAutomator, format!("text={body}"));
470 }
471 if lowered.starts_with("id=") || lowered.starts_with("id:") {
472 let body = decode_selector_body(&trimmed[3..]);
473 let normalized = if body.starts_with('#') {
474 body
475 } else {
476 format!("#{body}")
477 };
478 return (SelectorFlavor::Css, normalized);
479 }
480 if lowered.starts_with("css=") || lowered.starts_with("css:") {
481 return (SelectorFlavor::Css, decode_selector_body(&trimmed[4..]));
482 }
483 if trimmed.starts_with("//")
484 || trimmed.starts_with(".//")
485 || trimmed.starts_with("../")
486 || trimmed.starts_with('/')
487 || trimmed.starts_with('(')
488 {
489 return (SelectorFlavor::XPath, trimmed.to_string());
490 }
491 (SelectorFlavor::Css, trimmed.to_string())
492}
493
494pub fn normalize_selector_for_transport(selector: &str) -> String {
495 let trimmed = selector.trim();
496 if trimmed.is_empty() {
497 return String::new();
498 }
499 if is_normalized_transport_selector(trimmed) {
500 return trimmed.to_string();
501 }
502 let (flavor, body) = parse_selector_for_transport(selector);
503 format!(
504 "{}={}",
505 flavor.as_str(),
506 serde_json::to_string(&body).unwrap_or_else(|_| format!("{body:?}"))
507 )
508}
509
510pub fn chain_selector_for_transport(parent: &str, child: &str) -> String {
511 let parent_selector = if parent.trim().is_empty() {
512 String::new()
513 } else {
514 normalize_selector_for_transport(parent)
515 };
516 let child_selector = if child.trim().is_empty() {
517 String::new()
518 } else {
519 normalize_selector_for_transport(child)
520 };
521 if parent_selector.is_empty() {
522 return child_selector;
523 }
524 if child_selector.is_empty() {
525 return parent_selector;
526 }
527 format!("{parent_selector} {child_selector}")
528}
529
530#[cfg(test)]
531mod tests {
532 use super::*;
533
534 #[tokio::test]
535 async fn boots_mobile_runtime() {
536 assert_eq!(boot().await, "mobile ready");
537 }
538
539 #[tokio::test]
540 async fn boots_named_mobile_surface() {
541 assert_eq!(boot_surface("android", 1).await, "android ready");
542 }
543
544 #[test]
545 fn normalizes_xpath_and_css_like_web_clients() {
546 assert_eq!(
547 normalize_selector_for_transport("xpath=//android.widget.TextView"),
548 "xpath=\"//android.widget.TextView\""
549 );
550 assert_eq!(normalize_selector_for_transport("#login"), "css=\"#login\"");
551 assert_eq!(
552 normalize_selector_for_transport("Id=bottom_nav_account"),
553 "css=\"#bottom_nav_account\""
554 );
555 assert_eq!(
556 normalize_selector_for_transport(r"Id=bottom\_nav\_account"),
557 "css=\"#bottom_nav_account\""
558 );
559 assert_eq!(
560 normalize_selector_for_transport("text=Account"),
561 "uia=\"text=Account\""
562 );
563 assert_eq!(
564 normalize_selector_for_transport("textContains=Account"),
565 "uia=\"textContains=Account\""
566 );
567 assert_eq!(
568 normalize_selector_for_transport("resourceId=com.example:id/login"),
569 "uia=\"resourceId=com.example:id/login\""
570 );
571 assert_eq!(
572 normalize_selector_for_transport("descriptionContains=Account"),
573 "uia=\"descriptionContains=Account\""
574 );
575 assert_eq!(
576 normalize_selector_for_transport("selected=true"),
577 "uia=\"selected=true\""
578 );
579 assert_eq!(
580 normalize_selector_for_transport("classNameMatches=android\\.widget\\..*"),
581 "uia=\"classNameMatches=android\\\\.widget\\\\..*\""
582 );
583 }
584
585 #[test]
586 fn chains_mobile_locators_like_web_locators() {
587 let parent = MobileLocator::normalize("xpath=//android.view.ViewGroup");
588 let child = parent.chain("css=.cta");
589 assert_eq!(
590 child.selector,
591 "xpath=\"//android.view.ViewGroup\" css=\".cta\""
592 );
593 }
594
595 #[test]
596 fn mobile_connect_command_returns_web_like_session_shape() {
597 let command = MobileCommand::Connect(ConnectOptions {
598 platform: MobilePlatform::Android,
599 device: Some("emulator-5554".to_string()),
600 adb_endpoint: None,
601 preserve_app_state: true,
602 timeout_ms: Some(5_000),
603 });
604
605 match command {
606 MobileCommand::Connect(options) => {
607 assert_eq!(options.platform, MobilePlatform::Android);
608 assert_eq!(options.device.as_deref(), Some("emulator-5554"));
609 }
610 _ => panic!("expected connect command"),
611 }
612 }
613
614 #[test]
615 fn mobile_launch_command_keeps_launch_shape_separate() {
616 let browser_session = MobileBrowserSessionHandle {
617 platform: MobilePlatform::Android,
618 automation: MobileAutomationSessionInfo {
619 backend: "uiautomator2".to_string(),
620 session_id: "uiautomator2:emulator-5554".to_string(),
621 note: "ready".to_string(),
622 },
623 device: DeviceTarget {
624 platform: MobilePlatform::Android,
625 device_id: "emulator-5554".to_string(),
626 connection_kind: DeviceConnectionKind::Emulator,
627 },
628 };
629
630 let command = MobileCommand::LaunchApp {
631 browser_session,
632 options: LaunchOptions {
633 apk_path: Some("/tmp/app.apk".to_string()),
634 app_id: Some("dev.allwright.sample".to_string()),
635 launch_activity: Some(".MainActivity".to_string()),
636 stop_before_launch: true,
637 timeout_ms: Some(15_000),
638 },
639 };
640
641 match command {
642 MobileCommand::LaunchApp { options, .. } => {
643 assert_eq!(options.apk_path.as_deref(), Some("/tmp/app.apk"));
644 assert!(options.stop_before_launch);
645 }
646 _ => panic!("expected launch command"),
647 }
648 }
649}