use async_trait::async_trait;
use std::path::Path;
use tokio::sync::Mutex;
use smix_adb::{AdbClient, AdbError};
use smix_driver::Platform;
use smix_simctl::DeviceControlError;
use crate::PermissionAction;
use crate::device_control::{DeviceControl, Permission};
const SIMCTL_DEFAULT_SPEED_MPS: f64 = 20.0;
const GEO_FIX_INTERVAL: std::time::Duration = std::time::Duration::from_secs(1);
fn haversine_metres(from: (f64, f64), to: (f64, f64)) -> f64 {
const EARTH_RADIUS_M: f64 = 6_371_000.0;
let (lat1, lat2) = (from.0.to_radians(), to.0.to_radians());
let d_lat = lat2 - lat1;
let d_lon = (to.1 - from.1).to_radians();
let a = (d_lat / 2.0).sin().powi(2) + lat1.cos() * lat2.cos() * (d_lon / 2.0).sin().powi(2);
2.0 * EARTH_RADIUS_M * a.sqrt().asin()
}
struct AndroidRecording {
child: tokio::process::Child,
serial: String,
remote_path: String,
local_path: std::path::PathBuf,
}
pub struct AndroidDeviceControl {
client: AdbClient,
recording: Mutex<Option<AndroidRecording>>,
travel: Mutex<Option<tokio::task::JoinHandle<()>>>,
}
impl Default for AndroidDeviceControl {
fn default() -> Self {
Self::new()
}
}
impl AndroidDeviceControl {
#[must_use]
pub fn new() -> Self {
AndroidDeviceControl {
client: AdbClient::new(),
recording: Mutex::new(None),
travel: Mutex::new(None),
}
}
async fn entry_point(&self, serial: &str, bundle_id: &str) -> String {
const CONVENTION: &str = ".MainActivity";
let out = self
.client
.shell(
serial,
&[
"cmd",
"package",
"resolve-activity",
"--brief",
"-c",
"android.intent.category.LAUNCHER",
bundle_id,
],
)
.await;
let Ok(text) = out else {
return CONVENTION.to_string();
};
text.lines()
.rev()
.map(str::trim)
.find(|l| l.starts_with(&format!("{bundle_id}/")))
.and_then(|l| l.strip_prefix(bundle_id))
.map(|rest| rest.trim_start_matches('/').to_string())
.map(|a| {
if a.starts_with('.') {
a
} else {
format!(".{a}")
}
})
.filter(|a| a.len() > 1)
.unwrap_or_else(|| CONVENTION.to_string())
}
#[must_use]
pub fn with_client(client: AdbClient) -> Self {
AndroidDeviceControl {
client,
recording: Mutex::new(None),
travel: Mutex::new(None),
}
}
}
fn adb_to_simctl_err(e: AdbError, subcommand: &str) -> DeviceControlError {
let subcommand = &format!("adb {subcommand}");
match e {
AdbError::BinaryNotFound => DeviceControlError::non_zero_exit(
subcommand,
-1,
"adb binary not found in PATH; install Android SDK platform-tools",
),
AdbError::Spawn(io) => {
DeviceControlError::non_zero_exit(subcommand, -1, format!("adb spawn failed: {io}"))
}
AdbError::NonZeroExit {
subcommand: sub,
code,
stderr,
serial,
} => DeviceControlError::non_zero_exit(
match serial {
Some(s) => format!("adb -s {s} {sub}"),
None => format!("adb {sub}"),
},
code,
stderr,
),
AdbError::Malformed {
subcommand: sub,
detail,
} => DeviceControlError::Malformed {
subcommand: sub,
detail,
},
}
}
pub const ANDROID_ANIMATION_SCALES: [&str; 3] = [
"window_animation_scale",
"transition_animation_scale",
"animator_duration_scale",
];
pub fn animation_settings_verified(read_back: &[(&str, &str)]) -> Result<(), Vec<String>> {
let mut bad = Vec::new();
for (setting, value) in read_back {
let v = value.trim();
let ok = if setting.starts_with("UIAccessibility") {
v == "1"
} else {
v.parse::<f64>().is_ok_and(|n| n == 0.0)
};
if !ok {
let seen = if v.is_empty() { "<empty>" } else { v };
bad.push(format!(
"{setting} read back as {seen}, so the device did not take it"
));
}
}
if bad.is_empty() { Ok(()) } else { Err(bad) }
}
#[must_use]
pub fn parse_resolved_activity(text: &str, bundle_id: &str) -> Option<String> {
let prefix = format!("{bundle_id}/");
text.lines()
.map(str::trim)
.find(|l| l.starts_with(&prefix))
.and_then(|l| l.strip_prefix(bundle_id))
.map(|rest| rest.trim_start_matches('/').to_string())
.map(|a| {
if let Some(tail) = a.strip_prefix(&format!("{bundle_id}.")) {
format!(".{tail}")
} else if a.starts_with('.') {
a
} else {
format!(".{a}")
}
})
.filter(|a| a.len() > 1)
}
#[async_trait]
impl DeviceControl for AndroidDeviceControl {
fn platform(&self) -> Platform {
Platform::Android
}
async fn launch(&self, serial: &str, bundle_id: &str) -> Result<u32, DeviceControlError> {
let activity = self.entry_point(serial, bundle_id).await;
self.client
.start_activity(serial, bundle_id, &activity, &[])
.await
.map_err(|e| adb_to_simctl_err(e, "shell am start"))?;
Ok(0)
}
async fn launch_with_args(
&self,
serial: &str,
bundle_id: &str,
args: &[String],
activity: Option<&str>,
) -> Result<u32, DeviceControlError> {
let extras: Vec<(String, String)> = args
.chunks(2)
.filter_map(|chunk| {
if chunk.len() == 2 {
Some((chunk[0].clone(), chunk[1].clone()))
} else {
None
}
})
.collect();
let extra_refs: Vec<(&str, &str)> = extras
.iter()
.map(|(k, v)| (k.as_str(), v.as_str()))
.collect();
let activity = match activity {
Some(a) => a.to_string(),
None => self.entry_point(serial, bundle_id).await,
};
self.client
.start_activity(serial, bundle_id, &activity, &extra_refs)
.await
.map_err(|e| adb_to_simctl_err(e, "shell am start"))?;
Ok(0)
}
async fn set_animations_quiet(
&self,
serial: &str,
quiet: bool,
) -> Result<(), DeviceControlError> {
let target = if quiet { "0" } else { "1" };
for setting in ANDROID_ANIMATION_SCALES {
self.client
.shell(serial, &["settings", "put", "global", setting, target])
.await
.map_err(|e| adb_to_simctl_err(e, "shell settings put"))?;
}
if !quiet {
return Ok(());
}
let mut read_back = Vec::new();
for setting in ANDROID_ANIMATION_SCALES {
let value = self
.client
.shell(serial, &["settings", "get", "global", setting])
.await
.map_err(|e| adb_to_simctl_err(e, "shell settings get"))?;
read_back.push((setting, value.trim().to_string()));
}
let pairs: Vec<(&str, &str)> = read_back.iter().map(|(k, v)| (*k, v.as_str())).collect();
animation_settings_verified(&pairs).map_err(|bad| {
DeviceControlError::non_zero_exit(
"shell settings get",
1,
format!(
"animations were not quietened on {serial}: {}",
bad.join("; ")
),
)
})
}
async fn terminate(&self, serial: &str, bundle_id: &str) -> Result<(), DeviceControlError> {
self.client
.force_stop(serial, bundle_id)
.await
.map_err(|e| adb_to_simctl_err(e, "shell am force-stop"))
}
async fn install(&self, serial: &str, app_path: &str) -> Result<(), DeviceControlError> {
self.client
.install(serial, Path::new(app_path))
.await
.map_err(|e| adb_to_simctl_err(e, "install"))
}
async fn uninstall(&self, serial: &str, bundle_id: &str) -> Result<(), DeviceControlError> {
self.client
.uninstall(serial, bundle_id)
.await
.map_err(|e| adb_to_simctl_err(e, "uninstall"))
}
async fn keychain_reset(&self, _serial: &str) -> Result<(), DeviceControlError> {
Err(DeviceControlError::non_zero_exit(
"keychain_reset",
-1,
"clearKeychain has no Android equivalent: credentials live in each app's own \
KeyStore, which the host cannot reach. Use clearAppData to wipe the app's \
state, or have the app expose a sign-out path.",
))
}
async fn privacy_reset_all(
&self,
serial: &str,
bundle_id: &str,
) -> Result<(), DeviceControlError> {
let granted = self
.client
.runtime_permissions_granted(serial, bundle_id)
.await
.map_err(|e| adb_to_simctl_err(e, "shell dumpsys package"))?;
for permission in granted {
self.client
.pm_revoke(serial, bundle_id, &permission)
.await
.map_err(|e| adb_to_simctl_err(e, "shell pm revoke"))?;
}
Ok(())
}
async fn clear_app_sandbox(
&self,
serial: &str,
bundle_id: &str,
) -> Result<(), DeviceControlError> {
self.client
.pm_clear(serial, bundle_id)
.await
.map_err(|e| adb_to_simctl_err(e, "shell pm clear"))
}
async fn open_url(&self, serial: &str, url: &str) -> Result<(), DeviceControlError> {
self.client
.shell(
serial,
&["am", "start", "-a", "android.intent.action.VIEW", "-d", url],
)
.await
.map(|_| ())
.map_err(|e| adb_to_simctl_err(e, "shell am start -a VIEW"))
}
async fn send_push(
&self,
_serial: &str,
_bundle_id: &str,
_apns_json_path: &str,
) -> Result<(), DeviceControlError> {
Err(DeviceControlError::non_zero_exit(
"send_push",
-1,
"Android FCM push not implemented (cross-platform yaml `sendPush:` is iOS APNs only)",
))
}
async fn screenshot(&self, serial: &str) -> Result<Vec<u8>, DeviceControlError> {
self.client
.screenshot(serial)
.await
.map_err(|e| adb_to_simctl_err(e, "shell screencap"))
}
async fn pasteboard_set(&self, _serial: &str, _text: &str) -> Result<(), DeviceControlError> {
Err(DeviceControlError::non_zero_exit(
"pasteboard_set",
-1,
"Android does not let a test runner write the clipboard: since Android 10 the \
clipboard serves only the focused app, and the runner cannot be focused while \
driving your app. Pass the text via inputText, or have the app expose it another way.",
))
}
async fn pasteboard_get(&self, _serial: &str) -> Result<String, DeviceControlError> {
Err(DeviceControlError::non_zero_exit(
"pasteboard_get",
-1,
"Android does not let a test runner read the clipboard: since Android 10 the \
clipboard serves only the focused app, and the runner cannot be focused while \
driving your app. Assert on what the app renders instead.",
))
}
async fn add_media(&self, serial: &str, paths: &[String]) -> Result<(), DeviceControlError> {
if paths.is_empty() {
return Err(DeviceControlError::Malformed {
subcommand: "add_media".into(),
detail: "no paths supplied".into(),
});
}
for p in paths {
let local = Path::new(p);
let name = local.file_name().and_then(|n| n.to_str()).ok_or_else(|| {
DeviceControlError::Malformed {
subcommand: "add_media".into(),
detail: format!("path has no file name: {p}"),
}
})?;
let remote = format!("/sdcard/Pictures/{name}");
self.client
.push(serial, local, &remote)
.await
.map_err(|e| adb_to_simctl_err(e, "push"))?;
self.client
.broadcast(
serial,
"android.intent.action.MEDIA_SCANNER_SCAN_FILE",
Some(&format!("file://{remote}")),
)
.await
.map_err(|e| adb_to_simctl_err(e, "media scan"))?;
}
Ok(())
}
async fn location_set(
&self,
serial: &str,
lat: f64,
lon: f64,
) -> Result<(), DeviceControlError> {
self.client
.emu(serial, &["geo", "fix", &lon.to_string(), &lat.to_string()])
.await
.map(|_| ())
.map_err(|e| adb_to_simctl_err(e, "emu geo fix"))
}
async fn location_start(
&self,
serial: &str,
points: &[(f64, f64)],
speed_mps: Option<f64>,
) -> Result<(), DeviceControlError> {
if points.len() < 2 {
return Err(DeviceControlError::Malformed {
subcommand: "location_start".into(),
detail: format!("requires ≥2 waypoints, got {}", points.len()),
});
}
let speed = speed_mps.unwrap_or(SIMCTL_DEFAULT_SPEED_MPS);
if !(speed.is_finite() && speed > 0.0) {
return Err(DeviceControlError::Malformed {
subcommand: "location_start".into(),
detail: format!("speed must be finite and positive, got {speed}"),
});
}
let route: Vec<(f64, f64)> = points.to_vec();
let client = self.client.clone();
let serial = serial.to_string();
let mut slot = self.travel.lock().await;
if let Some(previous) = slot.take() {
previous.abort();
}
let handle = tokio::spawn(async move {
for pair in route.windows(2) {
let (from, to) = (pair[0], pair[1]);
let seconds = haversine_metres(from, to) / speed;
let ticks = (seconds / GEO_FIX_INTERVAL.as_secs_f64()).round() as u64;
for tick in 1..=ticks.max(1) {
#[expect(
clippy::cast_precision_loss,
reason = "a route long enough to lose f64 precision here would \
take longer to walk than the emulator stays up"
)]
let t = tick as f64 / ticks.max(1) as f64;
let lat = from.0 + (to.0 - from.0) * t;
let lon = from.1 + (to.1 - from.1) * t;
if client
.emu(&serial, &["geo", "fix", &lon.to_string(), &lat.to_string()])
.await
.is_err()
{
return;
}
tokio::time::sleep(GEO_FIX_INTERVAL).await;
}
}
});
*slot = Some(handle);
Ok(())
}
async fn set_permission(
&self,
serial: &str,
bundle_id: &str,
permission: Permission,
action: PermissionAction,
) -> Result<(), DeviceControlError> {
let Some(android_perm) = permission.to_android() else {
return Ok(());
};
match action {
PermissionAction::Grant => self
.client
.pm_grant(serial, bundle_id, android_perm)
.await
.map_err(|e| adb_to_simctl_err(e, "shell pm grant")),
PermissionAction::Revoke => self
.client
.pm_revoke(serial, bundle_id, android_perm)
.await
.map_err(|e| adb_to_simctl_err(e, "shell pm revoke")),
PermissionAction::Reset => {
self.client
.pm_revoke(serial, bundle_id, android_perm)
.await
.map_err(|e| adb_to_simctl_err(e, "shell pm revoke (Reset alias)"))
}
}
}
async fn start_recording(
&self,
serial: &str,
output_path: &Path,
) -> Result<(), DeviceControlError> {
let mut guard = self.recording.lock().await;
if guard.is_some() {
return Err(DeviceControlError::non_zero_exit(
"screenrecord",
-1,
"a recording is already in progress (call stop_recording first)",
));
}
let name = output_path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("smix-recording.mp4");
let remote_path = format!("/sdcard/{name}");
let _ = self.client.shell(serial, &["rm", "-f", &remote_path]).await;
let child = self
.client
.spawn_shell(serial, &["screenrecord", &remote_path])
.map_err(|e| adb_to_simctl_err(e, "screenrecord"))?;
*guard = Some(AndroidRecording {
child,
serial: serial.to_string(),
remote_path,
local_path: output_path.to_path_buf(),
});
Ok(())
}
async fn stop_recording(&self) -> Result<(), DeviceControlError> {
let mut guard = self.recording.lock().await;
let mut rec = guard.take().ok_or_else(|| {
DeviceControlError::non_zero_exit(
"screenrecord",
-1,
"no recording in progress (call start_recording first)",
)
})?;
if let Some(pid) = rec.child.id() {
unsafe { libc::kill(pid as i32, libc::SIGINT) };
}
let _ = rec.child.wait().await;
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
self.client
.pull(&rec.serial, &rec.remote_path, &rec.local_path)
.await
.map_err(|e| adb_to_simctl_err(e, "pull recording"))?;
let _ = self
.client
.shell(&rec.serial, &["rm", "-f", &rec.remote_path])
.await;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn haversine_matches_the_geometry() {
let r = 6_371_000.0_f64;
let d = haversine_metres((0.0, 0.0), (0.0, 1.0));
assert!((d - r * std::f64::consts::PI / 180.0).abs() < 1.0, "{d}");
let d = haversine_metres((0.0, 0.0), (90.0, 0.0));
assert!((d - r * std::f64::consts::FRAC_PI_2).abs() < 1.0, "{d}");
let d = haversine_metres((0.0, 0.0), (0.0, 180.0));
assert!((d - r * std::f64::consts::PI).abs() < 1.0, "{d}");
let d = haversine_metres((0.0, 0.0), (1.0, 0.0));
assert!((d - r * std::f64::consts::PI / 180.0).abs() < 1.0, "{d}");
}
#[test]
fn haversine_matches_a_known_route() {
let d = haversine_metres((48.856_614, 2.352_222), (40.712_776, -74.005_974));
assert!((d - 5_837_000.0).abs() < 20_000.0, "{d}");
}
#[test]
fn a_leg_of_no_length_takes_no_time() {
let here = (35.681_236, 139.767_125);
assert!(haversine_metres(here, here) < 0.001);
}
#[tokio::test]
async fn a_route_needs_somewhere_to_go() {
let device = AndroidDeviceControl::new();
let err = device
.location_start("emulator-5554", &[(35.0, 139.0)], None)
.await
.expect_err("one waypoint is not a route");
assert!(err.to_string().contains("≥2 waypoints"), "{err}");
}
#[tokio::test]
async fn a_route_walked_at_zero_speed_never_arrives() {
let device = AndroidDeviceControl::new();
let err = device
.location_start("emulator-5554", &[(35.0, 139.0), (36.0, 140.0)], Some(0.0))
.await
.expect_err("zero speed would divide by zero and tick forever");
assert!(err.to_string().contains("positive"), "{err}");
}
}