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    /// v1.0.3 — Android impl: attach / clear the `Session-Id` header
109    /// on every subsequent request. Same wire as iOS; the Kotlin
110    /// runner-side session table is symmetric.
111    fn set_session_id(&mut self, id: Option<String>) {
112        match id {
113            Some(sid) => self.runner.set_session_id(sid),
114            None => self.runner.clear_session_id(),
115        }
116    }
117
118    // === Sense ===
119
120    async fn tree(&self, include: Option<IncludeScope>) -> Result<A11yNode, ExpectationFailure> {
121        // v6.0 c3c-ii — delegates to Kotlin runner GET /tree (UiAutomator2
122        // dumpWindowHierarchy → A11yNode JSON shape). HttpRunnerClient
123        // is platform-agnostic; same wire as iOS.
124        self.runner.get_tree(include).await.map_err(|e| {
125            ExpectationFailure::new(FailureInit {
126                code: Some(FailureCode::DriverError),
127                message: format!("AndroidDriver::tree: {e}"),
128                ..Default::default()
129            })
130        })
131    }
132
133    async fn find(
134        &self,
135        selector: &Selector,
136        include: Option<IncludeScope>,
137    ) -> Result<bool, ExpectationFailure> {
138        // v6.0 c3c-iii — host-resolve over tree (Kotlin runner /find route
139        // not needed; tree dump 已含全树).
140        let tree = self.tree(include).await?;
141        Ok(resolve_selector(&tree, selector).is_some())
142    }
143
144    async fn find_one(
145        &self,
146        selector: &Selector,
147        include: Option<IncludeScope>,
148    ) -> Result<Option<A11yNode>, ExpectationFailure> {
149        let tree = self.tree(include).await?;
150        Ok(resolve_selector(&tree, selector).cloned())
151    }
152
153    async fn find_all(
154        &self,
155        selector: &Selector,
156        include: Option<IncludeScope>,
157    ) -> Result<Vec<A11yNode>, ExpectationFailure> {
158        let tree = self.tree(include).await?;
159        Ok(resolve_selector_all(&tree, selector)
160            .into_iter()
161            .cloned()
162            .collect())
163    }
164
165    async fn find_norm_coord(
166        &self,
167        selector: &Selector,
168    ) -> Result<Option<(f64, f64)>, ExpectationFailure> {
169        let tree = self.tree(None).await?;
170        match resolve_to_norm_coord(&tree, selector) {
171            Ok(coord) => Ok(Some(coord)),
172            Err(HostResolveError::NotFound | HostResolveError::EmptyMatchedFrame) => Ok(None),
173            Err(HostResolveError::UnknownAppFrame) => Err(ExpectationFailure::new(FailureInit {
174                code: Some(FailureCode::DriverError),
175                message: "AndroidDriver::find_norm_coord: tree bounds w/h ≤ 0 (unknown app frame)"
176                    .into(),
177                ..Default::default()
178            })),
179            Err(HostResolveError::CentroidOutOfFrame { .. }) => Ok(None),
180        }
181    }
182
183    async fn find_text_by_ocr(
184        &self,
185        text: &str,
186        locales: &[String],
187        recognition_level: &str,
188    ) -> Result<Option<OcrFrame>, ExpectationFailure> {
189        // v6.3 c2 — Google ML Kit Text Recognition (Latin script package).
190        // Locales + recognition_level args are iOS Apple Vision specific;
191        // Kotlin /find-text-by-ocr endpoint reads + ignores them today
192        // (ML Kit Latin handles ASCII/European text universally).
193        self.runner
194            .find_text_by_ocr(text, locales, recognition_level)
195            .await
196            .map_err(|e| {
197                ExpectationFailure::new(FailureInit {
198                    code: Some(FailureCode::DriverError),
199                    message: format!("AndroidDriver::find_text_by_ocr: {e}"),
200                    ..Default::default()
201                })
202            })
203    }
204
205    async fn system_popups(
206        &self,
207        include: Option<IncludeScope>,
208    ) -> Result<Vec<SystemPopup>, ExpectationFailure> {
209        // v6.3 c3 — Kotlin /system-popups walks UiAutomation.windows and
210        // classifies dialog-shaped TYPE_APPLICATION windows. Returns
211        // envelope {popups: [...]} per HttpRunnerClient deserialization.
212        self.runner.system_popups(include).await.map_err(|e| {
213            ExpectationFailure::new(FailureInit {
214                code: Some(FailureCode::DriverError),
215                message: format!("AndroidDriver::system_popups: {e}"),
216                ..Default::default()
217            })
218        })
219    }
220
221    async fn system_popup_action(
222        &self,
223        popup_id: &str,
224        button_id: &str,
225    ) -> Result<bool, ExpectationFailure> {
226        // v6.3 c3 — Kotlin /system-popup-action re-walks windows + finds
227        // popup by id + button by testTag-derived id + UiDevice.click on
228        // its bounding box center.
229        self.runner
230            .system_popup_action(popup_id, button_id)
231            .await
232            .map_err(|e| {
233                ExpectationFailure::new(FailureInit {
234                    code: Some(FailureCode::DriverError),
235                    message: format!("AndroidDriver::system_popup_action: {e}"),
236                    ..Default::default()
237                })
238            })
239    }
240
241    async fn wait_for(
242        &self,
243        selector: &Selector,
244        timeout: Duration,
245        include: Option<IncludeScope>,
246    ) -> Result<A11yNode, ExpectationFailure> {
247        // v6.0 c3c-iii — poll tree at 250ms cadence up to `timeout`, return
248        // matched node on first hit. Mirror of iOS wait_for semantics.
249        let start = std::time::Instant::now();
250        loop {
251            let tree = self.tree(include).await?;
252            if let Some(node) = resolve_selector(&tree, selector) {
253                return Ok(node.clone());
254            }
255            if start.elapsed() >= timeout {
256                return Err(ExpectationFailure::new(FailureInit {
257                    code: Some(FailureCode::ElementNotFound),
258                    message: format!(
259                        "AndroidDriver::wait_for timeout after {}ms: {}",
260                        timeout.as_millis(),
261                        describe_selector(selector)
262                    ),
263                    selector: Some(selector.clone()),
264                    ..Default::default()
265                }));
266            }
267            tokio::time::sleep(Duration::from_millis(250)).await;
268        }
269    }
270
271    // === Act ===
272
273    async fn tap(
274        &self,
275        selector: &Selector,
276        include: Option<IncludeScope>,
277    ) -> Result<(), ExpectationFailure> {
278        // v6.0 c3c-iii — host-resolve + tap_at_norm_coord (mirror IosDriver
279        // Path B). v6.0 c3c-v — refactored to shared helper.
280        let (nx, ny) = resolve_with_implicit_wait(self, selector, include).await?;
281        self.runner.tap_at_norm_coord(nx, ny).await.map_err(|e| {
282            ExpectationFailure::new(FailureInit {
283                code: Some(FailureCode::DriverError),
284                message: format!("AndroidDriver::tap: runner.tap_at_norm_coord: {e}"),
285                ..Default::default()
286            })
287        })
288    }
289
290    async fn tap_with_mode(
291        &self,
292        _selector: &Selector,
293        _mode: TapMode,
294        _include: Option<IncludeScope>,
295    ) -> Result<(), ExpectationFailure> {
296        Err(defer_err("tap_with_mode"))
297    }
298
299    async fn tap_at_norm_coord(&self, nx: f64, ny: f64) -> Result<(), ExpectationFailure> {
300        // v6.0 c3c-iii — direct passthru to Kotlin runner /tap-at-norm-coord.
301        self.runner.tap_at_norm_coord(nx, ny).await.map_err(|e| {
302            ExpectationFailure::new(FailureInit {
303                code: Some(FailureCode::DriverError),
304                message: format!("AndroidDriver::tap_at_norm_coord: {e}"),
305                ..Default::default()
306            })
307        })
308    }
309
310    async fn tap_by_id(&self, id: &str) -> Result<(), ExpectationFailure> {
311        // v6.0 c3c-v — POST /tap-by-id with {id}. Kotlin side finds
312        // UiObject2 via By.res(short or fully-qualified) and clicks.
313        let ok = self.runner.tap_by_id(id).await.map_err(|e| {
314            ExpectationFailure::new(FailureInit {
315                code: Some(FailureCode::DriverError),
316                message: format!("AndroidDriver::tap_by_id: {e}"),
317                ..Default::default()
318            })
319        })?;
320        if !ok {
321            return Err(ExpectationFailure::new(FailureInit {
322                code: Some(FailureCode::ElementNotFound),
323                message: format!("AndroidDriver::tap_by_id: no element with resource-id '{id}'"),
324                ..Default::default()
325            }));
326        }
327        Ok(())
328    }
329
330    async fn double_tap(
331        &self,
332        selector: &Selector,
333        include: Option<IncludeScope>,
334    ) -> Result<(), ExpectationFailure> {
335        // v6.0 c3c-v — host-resolve + /double-tap-at-norm-coord (Kotlin
336        // side dispatches 2 clicks 150ms apart).
337        let (nx, ny) = resolve_with_implicit_wait(self, selector, include).await?;
338        self.runner
339            .double_tap_at_norm_coord(nx, ny)
340            .await
341            .map_err(|e| {
342                ExpectationFailure::new(FailureInit {
343                    code: Some(FailureCode::DriverError),
344                    message: format!("AndroidDriver::double_tap: {e}"),
345                    ..Default::default()
346                })
347            })
348    }
349
350    async fn long_press(
351        &self,
352        selector: &Selector,
353        duration: Duration,
354        include: Option<IncludeScope>,
355    ) -> Result<(), ExpectationFailure> {
356        // v6.0 c3c-v — host-resolve + /long-press-at-norm-coord with
357        // duration. Kotlin uses UiDevice.swipe(x,y,x,y,steps) where
358        // steps = duration / 5ms to approximate a sustained press.
359        let (nx, ny) = resolve_with_implicit_wait(self, selector, include).await?;
360        let duration_ms = duration.as_millis() as u64;
361        self.runner
362            .long_press_at_norm_coord(nx, ny, duration_ms)
363            .await
364            .map_err(|e| {
365                ExpectationFailure::new(FailureInit {
366                    code: Some(FailureCode::DriverError),
367                    message: format!("AndroidDriver::long_press: {e}"),
368                    ..Default::default()
369                })
370            })
371    }
372
373    async fn fill(
374        &self,
375        selector: &Selector,
376        text: &str,
377        include: Option<IncludeScope>,
378    ) -> Result<(), ExpectationFailure> {
379        // v6.0 c3c-v — host-resolve → tap to focus → /input-text. Mirror
380        // of swift FlyingFox /fill semantics (selector resolves; client
381        // types text into focused field).
382        let (nx, ny) = resolve_with_implicit_wait(self, selector, include).await?;
383        self.runner.tap_at_norm_coord(nx, ny).await.map_err(|e| {
384            ExpectationFailure::new(FailureInit {
385                code: Some(FailureCode::DriverError),
386                message: format!("AndroidDriver::fill: focus tap failed: {e}"),
387                ..Default::default()
388            })
389        })?;
390        self.runner.input_text(text).await.map_err(|e| {
391            ExpectationFailure::new(FailureInit {
392                code: Some(FailureCode::DriverError),
393                message: format!("AndroidDriver::fill: input_text failed: {e}"),
394                ..Default::default()
395            })
396        })
397    }
398
399    async fn clear(
400        &self,
401        selector: &Selector,
402        include: Option<IncludeScope>,
403    ) -> Result<(), ExpectationFailure> {
404        // v6.0 c3c-v — host-resolve → tap to focus → press DELETE N times.
405        // No Kotlin /clear endpoint needed (avoids UiObject2 fragility);
406        // 50 BACKSPACE presses cover near all real-world input fields.
407        let (nx, ny) = resolve_with_implicit_wait(self, selector, include).await?;
408        self.runner.tap_at_norm_coord(nx, ny).await.map_err(|e| {
409            ExpectationFailure::new(FailureInit {
410                code: Some(FailureCode::DriverError),
411                message: format!("AndroidDriver::clear: focus tap failed: {e}"),
412                ..Default::default()
413            })
414        })?;
415        for _ in 0..50 {
416            self.runner.press_key(KeyName::Delete).await.map_err(|e| {
417                ExpectationFailure::new(FailureInit {
418                    code: Some(FailureCode::DriverError),
419                    message: format!("AndroidDriver::clear: delete press failed: {e}"),
420                    ..Default::default()
421                })
422            })?;
423        }
424        Ok(())
425    }
426
427    async fn press_key(&self, key: KeyName) -> Result<(), ExpectationFailure> {
428        // v6.0 c3c-iv — Kotlin /press-key maps smix KeyName → KeyEvent.KEYCODE_*.
429        self.runner.press_key(key).await.map(|_| ()).map_err(|e| {
430            ExpectationFailure::new(FailureInit {
431                code: Some(FailureCode::DriverError),
432                message: format!("AndroidDriver::press_key: {e}"),
433                ..Default::default()
434            })
435        })
436    }
437
438    async fn scroll(
439        &self,
440        selector: &Selector,
441        direction: SwipeDirection,
442    ) -> Result<(), ExpectationFailure> {
443        // v6.0 c3c-iv — host-side scroll-until-visible loop. /tree +
444        // /swipe-once primitives — same pattern as iOS.
445        let start = std::time::Instant::now();
446        let timeout = Duration::from_secs(20);
447        const MAX_SWIPES: u32 = 30;
448        for _ in 0..=MAX_SWIPES {
449            let tree = self.tree(None).await?;
450            if resolve_selector(&tree, selector).is_some() {
451                return Ok(());
452            }
453            if start.elapsed() > timeout {
454                break;
455            }
456            self.swipe_once(direction).await?;
457        }
458        Err(ExpectationFailure::new(FailureInit {
459            code: Some(FailureCode::ElementNotFound),
460            message: format!(
461                "AndroidDriver::scroll: element not visible after {} swipes: {}",
462                MAX_SWIPES,
463                describe_selector(selector)
464            ),
465            selector: Some(selector.clone()),
466            ..Default::default()
467        }))
468    }
469
470    async fn swipe_once(&self, direction: SwipeDirection) -> Result<(), ExpectationFailure> {
471        self.runner.swipe_once(direction).await.map_err(|e| {
472            ExpectationFailure::new(FailureInit {
473                code: Some(FailureCode::DriverError),
474                message: format!("AndroidDriver::swipe_once: {e}"),
475                ..Default::default()
476            })
477        })
478    }
479
480    async fn swipe_at_norm_coord(
481        &self,
482        from: (f64, f64),
483        to: (f64, f64),
484    ) -> Result<(), ExpectationFailure> {
485        self.runner
486            .swipe_at_norm_coord(from, to)
487            .await
488            .map_err(|e| {
489                ExpectationFailure::new(FailureInit {
490                    code: Some(FailureCode::DriverError),
491                    message: format!("AndroidDriver::swipe_at_norm_coord: {e}"),
492                    ..Default::default()
493                })
494            })
495    }
496
497    async fn hide_keyboard(&self) -> Result<(), ExpectationFailure> {
498        self.runner.hide_keyboard().await.map_err(|e| {
499            ExpectationFailure::new(FailureInit {
500                code: Some(FailureCode::DriverError),
501                message: format!("AndroidDriver::hide_keyboard: {e}"),
502                ..Default::default()
503            })
504        })
505    }
506
507    async fn back(&self) -> Result<(), ExpectationFailure> {
508        // v6.0 c3c-iv — Kotlin /back → UiDevice.pressBack (KEYCODE_BACK).
509        self.runner.back().await.map_err(|e| {
510            ExpectationFailure::new(FailureInit {
511                code: Some(FailureCode::DriverError),
512                message: format!("AndroidDriver::back: {e}"),
513                ..Default::default()
514            })
515        })
516    }
517
518    async fn set_orientation(&self, orientation: Orientation) -> Result<(), ExpectationFailure> {
519        self.runner
520            .set_orientation(orientation.as_wire())
521            .await
522            .map_err(|e| {
523                ExpectationFailure::new(FailureInit {
524                    code: Some(FailureCode::DriverError),
525                    message: format!("AndroidDriver::set_orientation: {e}"),
526                    ..Default::default()
527                })
528            })
529    }
530
531    async fn foreground(&self, bundle_id: &str) -> Result<(), ExpectationFailure> {
532        // v6.3 c1 — Kotlin /foreground runs `am start --activity-single-top
533        // -n pkg/.MainActivity` (mirror iOS XCUIDevice activate semantic
534        // without launching a new instance).
535        self.runner.foreground(bundle_id).await.map_err(|e| {
536            ExpectationFailure::new(FailureInit {
537                code: Some(FailureCode::DriverError),
538                message: format!("AndroidDriver::foreground: {e}"),
539                ..Default::default()
540            })
541        })
542    }
543
544    async fn webview_eval(&self, js: &str) -> Result<serde_json::Value, ExpectationFailure> {
545        // v6.5 c1 — runner /webview-eval proxies HTTP to fixture's shim
546        // server (WebViewEvalServer on :28081, started by MainActivity
547        // onCreate). evaluateJavascript callback result is a JSON-encoded
548        // string (e.g. "null", "\"hello\"", "42") passed verbatim.
549        self.runner.webview_eval(js).await.map_err(|e| {
550            ExpectationFailure::new(FailureInit {
551                code: Some(FailureCode::DriverError),
552                message: format!("AndroidDriver::webview_eval: {e}"),
553                ..Default::default()
554            })
555        })
556    }
557}