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::SimctlError;
14
15use crate::PermissionAction;
16use crate::device_control::{DeviceControl, Permission};
17
18/// Android `DeviceControl` impl. Wraps `smix_adb::AdbClient` + holds an
19/// active recording handle internally.
20///
21/// Some methods for operations not yet fully implemented surface
22/// `SimctlError::NonZeroExit` with a clear message; `smix-adb`
23/// translation lives in `adb_to_simctl_err`.
24pub struct AndroidDeviceControl {
25    client: AdbClient,
26    /// Active `adb shell screenrecord` child handle (PID), if recording.
27    /// Placeholder; real recording wiring lands when Android video
28    /// capture becomes a requirement.
29    #[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    /// Construct with an existing `AdbClient` (tests / non-PATH adb).
49    #[must_use]
50    pub fn with_client(client: AdbClient) -> Self {
51        AndroidDeviceControl {
52            client,
53            recording: Mutex::new(None),
54        }
55    }
56}
57
58/// Translate `AdbError` → `SimctlError` so the trait surface stays
59/// consistent (single error type across platforms — App layer maps to
60/// `ExpectationFailure`). Android-specific detail preserved in message.
61fn 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    // === Lifecycle ===
100
101    async fn launch(&self, serial: &str, bundle_id: &str) -> Result<u32, SimctlError> {
102        // AdbClient::start_activity itself builds `package/activity` for
103        // `am start -n`. Pass the activity name RELATIVE to the package
104        // (".MainActivity") so the wire ends up `<pkg>/.MainActivity`
105        // rather than the double-prefix form `<pkg>/<pkg>/.MainActivity`.
106        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        // No direct Android analog; KeyChain/AccountManager require app-side
163        // intent or root. Surface as a platform no-op (matches the
164        // cross-platform yaml `clearKeychain: true` expectation that
165        // Android silently no-ops rather than crashing).
166        Ok(())
167    }
168
169    // === Lifecycle ancillary ===
170
171    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        // Android push = FCM not APNs. Cross-platform yaml `sendPush:` on
189        // Android requires FCM credentials + Firebase project setup —
190        // deferred. Surface an explicit error so yaml authors know it
191        // is not silently no-op.
192        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    // === Clipboard / Media / Location ===
203
204    async fn pasteboard_set(&self, _serial: &str, _text: &str) -> Result<(), SimctlError> {
205        // Android clipboard via `cmd clipboard set-text` (API 29+) or
206        // `service call clipboard` (older). v6.x dedicated cycle; skeleton
207        // returns error to avoid silent failure.
208        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        // `adb push <local> /sdcard/Pictures/` then `am broadcast -a
217        // android.intent.action.MEDIA_SCANNER_SCAN_FILE`.
218        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        // `adb emu geo fix <lon> <lat>` — emu console (note arg order:
223        // lon first, then lat).
224        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        // Multi-waypoint route requires Android emulator GPX/KML upload
241        // via emu console `geo gpx`.
242        Err(SimctlError::non_zero_exit("location_start", -1, "Android multi-waypoint route deferred to a future cycle"))
243    }
244
245    // === Permissions ===
246
247    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            // iOS-only permission on Android → no-op (cross-platform yaml
256            // friendly; matches IosDeviceControl behavior for Storage on iOS).
257            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                // Android has no direct `pm reset <perm>` per-package; reset
272                // = uninstall+reinstall OR `pm reset-permissions`. For
273                // cross-platform yaml `permissions: { camera: unset }`
274                // semantic, treat as revoke (closest match).
275                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    // === Recording ===
284
285    async fn start_recording(&self, _serial: &str, _output_path: &Path) -> Result<(), SimctlError> {
286        // `adb shell screenrecord /sdcard/sim.mp4` — runs on device,
287        // max 3min default.
288        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}