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}