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::SimctlError;
use crate::PermissionAction;
use crate::device_control::{DeviceControl, Permission};
pub struct AndroidDeviceControl {
client: AdbClient,
#[allow(dead_code)]
recording: Mutex<Option<u32>>,
}
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),
}
}
#[must_use]
pub fn with_client(client: AdbClient) -> Self {
AndroidDeviceControl {
client,
recording: Mutex::new(None),
}
}
}
fn adb_to_simctl_err(e: AdbError, subcommand: &str) -> SimctlError {
match e {
AdbError::BinaryNotFound => SimctlError::NonZeroExit {
subcommand: subcommand.into(),
code: -1,
stderr: "adb binary not found in PATH; install Android SDK platform-tools".into(),
},
AdbError::Spawn(io) => SimctlError::NonZeroExit {
subcommand: subcommand.into(),
code: -1,
stderr: format!("adb spawn failed: {io}"),
},
AdbError::NonZeroExit {
subcommand: sub,
code,
stderr,
serial,
} => SimctlError::NonZeroExit {
subcommand: format!("adb {sub} (serial={serial:?})"),
code,
stderr,
},
AdbError::Malformed {
subcommand: sub,
detail,
} => SimctlError::Malformed {
subcommand: sub,
detail,
},
}
}
#[async_trait]
impl DeviceControl for AndroidDeviceControl {
fn platform(&self) -> Platform {
Platform::Android
}
async fn launch(&self, serial: &str, bundle_id: &str) -> Result<u32, SimctlError> {
self.client
.start_activity(serial, bundle_id, ".MainActivity", &[])
.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],
) -> Result<u32, SimctlError> {
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();
self.client
.start_activity(serial, bundle_id, ".MainActivity", &extra_refs)
.await
.map_err(|e| adb_to_simctl_err(e, "shell am start"))?;
Ok(0)
}
async fn terminate(&self, serial: &str, bundle_id: &str) -> Result<(), SimctlError> {
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<(), SimctlError> {
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<(), SimctlError> {
self.client
.uninstall(serial, bundle_id)
.await
.map_err(|e| adb_to_simctl_err(e, "uninstall"))
}
async fn keychain_reset(&self, _udid: &str) -> Result<(), SimctlError> {
Ok(())
}
async fn open_url(&self, serial: &str, url: &str) -> Result<(), SimctlError> {
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<(), SimctlError> {
Err(SimctlError::NonZeroExit {
subcommand: "send_push".into(),
code: -1,
stderr: "Android FCM push not implemented (cross-platform yaml `sendPush:` is iOS APNs only)".into(),
})
}
async fn screenshot(&self, serial: &str) -> Result<Vec<u8>, SimctlError> {
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<(), SimctlError> {
Err(SimctlError::NonZeroExit {
subcommand: "pasteboard_set".into(),
code: -1,
stderr: "Android clipboard set wiring deferred to a future cycle".into(),
})
}
async fn pasteboard_get(&self, _serial: &str) -> Result<String, SimctlError> {
Err(SimctlError::NonZeroExit {
subcommand: "pasteboard_get".into(),
code: -1,
stderr: "Android clipboard get wiring deferred to a future cycle".into(),
})
}
async fn add_media(&self, _serial: &str, _paths: &[String]) -> Result<(), SimctlError> {
Err(SimctlError::NonZeroExit {
subcommand: "add_media".into(),
code: -1,
stderr: "Android media library push deferred to a future cycle".into(),
})
}
async fn location_set(&self, serial: &str, lat: f64, lon: f64) -> Result<(), SimctlError> {
self.client
.shell(
serial,
&["emu", "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<(), SimctlError> {
Err(SimctlError::NonZeroExit {
subcommand: "location_start".into(),
code: -1,
stderr: "Android multi-waypoint route deferred to a future cycle".into(),
})
}
async fn set_permission(
&self,
serial: &str,
bundle_id: &str,
permission: Permission,
action: PermissionAction,
) -> Result<(), SimctlError> {
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<(), SimctlError> {
Err(SimctlError::NonZeroExit {
subcommand: "start_recording".into(),
code: -1,
stderr: "Android screenrecord wiring deferred to a future cycle".into(),
})
}
async fn stop_recording(&self) -> Result<(), SimctlError> {
Err(SimctlError::NonZeroExit {
subcommand: "stop_recording".into(),
code: -1,
stderr: "Android screenrecord wiring deferred to a future cycle".into(),
})
}
}