Skip to main content

smix_sdk/
android_device.rs

1//! Android `DeviceControl` impl backed by `smix_adb::AdbClient`.
2//!
3//! Mirror of [`crate::IosDeviceControl`]. Wraps `adb` shell commands;
4//! routes the cross-platform `Permission` enum to `android.permission.*`
5//! grant/revoke via `pm`.
6
7use async_trait::async_trait;
8use std::path::Path;
9use tokio::sync::Mutex;
10
11use smix_adb::{AdbClient, AdbError};
12use smix_driver::Platform;
13use smix_simctl::DeviceControlError;
14
15use crate::PermissionAction;
16use crate::device_control::{DeviceControl, Permission};
17
18/// What `simctl location start` uses when `--speed` is absent, per its own
19/// help text. Mirrored so a flow that omits the speed travels at the same
20/// rate on both platforms.
21const SIMCTL_DEFAULT_SPEED_MPS: f64 = 20.0;
22
23/// How often the waypoint loop posts a position. A GPS receiver reports at
24/// 1 Hz, and the emulator's own `mFixInterval` is 1000ms, so a faster tick
25/// would only queue fixes the framework reports at this rate anyway.
26const GEO_FIX_INTERVAL: std::time::Duration = std::time::Duration::from_secs(1);
27
28/// Great-circle distance in metres. Used only to turn a leg into a
29/// duration, which is why the earth is a sphere here.
30fn haversine_metres(from: (f64, f64), to: (f64, f64)) -> f64 {
31    const EARTH_RADIUS_M: f64 = 6_371_000.0;
32    let (lat1, lat2) = (from.0.to_radians(), to.0.to_radians());
33    let d_lat = lat2 - lat1;
34    let d_lon = (to.1 - from.1).to_radians();
35    let a = (d_lat / 2.0).sin().powi(2) + lat1.cos() * lat2.cos() * (d_lon / 2.0).sin().powi(2);
36    2.0 * EARTH_RADIUS_M * a.sqrt().asin()
37}
38
39/// An `adb shell screenrecord` in flight.
40///
41/// `stop_recording` takes no serial and no path — the trait shape assumes the
42/// implementation remembers. iOS remembers a child process; here the child is
43/// on the *device*, writing to the device's filesystem, so stopping also means
44/// knowing where to pull the file from and where the caller wanted it.
45struct AndroidRecording {
46    /// The local `adb` child. Killing it sends SIGINT down to screenrecord,
47    /// which is what makes it flush a playable mp4.
48    child: tokio::process::Child,
49    serial: String,
50    /// Where screenrecord is writing, on the device.
51    remote_path: String,
52    /// Where the caller asked for the file, on this machine.
53    local_path: std::path::PathBuf,
54}
55
56/// Android `DeviceControl`, over `adb`.
57///
58/// Where a capability has no Android primitive, the method says so and
59/// names the alternative rather than succeeding quietly. `smix-adb`'s
60/// errors are translated by `adb_to_simctl_err`.
61pub struct AndroidDeviceControl {
62    client: AdbClient,
63    /// The `adb shell screenrecord` in flight, if any.
64    recording: Mutex<Option<AndroidRecording>>,
65    /// The waypoint loop in flight, if any. iOS hands the route to
66    /// CoreSimulator and forgets it; here nothing on the device walks a
67    /// route, so this process does the walking.
68    travel: Mutex<Option<tokio::task::JoinHandle<()>>>,
69}
70
71impl Default for AndroidDeviceControl {
72    fn default() -> Self {
73        Self::new()
74    }
75}
76
77impl AndroidDeviceControl {
78    #[must_use]
79    pub fn new() -> Self {
80        AndroidDeviceControl {
81            client: AdbClient::new(),
82            recording: Mutex::new(None),
83            travel: Mutex::new(None),
84        }
85    }
86
87    /// The activity `am start -n` should name for this package.
88    ///
89    /// Every Android launch used to be `<pkg>/.MainActivity`, which is
90    /// what a scaffolded app is called and what almost nothing else is:
91    /// an AOSP app, an app whose entry point was renamed, or anything
92    /// generated by a framework with its own convention would simply
93    /// not start, and the failure said nothing about why.
94    ///
95    /// The package manager already knows. `cmd package resolve-activity
96    /// --brief` prints the component on its last line; the older
97    /// `--brief` output is `<pkg>/<activity>`, which is the form
98    /// `start_activity` wants after the package prefix is stripped.
99    ///
100    /// Falls back to `.MainActivity` when the query fails rather than
101    /// erroring: that is the behaviour every caller had until now, so a
102    /// device that answers nothing is no worse off than before.
103    async fn entry_point(&self, serial: &str, bundle_id: &str) -> String {
104        const CONVENTION: &str = ".MainActivity";
105        let out = self
106            .client
107            .shell(
108                serial,
109                &[
110                    "cmd",
111                    "package",
112                    "resolve-activity",
113                    "--brief",
114                    "-c",
115                    "android.intent.category.LAUNCHER",
116                    bundle_id,
117                ],
118            )
119            .await;
120        let Ok(text) = out else {
121            return CONVENTION.to_string();
122        };
123        text.lines()
124            .rev()
125            .map(str::trim)
126            .find(|l| l.starts_with(&format!("{bundle_id}/")))
127            .and_then(|l| l.strip_prefix(bundle_id))
128            .map(|rest| rest.trim_start_matches('/').to_string())
129            .map(|a| {
130                if a.starts_with('.') {
131                    a
132                } else {
133                    format!(".{a}")
134                }
135            })
136            .filter(|a| a.len() > 1)
137            .unwrap_or_else(|| CONVENTION.to_string())
138    }
139
140    /// Construct with an existing `AdbClient` (tests / non-PATH adb).
141    #[must_use]
142    pub fn with_client(client: AdbClient) -> Self {
143        AndroidDeviceControl {
144            client,
145            recording: Mutex::new(None),
146            travel: Mutex::new(None),
147        }
148    }
149}
150
151/// Translate an adb failure into the shared device-control error, keeping
152/// the Android detail in the message.
153///
154/// The subcommand is prefixed with `adb` so the message names the tool that
155/// actually ran. `DeviceControlError` is shared by both platforms, and a reader
156/// chasing an Android failure should not be pointed at Xcode.
157fn adb_to_simctl_err(e: AdbError, subcommand: &str) -> DeviceControlError {
158    let subcommand = &format!("adb {subcommand}");
159    match e {
160        AdbError::BinaryNotFound => DeviceControlError::non_zero_exit(
161            subcommand,
162            -1,
163            "adb binary not found in PATH; install Android SDK platform-tools",
164        ),
165        AdbError::Spawn(io) => {
166            DeviceControlError::non_zero_exit(subcommand, -1, format!("adb spawn failed: {io}"))
167        }
168        AdbError::NonZeroExit {
169            subcommand: sub,
170            code,
171            stderr,
172            serial,
173        } => DeviceControlError::non_zero_exit(
174            match serial {
175                Some(s) => format!("adb -s {s} {sub}"),
176                None => format!("adb {sub}"),
177            },
178            code,
179            stderr,
180        ),
181        AdbError::Malformed {
182            subcommand: sub,
183            detail,
184        } => DeviceControlError::Malformed {
185            subcommand: sub,
186            detail,
187        },
188    }
189}
190
191/// The animation settings smix zeroes on Android, in the order it
192/// writes them.
193///
194/// All three matter and they are separately settable: window covers
195/// activity windows, transition covers activity-to-activity, animator
196/// covers `ObjectAnimator` — a screen can sit still by two of these
197/// and keep moving by the third.
198pub const ANDROID_ANIMATION_SCALES: [&str; 3] = [
199    "window_animation_scale",
200    "transition_animation_scale",
201    "animator_duration_scale",
202];
203
204/// Judge what a device gave back after being told to stop animating.
205///
206/// Takes `(setting, value-read-back)` pairs and returns the ones that
207/// did not take. A device is not trusted to have honoured a write:
208/// `simctl ui appearance` is documented per-simulator and behaves
209/// globally, and this project has been bitten by exactly that shape
210/// before. A switch that reports success while the device kept its
211/// animations is worse than no switch, because the run that follows
212/// looks deterministic and is not.
213///
214/// Two vocabularies, because two platforms: an Android scale is off at
215/// `0`, however it is spelled (`0`, `0.0`); iOS Reduce Motion is on at
216/// `1`. Anything else — including the empty string and `null` that
217/// `settings get` prints for a key never written — is a failure that
218/// names itself.
219pub fn animation_settings_verified(read_back: &[(&str, &str)]) -> Result<(), Vec<String>> {
220    let mut bad = Vec::new();
221    for (setting, value) in read_back {
222        let v = value.trim();
223        let ok = if setting.starts_with("UIAccessibility") {
224            v == "1"
225        } else {
226            v.parse::<f64>().is_ok_and(|n| n == 0.0)
227        };
228        if !ok {
229            let seen = if v.is_empty() { "<empty>" } else { v };
230            bad.push(format!(
231                "{setting} read back as {seen}, so the device did not take it"
232            ));
233        }
234    }
235    if bad.is_empty() { Ok(()) } else { Err(bad) }
236}
237
238/// Pull the activity out of `cmd package resolve-activity --brief`.
239///
240/// Two lines on a hit — a `priority=… isDefault=…` header, then the
241/// component as `<pkg>/<activity>` — and the single line
242/// `No activity found` on a miss. Captured from an API 33 emulator on
243/// 2026-07-22; the fixtures under `tests/fixtures/` are that output
244/// verbatim, so this stays checkable without a device.
245///
246/// `None` means "the device did not name one", which the caller reads
247/// as the old `.MainActivity` convention: a shell whose output format
248/// this does not recognise leaves every caller exactly where it was
249/// before the query existed, rather than launching something wrong.
250///
251/// Separate from the method that runs the shell, because the shell is
252/// the part needing a device and this is the part that can be wrong.
253/// The activity comes back relative (`.Settings`) because
254/// `AdbClient::start_activity` prefixes the package itself; returning
255/// the fully-qualified form would produce `<pkg>/<pkg>.Settings`.
256#[must_use]
257pub fn parse_resolved_activity(text: &str, bundle_id: &str) -> Option<String> {
258    let prefix = format!("{bundle_id}/");
259    // Forward, not reversed. The output's first line is a
260    // `priority=… isDefault=…` header and the second is the component,
261    // so a reversed scan looks like it is skipping the header — but it
262    // is the prefix match that skips it, and reversing was a guess
263    // about output shapes never observed. Deleting `.rev()` changed no
264    // test, which is what said so.
265    text.lines()
266        .map(str::trim)
267        .find(|l| l.starts_with(&prefix))
268        .and_then(|l| l.strip_prefix(bundle_id))
269        .map(|rest| rest.trim_start_matches('/').to_string())
270        .map(|a| {
271            if let Some(tail) = a.strip_prefix(&format!("{bundle_id}.")) {
272                format!(".{tail}")
273            } else if a.starts_with('.') {
274                a
275            } else {
276                format!(".{a}")
277            }
278        })
279        .filter(|a| a.len() > 1)
280}
281
282#[async_trait]
283impl DeviceControl for AndroidDeviceControl {
284    fn platform(&self) -> Platform {
285        Platform::Android
286    }
287
288    // === Lifecycle ===
289
290    async fn launch(&self, serial: &str, bundle_id: &str) -> Result<u32, DeviceControlError> {
291        let activity = self.entry_point(serial, bundle_id).await;
292        self.client
293            .start_activity(serial, bundle_id, &activity, &[])
294            .await
295            .map_err(|e| adb_to_simctl_err(e, "shell am start"))?;
296        Ok(0)
297    }
298
299    async fn launch_with_args(
300        &self,
301        serial: &str,
302        bundle_id: &str,
303        args: &[String],
304        activity: Option<&str>,
305    ) -> Result<u32, DeviceControlError> {
306        let extras: Vec<(String, String)> = args
307            .chunks(2)
308            .filter_map(|chunk| {
309                if chunk.len() == 2 {
310                    Some((chunk[0].clone(), chunk[1].clone()))
311                } else {
312                    None
313                }
314            })
315            .collect();
316        let extra_refs: Vec<(&str, &str)> = extras
317            .iter()
318            .map(|(k, v)| (k.as_str(), v.as_str()))
319            .collect();
320        let activity = match activity {
321            Some(a) => a.to_string(),
322            None => self.entry_point(serial, bundle_id).await,
323        };
324        self.client
325            .start_activity(serial, bundle_id, &activity, &extra_refs)
326            .await
327            .map_err(|e| adb_to_simctl_err(e, "shell am start"))?;
328        Ok(0)
329    }
330
331    async fn set_animations_quiet(
332        &self,
333        serial: &str,
334        quiet: bool,
335    ) -> Result<(), DeviceControlError> {
336        // 1 is the platform default; 0 is off. Restoring means writing
337        // 1 back rather than deleting the key, because a deleted key
338        // reads as absent and absent is not a value the device honours.
339        let target = if quiet { "0" } else { "1" };
340        for setting in ANDROID_ANIMATION_SCALES {
341            self.client
342                .shell(serial, &["settings", "put", "global", setting, target])
343                .await
344                .map_err(|e| adb_to_simctl_err(e, "shell settings put"))?;
345        }
346        if !quiet {
347            return Ok(());
348        }
349        let mut read_back = Vec::new();
350        for setting in ANDROID_ANIMATION_SCALES {
351            let value = self
352                .client
353                .shell(serial, &["settings", "get", "global", setting])
354                .await
355                .map_err(|e| adb_to_simctl_err(e, "shell settings get"))?;
356            read_back.push((setting, value.trim().to_string()));
357        }
358        let pairs: Vec<(&str, &str)> = read_back.iter().map(|(k, v)| (*k, v.as_str())).collect();
359        animation_settings_verified(&pairs).map_err(|bad| {
360            DeviceControlError::non_zero_exit(
361                "shell settings get",
362                1,
363                format!(
364                    "animations were not quietened on {serial}: {}",
365                    bad.join("; ")
366                ),
367            )
368        })
369    }
370
371    async fn terminate(&self, serial: &str, bundle_id: &str) -> Result<(), DeviceControlError> {
372        self.client
373            .force_stop(serial, bundle_id)
374            .await
375            .map_err(|e| adb_to_simctl_err(e, "shell am force-stop"))
376    }
377
378    async fn install(&self, serial: &str, app_path: &str) -> Result<(), DeviceControlError> {
379        self.client
380            .install(serial, Path::new(app_path))
381            .await
382            .map_err(|e| adb_to_simctl_err(e, "install"))
383    }
384
385    async fn uninstall(&self, serial: &str, bundle_id: &str) -> Result<(), DeviceControlError> {
386        self.client
387            .uninstall(serial, bundle_id)
388            .await
389            .map_err(|e| adb_to_simctl_err(e, "uninstall"))
390    }
391
392    async fn keychain_reset(&self, _serial: &str) -> Result<(), DeviceControlError> {
393        // This used to return Ok(()) and call itself a no-op "matching the
394        // expectation that Android silently no-ops rather than crashing".
395        // A flow that clears the keychain does it so the next step meets a
396        // signed-out app; succeeding without doing it hands that step a
397        // logged-in one and blames the step.
398        Err(DeviceControlError::non_zero_exit(
399            "keychain_reset",
400            -1,
401            "clearKeychain has no Android equivalent: credentials live in each app's own \
402             KeyStore, which the host cannot reach. Use clearAppData to wipe the app's \
403             state, or have the app expose a sign-out path.",
404        ))
405    }
406
407    async fn privacy_reset_all(
408        &self,
409        serial: &str,
410        bundle_id: &str,
411    ) -> Result<(), DeviceControlError> {
412        // `pm reset-permissions` reverts every app on the device, including
413        // whatever the runner was granted to do its job, so the grants are
414        // read back and dropped one at a time.
415        let granted = self
416            .client
417            .runtime_permissions_granted(serial, bundle_id)
418            .await
419            .map_err(|e| adb_to_simctl_err(e, "shell dumpsys package"))?;
420        for permission in granted {
421            self.client
422                .pm_revoke(serial, bundle_id, &permission)
423                .await
424                .map_err(|e| adb_to_simctl_err(e, "shell pm revoke"))?;
425        }
426        Ok(())
427    }
428
429    async fn clear_app_sandbox(
430        &self,
431        serial: &str,
432        bundle_id: &str,
433    ) -> Result<(), DeviceControlError> {
434        // App data is app-private, so the host's only way in is `pm clear`,
435        // which also reverts the runtime permissions. iOS can wipe the
436        // sandbox and leave privacy alone; here the two come together, and
437        // the launch-fresh plan resets privacy anyway.
438        self.client
439            .pm_clear(serial, bundle_id)
440            .await
441            .map_err(|e| adb_to_simctl_err(e, "shell pm clear"))
442    }
443
444    // === Lifecycle ancillary ===
445
446    async fn open_url(&self, serial: &str, url: &str) -> Result<(), DeviceControlError> {
447        self.client
448            .shell(
449                serial,
450                &["am", "start", "-a", "android.intent.action.VIEW", "-d", url],
451            )
452            .await
453            .map(|_| ())
454            .map_err(|e| adb_to_simctl_err(e, "shell am start -a VIEW"))
455    }
456
457    async fn send_push(
458        &self,
459        _serial: &str,
460        _bundle_id: &str,
461        _apns_json_path: &str,
462    ) -> Result<(), DeviceControlError> {
463        // Android push = FCM not APNs. Cross-platform yaml `sendPush:` on
464        // Android requires FCM credentials + Firebase project setup —
465        // deferred. Surface an explicit error so yaml authors know it
466        // is not silently no-op.
467        Err(DeviceControlError::non_zero_exit(
468            "send_push",
469            -1,
470            "Android FCM push not implemented (cross-platform yaml `sendPush:` is iOS APNs only)",
471        ))
472    }
473
474    async fn screenshot(&self, serial: &str) -> Result<Vec<u8>, DeviceControlError> {
475        self.client
476            .screenshot(serial)
477            .await
478            .map_err(|e| adb_to_simctl_err(e, "shell screencap"))
479    }
480
481    // === Clipboard / Media / Location ===
482
483    // The clipboard is out of reach on Android, and not for want of wiring.
484    //
485    // Since Android 10, ClipboardService serves only the focused app.
486    // Measured on SDK 33, from the runner's own instrumentation process:
487    //
488    //   E ClipboardService: Denying clipboard access to dev.smix.runner.test,
489    //   application is not in focus nor is it a system service for user 0
490    //
491    // The runner can never satisfy that. Being focused would make it the
492    // foreground app, and then it could not drive the app under test — which
493    // is the entire job. `appops set … READ_CLIPBOARD allow` does not lift it;
494    // the check is on focus, not on an app-op. adb is no better: `cmd
495    // clipboard` has no shell implementation on SDK 33, and shell is not
496    // focused either.
497    //
498    // iOS has no equivalent problem because `simctl pasteboard` is a host
499    // privilege the simulator grants from outside the device. Android's
500    // emulator offers nothing like it.
501    //
502    // So this is a platform limit, and the honest thing is to say so rather
503    // than keep a skeleton that reads as unfinished work.
504    async fn pasteboard_set(&self, _serial: &str, _text: &str) -> Result<(), DeviceControlError> {
505        Err(DeviceControlError::non_zero_exit(
506            "pasteboard_set",
507            -1,
508            "Android does not let a test runner write the clipboard: since Android 10 the \
509             clipboard serves only the focused app, and the runner cannot be focused while \
510             driving your app. Pass the text via inputText, or have the app expose it another way.",
511        ))
512    }
513
514    async fn pasteboard_get(&self, _serial: &str) -> Result<String, DeviceControlError> {
515        Err(DeviceControlError::non_zero_exit(
516            "pasteboard_get",
517            -1,
518            "Android does not let a test runner read the clipboard: since Android 10 the \
519             clipboard serves only the focused app, and the runner cannot be focused while \
520             driving your app. Assert on what the app renders instead.",
521        ))
522    }
523
524    async fn add_media(&self, serial: &str, paths: &[String]) -> Result<(), DeviceControlError> {
525        if paths.is_empty() {
526            return Err(DeviceControlError::Malformed {
527                subcommand: "add_media".into(),
528                detail: "no paths supplied".into(),
529            });
530        }
531        for p in paths {
532            let local = Path::new(p);
533            let name = local.file_name().and_then(|n| n.to_str()).ok_or_else(|| {
534                DeviceControlError::Malformed {
535                    subcommand: "add_media".into(),
536                    detail: format!("path has no file name: {p}"),
537                }
538            })?;
539            let remote = format!("/sdcard/Pictures/{name}");
540            self.client
541                .push(serial, local, &remote)
542                .await
543                .map_err(|e| adb_to_simctl_err(e, "push"))?;
544            // Landing the bytes is not enough: MediaStore indexes the gallery,
545            // and an unindexed file is invisible to the app under test. The
546            // scan broadcast is what makes it show up.
547            self.client
548                .broadcast(
549                    serial,
550                    "android.intent.action.MEDIA_SCANNER_SCAN_FILE",
551                    Some(&format!("file://{remote}")),
552                )
553                .await
554                .map_err(|e| adb_to_simctl_err(e, "media scan"))?;
555        }
556        Ok(())
557    }
558
559    async fn location_set(
560        &self,
561        serial: &str,
562        lat: f64,
563        lon: f64,
564    ) -> Result<(), DeviceControlError> {
565        // The emulator console, not the device shell. This called
566        // `shell(["emu", …])` and so ran `adb shell emu geo fix`, which asks
567        // the device for a program named `emu` — `sh: emu: inaccessible or
568        // not found`, exit 127. The verb never worked. Note the argument
569        // order: longitude first.
570        self.client
571            .emu(serial, &["geo", "fix", &lon.to_string(), &lat.to_string()])
572            .await
573            .map(|_| ())
574            .map_err(|e| adb_to_simctl_err(e, "emu geo fix"))
575    }
576
577    async fn location_start(
578        &self,
579        serial: &str,
580        points: &[(f64, f64)],
581        speed_mps: Option<f64>,
582    ) -> Result<(), DeviceControlError> {
583        // The emulator console has no route: `geo` takes one position
584        // (`fix`), an NMEA sentence, or a GNSS sentence — no waypoint list,
585        // whatever a comment here once claimed about `geo gpx`. `automation
586        // play` replays a macro, but only one the emulator itself recorded.
587        //
588        // So the interpolation simctl does inside CoreSimulator happens here
589        // instead, one `geo fix` per tick. Same contract as iOS: the route
590        // starts and the call returns, so the loop outlives it.
591        if points.len() < 2 {
592            return Err(DeviceControlError::Malformed {
593                subcommand: "location_start".into(),
594                detail: format!("requires ≥2 waypoints, got {}", points.len()),
595            });
596        }
597        let speed = speed_mps.unwrap_or(SIMCTL_DEFAULT_SPEED_MPS);
598        if !(speed.is_finite() && speed > 0.0) {
599            return Err(DeviceControlError::Malformed {
600                subcommand: "location_start".into(),
601                detail: format!("speed must be finite and positive, got {speed}"),
602            });
603        }
604
605        let route: Vec<(f64, f64)> = points.to_vec();
606        let client = self.client.clone();
607        let serial = serial.to_string();
608
609        // Whoever asked last is where the device is going: stop the old walk
610        // before starting the new one, or the two take turns pointing the
611        // device at their own routes.
612        let mut slot = self.travel.lock().await;
613        if let Some(previous) = slot.take() {
614            previous.abort();
615        }
616        let handle = tokio::spawn(async move {
617            for pair in route.windows(2) {
618                let (from, to) = (pair[0], pair[1]);
619                let seconds = haversine_metres(from, to) / speed;
620                let ticks = (seconds / GEO_FIX_INTERVAL.as_secs_f64()).round() as u64;
621                for tick in 1..=ticks.max(1) {
622                    #[expect(
623                        clippy::cast_precision_loss,
624                        reason = "a route long enough to lose f64 precision here would \
625                                  take longer to walk than the emulator stays up"
626                    )]
627                    let t = tick as f64 / ticks.max(1) as f64;
628                    let lat = from.0 + (to.0 - from.0) * t;
629                    let lon = from.1 + (to.1 - from.1) * t;
630                    if client
631                        .emu(&serial, &["geo", "fix", &lon.to_string(), &lat.to_string()])
632                        .await
633                        .is_err()
634                    {
635                        // The device is gone, or the console closed. There is
636                        // no one to tell — the caller returned long ago.
637                        return;
638                    }
639                    tokio::time::sleep(GEO_FIX_INTERVAL).await;
640                }
641            }
642        });
643
644        *slot = Some(handle);
645        Ok(())
646    }
647
648    // === Permissions ===
649
650    async fn set_permission(
651        &self,
652        serial: &str,
653        bundle_id: &str,
654        permission: Permission,
655        action: PermissionAction,
656    ) -> Result<(), DeviceControlError> {
657        let Some(android_perm) = permission.to_android() else {
658            // iOS-only permission on Android → no-op (cross-platform yaml
659            // friendly; matches IosDeviceControl behavior for Storage on iOS).
660            return Ok(());
661        };
662        match action {
663            PermissionAction::Grant => self
664                .client
665                .pm_grant(serial, bundle_id, android_perm)
666                .await
667                .map_err(|e| adb_to_simctl_err(e, "shell pm grant")),
668            PermissionAction::Revoke => self
669                .client
670                .pm_revoke(serial, bundle_id, android_perm)
671                .await
672                .map_err(|e| adb_to_simctl_err(e, "shell pm revoke")),
673            PermissionAction::Reset => {
674                // Android has no direct `pm reset <perm>` per-package; reset
675                // = uninstall+reinstall OR `pm reset-permissions`. For
676                // cross-platform yaml `permissions: { camera: unset }`
677                // semantic, treat as revoke (closest match).
678                self.client
679                    .pm_revoke(serial, bundle_id, android_perm)
680                    .await
681                    .map_err(|e| adb_to_simctl_err(e, "shell pm revoke (Reset alias)"))
682            }
683        }
684    }
685
686    // === Recording ===
687
688    /// Start `adb shell screenrecord`.
689    ///
690    /// Unlike iOS, the recorder runs on the device and writes there, so the
691    /// file only reaches `output_path` when [`Self::stop_recording`] pulls it.
692    ///
693    /// Android caps this at **180 seconds** — `screenrecord --time-limit`
694    /// documents 180 as "Default / maximum", a ceiling rather than a default
695    /// that can be raised. `simctl recordVideo` has no such limit. Past the
696    /// cap the recorder exits on its own, and stop_recording pulls whatever
697    /// it managed to write.
698    async fn start_recording(
699        &self,
700        serial: &str,
701        output_path: &Path,
702    ) -> Result<(), DeviceControlError> {
703        let mut guard = self.recording.lock().await;
704        if guard.is_some() {
705            return Err(DeviceControlError::non_zero_exit(
706                "screenrecord",
707                -1,
708                "a recording is already in progress (call stop_recording first)",
709            ));
710        }
711        let name = output_path
712            .file_name()
713            .and_then(|n| n.to_str())
714            .unwrap_or("smix-recording.mp4");
715        let remote_path = format!("/sdcard/{name}");
716        // A leftover from a previous run would be pulled instead of this one
717        // if screenrecord failed to start.
718        let _ = self.client.shell(serial, &["rm", "-f", &remote_path]).await;
719        let child = self
720            .client
721            .spawn_shell(serial, &["screenrecord", &remote_path])
722            .map_err(|e| adb_to_simctl_err(e, "screenrecord"))?;
723        *guard = Some(AndroidRecording {
724            child,
725            serial: serial.to_string(),
726            remote_path,
727            local_path: output_path.to_path_buf(),
728        });
729        Ok(())
730    }
731
732    async fn stop_recording(&self) -> Result<(), DeviceControlError> {
733        let mut guard = self.recording.lock().await;
734        let mut rec = guard.take().ok_or_else(|| {
735            DeviceControlError::non_zero_exit(
736                "screenrecord",
737                -1,
738                "no recording in progress (call start_recording first)",
739            )
740        })?;
741
742        // SIGINT, not kill: screenrecord writes the mp4's moov atom on
743        // interrupt, and without it the file is unplayable. Same reason
744        // simctl's recordVideo stop signals rather than terminates.
745        if let Some(pid) = rec.child.id() {
746            // SAFETY: a thin POSIX syscall wrapper. The pid belongs to this
747            // Child, so it cannot have been recycled, and SIGINT is
748            // signal-safe.
749            unsafe { libc::kill(pid as i32, libc::SIGINT) };
750        }
751        let _ = rec.child.wait().await;
752        // The device-side recorder receives the signal through adb and needs
753        // a moment to finish writing before the file is worth pulling.
754        tokio::time::sleep(std::time::Duration::from_millis(500)).await;
755
756        self.client
757            .pull(&rec.serial, &rec.remote_path, &rec.local_path)
758            .await
759            .map_err(|e| adb_to_simctl_err(e, "pull recording"))?;
760        // Best-effort: a leftover on the device must not fail the stop.
761        let _ = self
762            .client
763            .shell(&rec.serial, &["rm", "-f", &rec.remote_path])
764            .await;
765        Ok(())
766    }
767}
768
769#[cfg(test)]
770mod tests {
771    use super::*;
772
773    /// Spans whose length follows from the sphere itself, so a wrong
774    /// formula cannot agree with them by construction.
775    #[test]
776    fn haversine_matches_the_geometry() {
777        let r = 6_371_000.0_f64;
778
779        // A degree of longitude on the equator: one 360th of the great
780        // circle.
781        let d = haversine_metres((0.0, 0.0), (0.0, 1.0));
782        assert!((d - r * std::f64::consts::PI / 180.0).abs() < 1.0, "{d}");
783
784        // Equator to pole: a quarter of the way round.
785        let d = haversine_metres((0.0, 0.0), (90.0, 0.0));
786        assert!((d - r * std::f64::consts::FRAC_PI_2).abs() < 1.0, "{d}");
787
788        // Antipodes: half. The far side is where a formula built on
789        // sin(Δ/2) rather than cos(Δ) keeps its accuracy.
790        let d = haversine_metres((0.0, 0.0), (0.0, 180.0));
791        assert!((d - r * std::f64::consts::PI).abs() < 1.0, "{d}");
792
793        // A meridian degree is a longitude degree on the equator, since
794        // both are the same great circle.
795        let d = haversine_metres((0.0, 0.0), (1.0, 0.0));
796        assert!((d - r * std::f64::consts::PI / 180.0).abs() < 1.0, "{d}");
797    }
798
799    /// One published distance, to catch a formula that is self-consistent
800    /// but not about the earth.
801    #[test]
802    fn haversine_matches_a_known_route() {
803        // Paris to New York, published as 5837 km.
804        let d = haversine_metres((48.856_614, 2.352_222), (40.712_776, -74.005_974));
805        assert!((d - 5_837_000.0).abs() < 20_000.0, "{d}");
806    }
807
808    #[test]
809    fn a_leg_of_no_length_takes_no_time() {
810        let here = (35.681_236, 139.767_125);
811        assert!(haversine_metres(here, here) < 0.001);
812    }
813
814    #[tokio::test]
815    async fn a_route_needs_somewhere_to_go() {
816        let device = AndroidDeviceControl::new();
817        let err = device
818            .location_start("emulator-5554", &[(35.0, 139.0)], None)
819            .await
820            .expect_err("one waypoint is not a route");
821        assert!(err.to_string().contains("≥2 waypoints"), "{err}");
822    }
823
824    #[tokio::test]
825    async fn a_route_walked_at_zero_speed_never_arrives() {
826        let device = AndroidDeviceControl::new();
827        let err = device
828            .location_start("emulator-5554", &[(35.0, 139.0), (36.0, 140.0)], Some(0.0))
829            .await
830            .expect_err("zero speed would divide by zero and tick forever");
831        assert!(err.to_string().contains("positive"), "{err}");
832    }
833}