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