Skip to main content

smix_driver/
android.rs

1//! v6.0 c3a — Android `Driver` impl skeleton.
2//!
3//! Wraps [`HttpRunnerClient`] talking to the Android-side Kotlin runner
4//! (v6.0 c3b — APK + KTOR HTTP server backed by UiAutomator2). The
5//! runner is reached via `adb forward tcp:HOST tcp:DEVICE` so host-side
6//! HTTP transport is identical to iOS.
7//!
8//! **State** — c3a ships trait skeleton: all 26 sense+act methods
9//! compile + return either (a) a transparent delegation to the runner
10//! if the wire shape is reusable, or (b) an explicit "v6.0 c3b runner
11//! not yet shipped" error so failures are visible (not silent).
12//!
13//! Acceptance gated by v6.0 c3b (Kotlin runner APK install + booted
14//! emulator). c3a is unit-tested via `Box<dyn Driver>` dyn_compat +
15//! platform=Android probes; end-to-end smoke lands at c3b.
16
17use async_trait::async_trait;
18use std::time::Duration;
19
20use smix_error::{ExpectationFailure, FailureCode, FailureInit};
21use smix_host_coord_resolver::{HostResolveError, resolve_to_norm_coord};
22use smix_input::{KeyName, SwipeDirection};
23use smix_runner_client::{HttpRunnerClient, IncludeScope, OcrFrame, SystemPopup, TapMode};
24use smix_screen::A11yNode;
25use smix_selector::{Selector, describe_selector};
26use smix_selector_resolver::{resolve_selector, resolve_selector_all};
27
28use crate::Orientation;
29use crate::traits::{Driver, Platform};
30
31/// Android `Driver` impl. Wraps `HttpRunnerClient` connecting to the
32/// Kotlin runner via adb-forwarded port (default 28080, configurable
33/// via `AndroidDriver::new(port)`).
34pub struct AndroidDriver {
35    runner: HttpRunnerClient,
36}
37
38impl AndroidDriver {
39    #[must_use]
40    pub fn new(runner: HttpRunnerClient) -> Self {
41        AndroidDriver { runner }
42    }
43
44    pub fn runner(&self) -> &HttpRunnerClient {
45        &self.runner
46    }
47}
48
49/// v6.0 c3c-v — host-resolve loop with 5s implicit-wait + 250ms poll.
50/// Returns viewport-normalized centroid coord. Shared by tap / double_tap
51/// / long_press / fill / clear.
52async fn resolve_with_implicit_wait(
53    driver: &AndroidDriver,
54    selector: &Selector,
55    include: Option<IncludeScope>,
56) -> Result<(f64, f64), ExpectationFailure> {
57    let start = std::time::Instant::now();
58    let timeout = Duration::from_millis(5000);
59    loop {
60        let tree = driver.tree(include).await?;
61        match resolve_to_norm_coord(&tree, selector) {
62            Ok(coord) => return Ok(coord),
63            Err(HostResolveError::NotFound) => {
64                if start.elapsed() > timeout {
65                    return Err(ExpectationFailure::new(FailureInit {
66                        code: Some(FailureCode::ElementNotFound),
67                        message: format!(
68                            "AndroidDriver: element not found: {}",
69                            describe_selector(selector)
70                        ),
71                        selector: Some(selector.clone()),
72                        ..Default::default()
73                    }));
74                }
75                tokio::time::sleep(Duration::from_millis(250)).await;
76                continue;
77            }
78            Err(e) => {
79                return Err(ExpectationFailure::new(FailureInit {
80                    code: Some(FailureCode::DriverError),
81                    message: format!("AndroidDriver: resolve error: {e:?}"),
82                    selector: Some(selector.clone()),
83                    ..Default::default()
84                }));
85            }
86        }
87    }
88}
89
90fn defer_err(method: &str) -> ExpectationFailure {
91    ExpectationFailure::new(FailureInit {
92        code: Some(FailureCode::DriverError),
93        message: format!(
94            "AndroidDriver::{method}: Kotlin runner endpoint not yet shipped (v6.0 c3b earned defer)"
95        ),
96        ..Default::default()
97    })
98}
99
100#[async_trait]
101impl Driver for AndroidDriver {
102    fn platform(&self) -> Platform {
103        Platform::Android
104    }
105
106    // No as_ios_driver override — uses default `None` from trait.
107
108    // === Sense ===
109
110    async fn tree(&self, include: Option<IncludeScope>) -> Result<A11yNode, ExpectationFailure> {
111        // v6.0 c3c-ii — delegates to Kotlin runner GET /tree (UiAutomator2
112        // dumpWindowHierarchy → A11yNode JSON shape). HttpRunnerClient
113        // is platform-agnostic; same wire as iOS.
114        self.runner.get_tree(include).await.map_err(|e| {
115            ExpectationFailure::new(FailureInit {
116                code: Some(FailureCode::DriverError),
117                message: format!("AndroidDriver::tree: {e}"),
118                ..Default::default()
119            })
120        })
121    }
122
123    async fn find(
124        &self,
125        selector: &Selector,
126        include: Option<IncludeScope>,
127    ) -> Result<bool, ExpectationFailure> {
128        // v6.0 c3c-iii — host-resolve over tree (Kotlin runner /find route
129        // not needed; tree dump 已含全树).
130        let tree = self.tree(include).await?;
131        Ok(resolve_selector(&tree, selector).is_some())
132    }
133
134    async fn find_one(
135        &self,
136        selector: &Selector,
137        include: Option<IncludeScope>,
138    ) -> Result<Option<A11yNode>, ExpectationFailure> {
139        let tree = self.tree(include).await?;
140        Ok(resolve_selector(&tree, selector).cloned())
141    }
142
143    async fn find_all(
144        &self,
145        selector: &Selector,
146        include: Option<IncludeScope>,
147    ) -> Result<Vec<A11yNode>, ExpectationFailure> {
148        let tree = self.tree(include).await?;
149        Ok(resolve_selector_all(&tree, selector)
150            .into_iter()
151            .cloned()
152            .collect())
153    }
154
155    async fn find_norm_coord(
156        &self,
157        selector: &Selector,
158    ) -> Result<Option<(f64, f64)>, ExpectationFailure> {
159        let tree = self.tree(None).await?;
160        match resolve_to_norm_coord(&tree, selector) {
161            Ok(coord) => Ok(Some(coord)),
162            Err(HostResolveError::NotFound | HostResolveError::EmptyMatchedFrame) => Ok(None),
163            Err(HostResolveError::UnknownAppFrame) => Err(ExpectationFailure::new(FailureInit {
164                code: Some(FailureCode::DriverError),
165                message: "AndroidDriver::find_norm_coord: tree bounds w/h ≤ 0 (unknown app frame)"
166                    .into(),
167                ..Default::default()
168            })),
169            Err(HostResolveError::CentroidOutOfFrame { .. }) => Ok(None),
170        }
171    }
172
173    async fn find_text_by_ocr(
174        &self,
175        text: &str,
176        locales: &[String],
177        recognition_level: &str,
178    ) -> Result<Option<OcrFrame>, ExpectationFailure> {
179        // v6.3 c2 — Google ML Kit Text Recognition (Latin script package).
180        // Locales + recognition_level args are iOS Apple Vision specific;
181        // Kotlin /find-text-by-ocr endpoint reads + ignores them today
182        // (ML Kit Latin handles ASCII/European text universally).
183        self.runner
184            .find_text_by_ocr(text, locales, recognition_level)
185            .await
186            .map_err(|e| {
187                ExpectationFailure::new(FailureInit {
188                    code: Some(FailureCode::DriverError),
189                    message: format!("AndroidDriver::find_text_by_ocr: {e}"),
190                    ..Default::default()
191                })
192            })
193    }
194
195    async fn system_popups(
196        &self,
197        include: Option<IncludeScope>,
198    ) -> Result<Vec<SystemPopup>, ExpectationFailure> {
199        // v6.3 c3 — Kotlin /system-popups walks UiAutomation.windows and
200        // classifies dialog-shaped TYPE_APPLICATION windows. Returns
201        // envelope {popups: [...]} per HttpRunnerClient deserialization.
202        self.runner.system_popups(include).await.map_err(|e| {
203            ExpectationFailure::new(FailureInit {
204                code: Some(FailureCode::DriverError),
205                message: format!("AndroidDriver::system_popups: {e}"),
206                ..Default::default()
207            })
208        })
209    }
210
211    async fn system_popup_action(
212        &self,
213        popup_id: &str,
214        button_id: &str,
215    ) -> Result<bool, ExpectationFailure> {
216        // v6.3 c3 — Kotlin /system-popup-action re-walks windows + finds
217        // popup by id + button by testTag-derived id + UiDevice.click on
218        // its bounding box center.
219        self.runner
220            .system_popup_action(popup_id, button_id)
221            .await
222            .map_err(|e| {
223                ExpectationFailure::new(FailureInit {
224                    code: Some(FailureCode::DriverError),
225                    message: format!("AndroidDriver::system_popup_action: {e}"),
226                    ..Default::default()
227                })
228            })
229    }
230
231    async fn wait_for(
232        &self,
233        selector: &Selector,
234        timeout: Duration,
235        include: Option<IncludeScope>,
236    ) -> Result<A11yNode, ExpectationFailure> {
237        // v6.0 c3c-iii — poll tree at 250ms cadence up to `timeout`, return
238        // matched node on first hit. Mirror of iOS wait_for semantics.
239        let start = std::time::Instant::now();
240        loop {
241            let tree = self.tree(include).await?;
242            if let Some(node) = resolve_selector(&tree, selector) {
243                return Ok(node.clone());
244            }
245            if start.elapsed() >= timeout {
246                return Err(ExpectationFailure::new(FailureInit {
247                    code: Some(FailureCode::ElementNotFound),
248                    message: format!(
249                        "AndroidDriver::wait_for timeout after {}ms: {}",
250                        timeout.as_millis(),
251                        describe_selector(selector)
252                    ),
253                    selector: Some(selector.clone()),
254                    ..Default::default()
255                }));
256            }
257            tokio::time::sleep(Duration::from_millis(250)).await;
258        }
259    }
260
261    // === Act ===
262
263    async fn tap(
264        &self,
265        selector: &Selector,
266        include: Option<IncludeScope>,
267    ) -> Result<(), ExpectationFailure> {
268        // v6.0 c3c-iii — host-resolve + tap_at_norm_coord (mirror IosDriver
269        // Path B). v6.0 c3c-v — refactored to shared helper.
270        let (nx, ny) = resolve_with_implicit_wait(self, selector, include).await?;
271        self.runner.tap_at_norm_coord(nx, ny).await.map_err(|e| {
272            ExpectationFailure::new(FailureInit {
273                code: Some(FailureCode::DriverError),
274                message: format!("AndroidDriver::tap: runner.tap_at_norm_coord: {e}"),
275                ..Default::default()
276            })
277        })
278    }
279
280    async fn tap_with_mode(
281        &self,
282        _selector: &Selector,
283        _mode: TapMode,
284        _include: Option<IncludeScope>,
285    ) -> Result<(), ExpectationFailure> {
286        Err(defer_err("tap_with_mode"))
287    }
288
289    async fn tap_at_norm_coord(&self, nx: f64, ny: f64) -> Result<(), ExpectationFailure> {
290        // v6.0 c3c-iii — direct passthru to Kotlin runner /tap-at-norm-coord.
291        self.runner.tap_at_norm_coord(nx, ny).await.map_err(|e| {
292            ExpectationFailure::new(FailureInit {
293                code: Some(FailureCode::DriverError),
294                message: format!("AndroidDriver::tap_at_norm_coord: {e}"),
295                ..Default::default()
296            })
297        })
298    }
299
300    async fn tap_by_id(&self, id: &str) -> Result<(), ExpectationFailure> {
301        // v6.0 c3c-v — POST /tap-by-id with {id}. Kotlin side finds
302        // UiObject2 via By.res(short or fully-qualified) and clicks.
303        let ok = self.runner.tap_by_id(id).await.map_err(|e| {
304            ExpectationFailure::new(FailureInit {
305                code: Some(FailureCode::DriverError),
306                message: format!("AndroidDriver::tap_by_id: {e}"),
307                ..Default::default()
308            })
309        })?;
310        if !ok {
311            return Err(ExpectationFailure::new(FailureInit {
312                code: Some(FailureCode::ElementNotFound),
313                message: format!("AndroidDriver::tap_by_id: no element with resource-id '{id}'"),
314                ..Default::default()
315            }));
316        }
317        Ok(())
318    }
319
320    async fn double_tap(
321        &self,
322        selector: &Selector,
323        include: Option<IncludeScope>,
324    ) -> Result<(), ExpectationFailure> {
325        // v6.0 c3c-v — host-resolve + /double-tap-at-norm-coord (Kotlin
326        // side dispatches 2 clicks 150ms apart).
327        let (nx, ny) = resolve_with_implicit_wait(self, selector, include).await?;
328        self.runner
329            .double_tap_at_norm_coord(nx, ny)
330            .await
331            .map_err(|e| {
332                ExpectationFailure::new(FailureInit {
333                    code: Some(FailureCode::DriverError),
334                    message: format!("AndroidDriver::double_tap: {e}"),
335                    ..Default::default()
336                })
337            })
338    }
339
340    async fn long_press(
341        &self,
342        selector: &Selector,
343        duration: Duration,
344        include: Option<IncludeScope>,
345    ) -> Result<(), ExpectationFailure> {
346        // v6.0 c3c-v — host-resolve + /long-press-at-norm-coord with
347        // duration. Kotlin uses UiDevice.swipe(x,y,x,y,steps) where
348        // steps = duration / 5ms to approximate a sustained press.
349        let (nx, ny) = resolve_with_implicit_wait(self, selector, include).await?;
350        let duration_ms = duration.as_millis() as u64;
351        self.runner
352            .long_press_at_norm_coord(nx, ny, duration_ms)
353            .await
354            .map_err(|e| {
355                ExpectationFailure::new(FailureInit {
356                    code: Some(FailureCode::DriverError),
357                    message: format!("AndroidDriver::long_press: {e}"),
358                    ..Default::default()
359                })
360            })
361    }
362
363    async fn fill(
364        &self,
365        selector: &Selector,
366        text: &str,
367        include: Option<IncludeScope>,
368    ) -> Result<(), ExpectationFailure> {
369        // v6.0 c3c-v — host-resolve → tap to focus → /input-text. Mirror
370        // of swift FlyingFox /fill semantics (selector resolves; client
371        // types text into focused field).
372        let (nx, ny) = resolve_with_implicit_wait(self, selector, include).await?;
373        self.runner.tap_at_norm_coord(nx, ny).await.map_err(|e| {
374            ExpectationFailure::new(FailureInit {
375                code: Some(FailureCode::DriverError),
376                message: format!("AndroidDriver::fill: focus tap failed: {e}"),
377                ..Default::default()
378            })
379        })?;
380        self.runner.input_text(text).await.map_err(|e| {
381            ExpectationFailure::new(FailureInit {
382                code: Some(FailureCode::DriverError),
383                message: format!("AndroidDriver::fill: input_text failed: {e}"),
384                ..Default::default()
385            })
386        })
387    }
388
389    async fn clear(
390        &self,
391        selector: &Selector,
392        include: Option<IncludeScope>,
393    ) -> Result<(), ExpectationFailure> {
394        // v6.0 c3c-v — host-resolve → tap to focus → press DELETE N times.
395        // No Kotlin /clear endpoint needed (avoids UiObject2 fragility);
396        // 50 BACKSPACE presses cover near all real-world input fields.
397        let (nx, ny) = resolve_with_implicit_wait(self, selector, include).await?;
398        self.runner.tap_at_norm_coord(nx, ny).await.map_err(|e| {
399            ExpectationFailure::new(FailureInit {
400                code: Some(FailureCode::DriverError),
401                message: format!("AndroidDriver::clear: focus tap failed: {e}"),
402                ..Default::default()
403            })
404        })?;
405        for _ in 0..50 {
406            self.runner.press_key(KeyName::Delete).await.map_err(|e| {
407                ExpectationFailure::new(FailureInit {
408                    code: Some(FailureCode::DriverError),
409                    message: format!("AndroidDriver::clear: delete press failed: {e}"),
410                    ..Default::default()
411                })
412            })?;
413        }
414        Ok(())
415    }
416
417    async fn press_key(&self, key: KeyName) -> Result<(), ExpectationFailure> {
418        // v6.0 c3c-iv — Kotlin /press-key maps smix KeyName → KeyEvent.KEYCODE_*.
419        self.runner.press_key(key).await.map(|_| ()).map_err(|e| {
420            ExpectationFailure::new(FailureInit {
421                code: Some(FailureCode::DriverError),
422                message: format!("AndroidDriver::press_key: {e}"),
423                ..Default::default()
424            })
425        })
426    }
427
428    async fn scroll(
429        &self,
430        selector: &Selector,
431        direction: SwipeDirection,
432    ) -> Result<(), ExpectationFailure> {
433        // v6.0 c3c-iv — host-side scroll-until-visible loop. /tree +
434        // /swipe-once primitives — same pattern as iOS.
435        let start = std::time::Instant::now();
436        let timeout = Duration::from_secs(20);
437        const MAX_SWIPES: u32 = 30;
438        for _ in 0..=MAX_SWIPES {
439            let tree = self.tree(None).await?;
440            if resolve_selector(&tree, selector).is_some() {
441                return Ok(());
442            }
443            if start.elapsed() > timeout {
444                break;
445            }
446            self.swipe_once(direction).await?;
447        }
448        Err(ExpectationFailure::new(FailureInit {
449            code: Some(FailureCode::ElementNotFound),
450            message: format!(
451                "AndroidDriver::scroll: element not visible after {} swipes: {}",
452                MAX_SWIPES,
453                describe_selector(selector)
454            ),
455            selector: Some(selector.clone()),
456            ..Default::default()
457        }))
458    }
459
460    async fn swipe_once(&self, direction: SwipeDirection) -> Result<(), ExpectationFailure> {
461        self.runner.swipe_once(direction).await.map_err(|e| {
462            ExpectationFailure::new(FailureInit {
463                code: Some(FailureCode::DriverError),
464                message: format!("AndroidDriver::swipe_once: {e}"),
465                ..Default::default()
466            })
467        })
468    }
469
470    async fn swipe_at_norm_coord(
471        &self,
472        from: (f64, f64),
473        to: (f64, f64),
474    ) -> Result<(), ExpectationFailure> {
475        self.runner
476            .swipe_at_norm_coord(from, to)
477            .await
478            .map_err(|e| {
479                ExpectationFailure::new(FailureInit {
480                    code: Some(FailureCode::DriverError),
481                    message: format!("AndroidDriver::swipe_at_norm_coord: {e}"),
482                    ..Default::default()
483                })
484            })
485    }
486
487    async fn hide_keyboard(&self) -> Result<(), ExpectationFailure> {
488        self.runner.hide_keyboard().await.map_err(|e| {
489            ExpectationFailure::new(FailureInit {
490                code: Some(FailureCode::DriverError),
491                message: format!("AndroidDriver::hide_keyboard: {e}"),
492                ..Default::default()
493            })
494        })
495    }
496
497    async fn back(&self) -> Result<(), ExpectationFailure> {
498        // v6.0 c3c-iv — Kotlin /back → UiDevice.pressBack (KEYCODE_BACK).
499        self.runner.back().await.map_err(|e| {
500            ExpectationFailure::new(FailureInit {
501                code: Some(FailureCode::DriverError),
502                message: format!("AndroidDriver::back: {e}"),
503                ..Default::default()
504            })
505        })
506    }
507
508    async fn set_orientation(&self, orientation: Orientation) -> Result<(), ExpectationFailure> {
509        self.runner
510            .set_orientation(orientation.as_wire())
511            .await
512            .map_err(|e| {
513                ExpectationFailure::new(FailureInit {
514                    code: Some(FailureCode::DriverError),
515                    message: format!("AndroidDriver::set_orientation: {e}"),
516                    ..Default::default()
517                })
518            })
519    }
520
521    async fn foreground(&self, bundle_id: &str) -> Result<(), ExpectationFailure> {
522        // v6.3 c1 — Kotlin /foreground runs `am start --activity-single-top
523        // -n pkg/.MainActivity` (mirror iOS XCUIDevice activate semantic
524        // without launching a new instance).
525        self.runner.foreground(bundle_id).await.map_err(|e| {
526            ExpectationFailure::new(FailureInit {
527                code: Some(FailureCode::DriverError),
528                message: format!("AndroidDriver::foreground: {e}"),
529                ..Default::default()
530            })
531        })
532    }
533
534    async fn webview_eval(&self, js: &str) -> Result<serde_json::Value, ExpectationFailure> {
535        // v6.5 c1 — runner /webview-eval proxies HTTP to fixture's shim
536        // server (WebViewEvalServer on :28081, started by MainActivity
537        // onCreate). evaluateJavascript callback result is a JSON-encoded
538        // string (e.g. "null", "\"hello\"", "42") passed verbatim.
539        self.runner.webview_eval(js).await.map_err(|e| {
540            ExpectationFailure::new(FailureInit {
541                code: Some(FailureCode::DriverError),
542                message: format!("AndroidDriver::webview_eval: {e}"),
543                ..Default::default()
544            })
545        })
546    }
547}