Skip to main content

smix_sdk/
device_control.rs

1//! `DeviceControl` trait: cross-platform sim/host control.
2//!
3//! Two-trait architecture: pairs with [`smix_driver::Driver`]
4//! (sense+act).
5//!
6//! Methods on this trait wrap host-side simulator/emulator control
7//! commands (`xcrun simctl` for iOS, `adb` for Android).
8//! Sense+act methods (tap/find/etc) live on [`smix_driver::Driver`].
9
10use async_trait::async_trait;
11use smix_simctl::{DeviceControlError, SimctlClient, SimctlPermission};
12use std::path::Path;
13
14pub use crate::PermissionAction;
15
16/// Platform-agnostic permission name used in [`DeviceControl::set_permission`]
17/// and the cross-platform yaml `launchApp.permissions:` shape. Avoids
18/// leaking iOS-specific `SimctlPermission` into the trait signature.
19///
20/// Naming follows iOS convention where present; Android-only permissions
21/// (Storage, PostNotifications) have explicit variants. Cross-platform
22/// permissions (Camera/Location/etc.) map both ways via `to_simctl`
23/// and `to_android`.
24#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
25pub enum Permission {
26    Camera,
27    Microphone,
28    PhotoLibrary,
29    Location,
30    LocationAlways,
31    Notifications,
32    Contacts,
33    Calendar,
34    Reminders,
35    Bluetooth,
36    Motion,
37    Media,
38    Health,
39    /// iOS-only — FaceID / TouchID biometric prompt.
40    FaceId,
41    /// iOS-only — HomeKit accessory access.
42    HomeKit,
43    /// Android-only — storage / files (iOS-side returns `None` from
44    /// `to_simctl`).
45    Storage,
46    /// Android-only POST_NOTIFICATIONS (API 33+). On iOS aliases to
47    /// `Notifications` for cross-platform yaml convenience.
48    PostNotifications,
49}
50
51impl Permission {
52    /// Map to iOS `SimctlPermission`. Returns `None` for Android-only
53    /// permissions (`Storage`).
54    #[must_use]
55    pub fn to_simctl(self) -> Option<SimctlPermission> {
56        match self {
57            Permission::Camera => Some(SimctlPermission::Camera),
58            Permission::Microphone => Some(SimctlPermission::Microphone),
59            Permission::PhotoLibrary => Some(SimctlPermission::Photos),
60            Permission::Location => Some(SimctlPermission::Location),
61            Permission::LocationAlways => Some(SimctlPermission::LocationAlways),
62            Permission::Notifications | Permission::PostNotifications => {
63                Some(SimctlPermission::Notifications)
64            }
65            Permission::Contacts => Some(SimctlPermission::Contacts),
66            Permission::Calendar => Some(SimctlPermission::Calendar),
67            Permission::Reminders => Some(SimctlPermission::Reminders),
68            Permission::Bluetooth => Some(SimctlPermission::Bluetooth),
69            Permission::Motion => Some(SimctlPermission::Motion),
70            Permission::Media => Some(SimctlPermission::Media),
71            Permission::Health => Some(SimctlPermission::Health),
72            Permission::FaceId => Some(SimctlPermission::Faceid),
73            Permission::HomeKit => Some(SimctlPermission::HomeKit),
74            Permission::Storage => None,
75        }
76    }
77
78    /// Reverse: map iOS `SimctlPermission` → `Permission`. Used by App
79    /// back-compat shim accepting `SimctlPermission` arg.
80    #[must_use]
81    pub fn from_simctl(perm: SimctlPermission) -> Self {
82        match perm {
83            SimctlPermission::Camera => Permission::Camera,
84            SimctlPermission::Microphone => Permission::Microphone,
85            SimctlPermission::Photos => Permission::PhotoLibrary,
86            SimctlPermission::Location => Permission::Location,
87            SimctlPermission::LocationAlways => Permission::LocationAlways,
88            SimctlPermission::Notifications => Permission::Notifications,
89            SimctlPermission::Contacts => Permission::Contacts,
90            SimctlPermission::Calendar => Permission::Calendar,
91            SimctlPermission::Reminders => Permission::Reminders,
92            SimctlPermission::Bluetooth => Permission::Bluetooth,
93            SimctlPermission::Motion => Permission::Motion,
94            SimctlPermission::Media => Permission::Media,
95            SimctlPermission::Health => Permission::Health,
96            SimctlPermission::Faceid => Permission::FaceId,
97            SimctlPermission::HomeKit => Permission::HomeKit,
98            SimctlPermission::AddressBook => Permission::Contacts,
99        }
100    }
101
102    /// Map to Android `android.permission.X` string. Returns `None` for
103    /// iOS-only permissions (`FaceId`, `HomeKit`). Wired by
104    /// `AndroidDeviceControl`; the iOS impl ignores it.
105    #[must_use]
106    pub fn to_android(self) -> Option<&'static str> {
107        match self {
108            Permission::Camera => Some("android.permission.CAMERA"),
109            Permission::Microphone => Some("android.permission.RECORD_AUDIO"),
110            Permission::PhotoLibrary => Some("android.permission.READ_MEDIA_IMAGES"),
111            Permission::Location => Some("android.permission.ACCESS_FINE_LOCATION"),
112            Permission::LocationAlways => Some("android.permission.ACCESS_BACKGROUND_LOCATION"),
113            Permission::Notifications | Permission::PostNotifications => {
114                Some("android.permission.POST_NOTIFICATIONS")
115            }
116            Permission::Contacts => Some("android.permission.READ_CONTACTS"),
117            Permission::Calendar => Some("android.permission.READ_CALENDAR"),
118            Permission::Bluetooth => Some("android.permission.BLUETOOTH_CONNECT"),
119            Permission::Motion => Some("android.permission.ACTIVITY_RECOGNITION"),
120            Permission::Media => Some("android.permission.READ_MEDIA_AUDIO"),
121            Permission::Storage => Some("android.permission.WRITE_EXTERNAL_STORAGE"),
122            Permission::Reminders
123            | Permission::Health
124            | Permission::FaceId
125            | Permission::HomeKit => None,
126        }
127    }
128}
129
130/// Sim/host control trait. The iOS impl wraps `xcrun simctl`; the
131/// Android impl wraps `adb`.
132///
133/// Methods take `udid: &str` first (iOS terminology; Android maps this
134/// to the device serial). All return `Result<_, DeviceControlError>` — the
135/// Android impl wraps adb errors into the same enum.
136#[async_trait]
137pub trait DeviceControl: Send + Sync {
138    /// Platform identifier. Returns `smix_driver::Platform`.
139    fn platform(&self) -> smix_driver::Platform;
140
141    /// iOS-only escape hatch: downcast to `&SimctlClient` for legacy
142    /// `App::simctl()` API surface. Android impl returns `None`.
143    fn as_ios_simctl(&self) -> Option<&SimctlClient> {
144        None
145    }
146
147    // === Lifecycle ===
148
149    async fn launch(&self, udid: &str, bundle_id: &str) -> Result<u32, DeviceControlError>;
150    /// Launch with process arguments, and on Android with an explicit
151    /// entry point.
152    ///
153    /// `activity` is `None` unless a flow's app config named one. The
154    /// Android side resolved every launch to `<pkg>/.MainActivity`
155    /// before this parameter existed, which is right for a scaffolded
156    /// app and wrong for every AOSP one; `None` now means "ask the
157    /// package manager" rather than "assume". iOS ignores it — a
158    /// bundle id already names what to launch.
159    async fn launch_with_args(
160        &self,
161        udid: &str,
162        bundle_id: &str,
163        args: &[String],
164        activity: Option<&str>,
165    ) -> Result<u32, DeviceControlError>;
166    async fn terminate(&self, udid: &str, bundle_id: &str) -> Result<(), DeviceControlError>;
167    async fn install(&self, udid: &str, app_path: &str) -> Result<(), DeviceControlError>;
168    async fn uninstall(&self, udid: &str, bundle_id: &str) -> Result<(), DeviceControlError>;
169    async fn keychain_reset(&self, udid: &str) -> Result<(), DeviceControlError>;
170
171    /// Push the device's animations as low as this platform allows,
172    /// then read the settings back and refuse if they did not take.
173    ///
174    /// `quiet = true` is the default a run gets; `false` restores the
175    /// device's own settings for `--animations`.
176    ///
177    /// How low differs by platform and the difference is not papered
178    /// over. Android zeroes three scales, which really is off. **iOS
179    /// does nothing**, because nothing on the host can: `simctl ui` has
180    /// no motion option, `simctl spawn … defaults write` cannot write
181    /// any domain, and XCUITest runs in its own process so
182    /// `UIView.setAnimationsEnabled(false)` cannot reach the app. This
183    /// interface first claimed iOS got Reduce Motion; a device said
184    /// otherwise.
185    ///
186    /// Reading back is not belt-and-braces. `simctl ui appearance` is
187    /// documented per-simulator and behaves globally; a setting written
188    /// by smix is not believed until the device repeats it. A switch
189    /// that reports success while the device kept animating is worse
190    /// than no switch — the run that follows looks deterministic and is
191    /// not.
192    async fn set_animations_quiet(
193        &self,
194        _id: &str,
195        _quiet: bool,
196    ) -> Result<(), DeviceControlError> {
197        Ok(())
198    }
199
200    /// Revoke every privacy permission the app has been granted.
201    ///
202    /// Companion to [`Self::clear_app_sandbox`]; together they are the
203    /// in-place replacement for `launchApp: clearState: true`, which avoids
204    /// uninstall-and-reinstall and the XCUITest binding loss that follows.
205    ///
206    /// Required, deliberately. This defaulted to `Ok(())` "so non-iOS
207    /// device controls keep compiling", and the result was that
208    /// `clearState: true` on Android reported success while clearing
209    /// nothing — the planner emits this op whatever the platform. A device
210    /// control that cannot do this has to say so out loud.
211    async fn privacy_reset_all(
212        &self,
213        udid: &str,
214        bundle_id: &str,
215    ) -> Result<(), DeviceControlError>;
216
217    /// Wipe the app's persisted data without uninstalling it, so the
218    /// test binding survives.
219    ///
220    /// Required for the same reason as [`Self::privacy_reset_all`].
221    async fn clear_app_sandbox(
222        &self,
223        udid: &str,
224        bundle_id: &str,
225    ) -> Result<(), DeviceControlError>;
226
227    /// Delete a single key from the target app's persisted
228    /// user-defaults / preferences store. iOS: `simctl spawn defaults
229    /// delete <bundle> <key>` (NSUserDefaults via the sim's cfprefsd).
230    /// Returns `Ok(true)` when the key existed, `Ok(false)` when
231    /// already absent (both are the "ensure absent" target state).
232    ///
233    /// Default impl errors explicitly — Android SharedPreferences has
234    /// no host-side per-key deletion path (files are app-private;
235    /// `pm clear` is the whole-store hammer, which is `clearAppData`'s
236    /// job, not this verb's). NOT a silent no-op: a consumer relying
237    /// on the deletion for test correctness must hear that it didn't
238    /// happen.
239    async fn user_defaults_delete(
240        &self,
241        _udid: &str,
242        _bundle_id: &str,
243        _key: &str,
244    ) -> Result<bool, DeviceControlError> {
245        Err(DeviceControlError::non_zero_exit(
246            "user-defaults-delete",
247            1,
248            "clearUserDefaults is not supported on this platform (iOS simulator only — \
249             Android SharedPreferences has no host-side per-key deletion; use clearAppData \
250             for a full store wipe)",
251        ))
252    }
253
254    // === Lifecycle ancillary ===
255
256    async fn open_url(&self, udid: &str, url: &str) -> Result<(), DeviceControlError>;
257    async fn send_push(
258        &self,
259        udid: &str,
260        bundle_id: &str,
261        apns_json_path: &str,
262    ) -> Result<(), DeviceControlError>;
263    async fn screenshot(&self, udid: &str) -> Result<Vec<u8>, DeviceControlError>;
264
265    /// Capture a frame preferring the fast raw-BGRA path (iOS: resident
266    /// IOSurface host, ~0.3 ms, skips the PNG encode+decode round-trip for
267    /// diff-loop consumers). The default impl wraps [`screenshot`](Self::screenshot)
268    /// as a PNG frame, so backends without a direct path (Android) keep
269    /// working unchanged.
270    ///
271    /// Since smix 2.0.0.
272    async fn capture_bgra(
273        &self,
274        udid: &str,
275    ) -> Result<smix_simctl::surface_capture::CapturedFrame, DeviceControlError> {
276        self.screenshot(udid)
277            .await
278            .map(smix_simctl::surface_capture::CapturedFrame::Png)
279    }
280
281    // === Clipboard / Media / Location ===
282
283    async fn pasteboard_set(&self, udid: &str, text: &str) -> Result<(), DeviceControlError>;
284    async fn pasteboard_get(&self, udid: &str) -> Result<String, DeviceControlError>;
285    async fn add_media(&self, udid: &str, paths: &[String]) -> Result<(), DeviceControlError>;
286    async fn location_set(&self, udid: &str, lat: f64, lon: f64) -> Result<(), DeviceControlError>;
287    async fn location_start(
288        &self,
289        udid: &str,
290        points: &[(f64, f64)],
291        speed_mps: Option<f64>,
292    ) -> Result<(), DeviceControlError>;
293
294    // === Permissions (cross-platform `Permission` enum) ===
295
296    async fn set_permission(
297        &self,
298        udid: &str,
299        bundle_id: &str,
300        permission: Permission,
301        action: PermissionAction,
302    ) -> Result<(), DeviceControlError>;
303
304    // === Recording (state owned internally by impl, see IosDeviceControl) ===
305
306    async fn start_recording(
307        &self,
308        udid: &str,
309        output_path: &Path,
310    ) -> Result<(), DeviceControlError>;
311    async fn stop_recording(&self) -> Result<(), DeviceControlError>;
312}