1use 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::SimctlError;
14
15use crate::PermissionAction;
16use crate::device_control::{DeviceControl, Permission};
17
18pub struct AndroidDeviceControl {
25 client: AdbClient,
26 #[allow(dead_code)]
30 recording: Mutex<Option<u32>>,
31}
32
33impl Default for AndroidDeviceControl {
34 fn default() -> Self {
35 Self::new()
36 }
37}
38
39impl AndroidDeviceControl {
40 #[must_use]
41 pub fn new() -> Self {
42 AndroidDeviceControl {
43 client: AdbClient::new(),
44 recording: Mutex::new(None),
45 }
46 }
47
48 #[must_use]
50 pub fn with_client(client: AdbClient) -> Self {
51 AndroidDeviceControl {
52 client,
53 recording: Mutex::new(None),
54 }
55 }
56}
57
58fn adb_to_simctl_err(e: AdbError, subcommand: &str) -> SimctlError {
62 match e {
63 AdbError::BinaryNotFound => SimctlError::non_zero_exit(
64 subcommand,
65 -1,
66 "adb binary not found in PATH; install Android SDK platform-tools",
67 ),
68 AdbError::Spawn(io) => SimctlError::non_zero_exit(
69 subcommand,
70 -1,
71 format!("adb spawn failed: {io}"),
72 ),
73 AdbError::NonZeroExit {
74 subcommand: sub,
75 code,
76 stderr,
77 serial,
78 } => SimctlError::non_zero_exit(
79 format!("adb {sub} (serial={serial:?})"),
80 code,
81 stderr,
82 ),
83 AdbError::Malformed {
84 subcommand: sub,
85 detail,
86 } => SimctlError::Malformed {
87 subcommand: sub,
88 detail,
89 },
90 }
91}
92
93#[async_trait]
94impl DeviceControl for AndroidDeviceControl {
95 fn platform(&self) -> Platform {
96 Platform::Android
97 }
98
99 async fn launch(&self, serial: &str, bundle_id: &str) -> Result<u32, SimctlError> {
102 self.client
107 .start_activity(serial, bundle_id, ".MainActivity", &[])
108 .await
109 .map_err(|e| adb_to_simctl_err(e, "shell am start"))?;
110 Ok(0)
111 }
112
113 async fn launch_with_args(
114 &self,
115 serial: &str,
116 bundle_id: &str,
117 args: &[String],
118 ) -> Result<u32, SimctlError> {
119 let extras: Vec<(String, String)> = args
120 .chunks(2)
121 .filter_map(|chunk| {
122 if chunk.len() == 2 {
123 Some((chunk[0].clone(), chunk[1].clone()))
124 } else {
125 None
126 }
127 })
128 .collect();
129 let extra_refs: Vec<(&str, &str)> = extras
130 .iter()
131 .map(|(k, v)| (k.as_str(), v.as_str()))
132 .collect();
133 self.client
134 .start_activity(serial, bundle_id, ".MainActivity", &extra_refs)
135 .await
136 .map_err(|e| adb_to_simctl_err(e, "shell am start"))?;
137 Ok(0)
138 }
139
140 async fn terminate(&self, serial: &str, bundle_id: &str) -> Result<(), SimctlError> {
141 self.client
142 .force_stop(serial, bundle_id)
143 .await
144 .map_err(|e| adb_to_simctl_err(e, "shell am force-stop"))
145 }
146
147 async fn install(&self, serial: &str, app_path: &str) -> Result<(), SimctlError> {
148 self.client
149 .install(serial, Path::new(app_path))
150 .await
151 .map_err(|e| adb_to_simctl_err(e, "install"))
152 }
153
154 async fn uninstall(&self, serial: &str, bundle_id: &str) -> Result<(), SimctlError> {
155 self.client
156 .uninstall(serial, bundle_id)
157 .await
158 .map_err(|e| adb_to_simctl_err(e, "uninstall"))
159 }
160
161 async fn keychain_reset(&self, _udid: &str) -> Result<(), SimctlError> {
162 Ok(())
167 }
168
169 async fn open_url(&self, serial: &str, url: &str) -> Result<(), SimctlError> {
172 self.client
173 .shell(
174 serial,
175 &["am", "start", "-a", "android.intent.action.VIEW", "-d", url],
176 )
177 .await
178 .map(|_| ())
179 .map_err(|e| adb_to_simctl_err(e, "shell am start -a VIEW"))
180 }
181
182 async fn send_push(
183 &self,
184 _serial: &str,
185 _bundle_id: &str,
186 _apns_json_path: &str,
187 ) -> Result<(), SimctlError> {
188 Err(SimctlError::non_zero_exit("send_push", -1, "Android FCM push not implemented (cross-platform yaml `sendPush:` is iOS APNs only)"))
193 }
194
195 async fn screenshot(&self, serial: &str) -> Result<Vec<u8>, SimctlError> {
196 self.client
197 .screenshot(serial)
198 .await
199 .map_err(|e| adb_to_simctl_err(e, "shell screencap"))
200 }
201
202 async fn pasteboard_set(&self, _serial: &str, _text: &str) -> Result<(), SimctlError> {
205 Err(SimctlError::non_zero_exit("pasteboard_set", -1, "Android clipboard set wiring deferred to a future cycle"))
209 }
210
211 async fn pasteboard_get(&self, _serial: &str) -> Result<String, SimctlError> {
212 Err(SimctlError::non_zero_exit("pasteboard_get", -1, "Android clipboard get wiring deferred to a future cycle"))
213 }
214
215 async fn add_media(&self, _serial: &str, _paths: &[String]) -> Result<(), SimctlError> {
216 Err(SimctlError::non_zero_exit("add_media", -1, "Android media library push deferred to a future cycle"))
219 }
220
221 async fn location_set(&self, serial: &str, lat: f64, lon: f64) -> Result<(), SimctlError> {
222 self.client
225 .shell(
226 serial,
227 &["emu", "geo", "fix", &lon.to_string(), &lat.to_string()],
228 )
229 .await
230 .map(|_| ())
231 .map_err(|e| adb_to_simctl_err(e, "emu geo fix"))
232 }
233
234 async fn location_start(
235 &self,
236 _serial: &str,
237 _points: &[(f64, f64)],
238 _speed_mps: Option<f64>,
239 ) -> Result<(), SimctlError> {
240 Err(SimctlError::non_zero_exit("location_start", -1, "Android multi-waypoint route deferred to a future cycle"))
243 }
244
245 async fn set_permission(
248 &self,
249 serial: &str,
250 bundle_id: &str,
251 permission: Permission,
252 action: PermissionAction,
253 ) -> Result<(), SimctlError> {
254 let Some(android_perm) = permission.to_android() else {
255 return Ok(());
258 };
259 match action {
260 PermissionAction::Grant => self
261 .client
262 .pm_grant(serial, bundle_id, android_perm)
263 .await
264 .map_err(|e| adb_to_simctl_err(e, "shell pm grant")),
265 PermissionAction::Revoke => self
266 .client
267 .pm_revoke(serial, bundle_id, android_perm)
268 .await
269 .map_err(|e| adb_to_simctl_err(e, "shell pm revoke")),
270 PermissionAction::Reset => {
271 self.client
276 .pm_revoke(serial, bundle_id, android_perm)
277 .await
278 .map_err(|e| adb_to_simctl_err(e, "shell pm revoke (Reset alias)"))
279 }
280 }
281 }
282
283 async fn start_recording(&self, _serial: &str, _output_path: &Path) -> Result<(), SimctlError> {
286 Err(SimctlError::non_zero_exit("start_recording", -1, "Android screenrecord wiring deferred to a future cycle"))
289 }
290
291 async fn stop_recording(&self) -> Result<(), SimctlError> {
292 Err(SimctlError::non_zero_exit("stop_recording", -1, "Android screenrecord wiring deferred to a future cycle"))
293 }
294}