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}