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    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}