Skip to main content

allwright/
client_mobile.rs

1use serde::{Deserialize, Serialize};
2
3use super::bootstrap::invoke_plugin;
4use super::types::{ClickResult, CommandOptions, Error, FillResult, Result};
5
6#[derive(Debug, Clone, Default)]
7pub struct MobileAndroidConnectOptions {
8    pub device: Option<String>,
9    pub adb_endpoint: Option<String>,
10    pub preserve_app_state: bool,
11    pub timeout_ms: Option<u32>,
12}
13
14#[derive(Debug, Clone, Default)]
15pub struct MobileAndroidLaunchOptions {
16    pub apk_path: Option<String>,
17    pub app_id: Option<String>,
18    pub launch_activity: Option<String>,
19    pub stop_before_launch: bool,
20    pub timeout_ms: Option<u32>,
21}
22
23#[derive(Debug, Clone, Serialize, Deserialize)]
24struct MobilePluginEnvelope<T> {
25    ok: bool,
26    result: Option<T>,
27    error: Option<String>,
28}
29
30#[derive(Debug, Clone, Serialize, Deserialize)]
31struct MobileBrowserSessionHandle {
32    platform: String,
33    automation: MobileAutomationSessionInfo,
34    device: MobileDeviceTarget,
35}
36
37#[derive(Debug, Clone, Serialize, Deserialize)]
38struct MobileAutomationSessionInfo {
39    session_id: String,
40}
41
42#[derive(Debug, Clone, Serialize, Deserialize)]
43struct MobileDeviceTarget {
44    device_id: String,
45}
46
47#[derive(Debug, Clone, Serialize, Deserialize)]
48struct MobilePageSessionHandle {
49    page_id: String,
50}
51
52#[derive(Debug, Clone, Serialize, Deserialize)]
53struct MobilePageInfo {
54    page_session: MobilePageSessionHandle,
55}
56
57#[derive(Debug, Clone, Serialize, Deserialize)]
58struct MobileConnectInfo {
59    browser_session: MobileBrowserSessionHandle,
60    initial_page: MobilePageInfo,
61}
62
63#[derive(Debug, Clone)]
64pub struct AndroidLocator {
65    page: AndroidPage,
66    selector: String,
67}
68
69#[derive(Debug, Clone)]
70pub struct AndroidPage {
71    browser_session: MobileBrowserSessionHandle,
72    page_session: MobilePageSessionHandle,
73}
74
75#[derive(Debug, Clone)]
76pub struct AndroidDevice {
77    connect_info: MobileConnectInfo,
78    page: AndroidPage,
79}
80
81pub mod android {
82    use super::*;
83
84    pub fn connect(options: MobileAndroidConnectOptions) -> Result<AndroidDevice> {
85        let request = serde_json::json!({
86            "command": "connect",
87            "platform": "android",
88            "device": options.device,
89            "adb_endpoint": options.adb_endpoint,
90            "preserve_app_state": options.preserve_app_state,
91            "timeout_ms": options.timeout_ms,
92        });
93        let connect_info: MobileConnectInfo = invoke_android("connect", request)?;
94        Ok(AndroidDevice::new(connect_info))
95    }
96}
97
98impl AndroidDevice {
99    fn new(connect_info: MobileConnectInfo) -> Self {
100        let page = AndroidPage {
101            browser_session: connect_info.browser_session.clone(),
102            page_session: connect_info.initial_page.page_session.clone(),
103        };
104        Self { connect_info, page }
105    }
106
107    pub fn session_id(&self) -> &str {
108        &self.connect_info.browser_session.automation.session_id
109    }
110
111    pub fn page(&self) -> AndroidPage {
112        self.page.clone()
113    }
114
115    pub fn initial_page(&self) -> AndroidPage {
116        self.page()
117    }
118
119    pub fn launch(&mut self, options: MobileAndroidLaunchOptions) -> Result<AndroidPage> {
120        let request = serde_json::json!({
121            "command": "launch_app",
122            "browser_session": self.connect_info.browser_session,
123            "options": {
124                "apk_path": options.apk_path,
125                "app_id": options.app_id,
126                "launch_activity": options.launch_activity,
127                "stop_before_launch": options.stop_before_launch,
128                "timeout_ms": options.timeout_ms,
129            },
130        });
131        let page_info: MobilePageInfo = invoke_android("launch", request)?;
132        self.page = AndroidPage {
133            browser_session: self.connect_info.browser_session.clone(),
134            page_session: page_info.page_session,
135        };
136        Ok(self.page.clone())
137    }
138}
139
140impl AndroidPage {
141    pub fn session_id(&self) -> &str {
142        &self.page_session.page_id
143    }
144
145    pub fn locator(&self, selector: impl Into<String>) -> AndroidLocator {
146        AndroidLocator {
147            page: self.clone(),
148            selector: normalize_mobile_selector_for_transport(&selector.into()),
149        }
150    }
151
152    pub fn click(&self, selector: &str, options: CommandOptions) -> Result<ClickResult> {
153        #[derive(Deserialize)]
154        struct ClickInfo {
155            selector: String,
156            note: String,
157            session_id: String,
158        }
159        let result: ClickInfo = invoke_android(
160            "click",
161            serde_json::json!({
162                "command": "click_element",
163                "browser_session": self.browser_session,
164                "page_session": self.page_session,
165                "selector": normalize_mobile_selector_for_transport(selector),
166                "timeout_ms": options.timeout_ms,
167            }),
168        )?;
169        Ok(ClickResult {
170            selector: result.selector,
171            note: result.note,
172            bidi_session_id: result.session_id,
173        })
174    }
175
176    pub fn fill(&self, selector: &str, value: &str, options: CommandOptions) -> Result<FillResult> {
177        #[derive(Deserialize)]
178        struct FillInfo {
179            selector: String,
180            value: String,
181            note: String,
182        }
183        let result: FillInfo = invoke_android(
184            "fill",
185            serde_json::json!({
186                "command": "fill_element",
187                "browser_session": self.browser_session,
188                "page_session": self.page_session,
189                "selector": normalize_mobile_selector_for_transport(selector),
190                "value": value,
191                "timeout_ms": options.timeout_ms,
192            }),
193        )?;
194        Ok(FillResult {
195            selector: result.selector,
196            value: result.value,
197            note: result.note,
198        })
199    }
200}
201
202impl AndroidLocator {
203    pub fn page(&self) -> &AndroidPage {
204        &self.page
205    }
206
207    pub fn selector(&self) -> &str {
208        &self.selector
209    }
210
211    pub fn locator(&self, selector: impl Into<String>) -> AndroidLocator {
212        AndroidLocator {
213            page: self.page.clone(),
214            selector: chain_mobile_selector_for_transport(&self.selector, &selector.into()),
215        }
216    }
217
218    pub fn click(&self, options: CommandOptions) -> Result<ClickResult> {
219        self.page.click(&self.selector, options)
220    }
221
222    pub fn fill(&self, value: &str, options: CommandOptions) -> Result<FillResult> {
223        self.page.fill(&self.selector, value, options)
224    }
225}
226
227fn invoke_android<T>(command_name: &str, request: serde_json::Value) -> Result<T>
228where
229    T: for<'de> Deserialize<'de>,
230{
231    let payload = invoke_plugin("mobile-android", &request.to_string())?;
232    let envelope: MobilePluginEnvelope<T> =
233        serde_json::from_str(payload.trim()).map_err(|error| {
234            Error::new(format!(
235                "failed to decode mobile-android plugin response for {command_name}: {error}"
236            ))
237        })?;
238    if !envelope.ok {
239        return Err(Error::new(envelope.error.unwrap_or_else(|| {
240            format!("mobile-android plugin {command_name} failed")
241        })));
242    }
243    envelope.result.ok_or_else(|| {
244        Error::new(format!(
245            "mobile-android plugin {command_name} returned success without a result payload"
246        ))
247    })
248}
249
250const UIAUTOMATOR_SELECTOR_KEYS: &[&str] = &[
251    "text",
252    "textcontains",
253    "textmatches",
254    "textstartswith",
255    "classname",
256    "classnamematches",
257    "description",
258    "desc",
259    "descriptioncontains",
260    "desccontains",
261    "descriptionmatches",
262    "descmatches",
263    "descriptionstartswith",
264    "descstartswith",
265    "checkable",
266    "checked",
267    "clickable",
268    "longclickable",
269    "scrollable",
270    "enabled",
271    "focusable",
272    "focused",
273    "selected",
274    "packagename",
275    "package",
276    "packagenamematches",
277    "resourceid",
278    "resourceidmatches",
279    "index",
280    "instance",
281];
282
283#[derive(Debug, Clone, Copy, PartialEq, Eq)]
284enum MobileSelectorFlavor {
285    Css,
286    XPath,
287    UiAutomator,
288}
289
290impl MobileSelectorFlavor {
291    fn as_str(self) -> &'static str {
292        match self {
293            Self::Css => "css",
294            Self::XPath => "xpath",
295            Self::UiAutomator => "uia",
296        }
297    }
298}
299
300fn parse_explicit_mobile_selector_prefix(selector: &str) -> Option<(MobileSelectorFlavor, usize)> {
301    let lowered = selector.to_ascii_lowercase();
302    if lowered.starts_with("xpath=") || lowered.starts_with("xpath:") {
303        return Some((MobileSelectorFlavor::XPath, 6));
304    }
305    if lowered.starts_with("css=") || lowered.starts_with("css:") {
306        return Some((MobileSelectorFlavor::Css, 4));
307    }
308    if lowered.starts_with("uia=") || lowered.starts_with("uia:") {
309        return Some((MobileSelectorFlavor::UiAutomator, 4));
310    }
311    None
312}
313
314fn parse_uiautomator_selector_prefix(selector: &str) -> Option<usize> {
315    for (index, ch) in selector.char_indices() {
316        if ch != '=' && ch != ':' {
317            continue;
318        }
319        let key = selector[..index].trim().to_ascii_lowercase();
320        if UIAUTOMATOR_SELECTOR_KEYS
321            .iter()
322            .any(|candidate| *candidate == key)
323        {
324            return Some(index + ch.len_utf8());
325        }
326        return None;
327    }
328    None
329}
330
331fn find_json_string_end(value: &str) -> Option<usize> {
332    let bytes = value.as_bytes();
333    if bytes.first().copied()? != b'"' {
334        return None;
335    }
336    let mut index = 1usize;
337    let mut escaped = false;
338    while index < bytes.len() {
339        let byte = bytes[index];
340        if escaped {
341            escaped = false;
342            index += 1;
343            continue;
344        }
345        match byte {
346            b'\\' => escaped = true,
347            b'"' => return Some(index + 1),
348            _ => {}
349        }
350        index += 1;
351    }
352    None
353}
354
355fn is_normalized_mobile_transport_selector(selector: &str) -> bool {
356    let trimmed = selector.trim();
357    if trimmed.is_empty() {
358        return false;
359    }
360
361    let mut index = 0usize;
362    while index < trimmed.len() {
363        let Some((_, prefix_len)) = parse_explicit_mobile_selector_prefix(&trimmed[index..]) else {
364            return false;
365        };
366        index += prefix_len;
367
368        let remainder = &trimmed[index..];
369        let Some(json_end) = find_json_string_end(remainder) else {
370            return false;
371        };
372        index += json_end;
373        if index == trimmed.len() {
374            return true;
375        }
376
377        let whitespace_len = trimmed[index..]
378            .chars()
379            .take_while(|char| char.is_ascii_whitespace())
380            .count();
381        if whitespace_len == 0 {
382            return false;
383        }
384        index += whitespace_len;
385        if parse_explicit_mobile_selector_prefix(&trimmed[index..]).is_none() {
386            return false;
387        }
388    }
389
390    true
391}
392
393fn decode_selector_body(body: &str) -> String {
394    let candidate = body.trim();
395    if candidate.len() >= 2 && candidate.starts_with('"') && candidate.ends_with('"') {
396        if let Ok(decoded) = serde_json::from_str::<String>(candidate) {
397            return decoded;
398        }
399    }
400    candidate.to_string()
401}
402
403fn parse_mobile_selector_for_transport(selector: &str) -> (MobileSelectorFlavor, String) {
404    let trimmed = selector.trim();
405    if let Some((flavor, prefix_len)) = parse_explicit_mobile_selector_prefix(trimmed) {
406        return (flavor, decode_selector_body(&trimmed[prefix_len..]));
407    }
408    if let Some(prefix_len) = parse_uiautomator_selector_prefix(trimmed) {
409        return (
410            MobileSelectorFlavor::UiAutomator,
411            format!("{}={}", &trimmed[..prefix_len - 1], &trimmed[prefix_len..]),
412        );
413    }
414    if trimmed.starts_with("//")
415        || trimmed.starts_with(".//")
416        || trimmed.starts_with("../")
417        || trimmed.starts_with('/')
418        || trimmed.starts_with('(')
419    {
420        return (MobileSelectorFlavor::XPath, trimmed.to_string());
421    }
422    (MobileSelectorFlavor::Css, trimmed.to_string())
423}
424
425fn normalize_mobile_selector_for_transport(selector: &str) -> String {
426    let trimmed = selector.trim();
427    if trimmed.is_empty() {
428        return String::new();
429    }
430    if is_normalized_mobile_transport_selector(trimmed) {
431        return trimmed.to_string();
432    }
433    let (flavor, body) = parse_mobile_selector_for_transport(selector);
434    format!(
435        "{}={}",
436        flavor.as_str(),
437        serde_json::to_string(&body).unwrap_or_else(|_| format!("{body:?}"))
438    )
439}
440
441fn chain_mobile_selector_for_transport(parent: &str, child: &str) -> String {
442    let parent_selector = if parent.trim().is_empty() {
443        String::new()
444    } else {
445        normalize_mobile_selector_for_transport(parent)
446    };
447    let child_selector = if child.trim().is_empty() {
448        String::new()
449    } else {
450        normalize_mobile_selector_for_transport(child)
451    };
452    if parent_selector.is_empty() {
453        return child_selector;
454    }
455    if child_selector.is_empty() {
456        return parent_selector;
457    }
458    format!("{parent_selector} {child_selector}")
459}