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}