Skip to main content

allwright_surface_mobile/
lib.rs

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}