//! Device settings and one-shot actions, run against the platform tools //! rather than through the hub: `xcrun simctl` and serve-sim's //! `serve-sim-ax-settings` helper for iOS, `adb shell` for Android. One typed //! action per control, as T3 Code does it; the hub's own command channel is //! never used, since that would let anything that reaches it run commands. use std::time::Duration; use anyhow::{Result, anyhow, bail}; use super::host::{self, Ready}; use super::{Action, ColorFilter, ForegroundApp, Orientation, Platform, Settings, TextSize, Toggle}; const TIMEOUT: Duration = Duration::from_secs(20); /// Whether a platform can run an action; the Tools drawer hides the rest. pub fn supports(platform: Platform, action: &Action) -> bool { match (platform, action) { (_, Action::SetToggle { setting, .. }) => match platform { Platform::Ios => matches!( setting, Toggle::ReduceMotion | Toggle::IncreaseContrast | Toggle::ReduceTransparency | Toggle::ShowBorders | Toggle::VoiceOver ), Platform::Android => matches!(setting, Toggle::ReduceMotion | Toggle::NetworkEnabled), }, (Platform::Ios, Action::SetOrientation(_)) => false, (Platform::Android, Action::SetLiquidGlass(_) | Action::SetColorFilter(_) | Action::SendPush { .. }) => false, _ => true, } } fn unsupported(action: &Action, platform: Platform) -> anyhow::Error { anyhow!("Device {} is not supported on {}.", action.name(), platform.key()) } pub fn run(ready: &Ready, platform: Platform, device: &str, action: &Action) -> Result<()> { if !supports(platform, action) { return Err(unsupported(action, platform)); } match platform { Platform::Ios => run_ios(ready, device, action), Platform::Android => run_android(device, action), } } /// iOS text-size categories for the four shared steps; `default` is what a /// fresh simulator reports. fn ios_text_size(size: TextSize) -> &'static str { match size { TextSize::Small => "small", TextSize::Default => "large", TextSize::Large => "extra-extra-large", TextSize::ExtraLarge => "accessibility-large", } } fn text_size_from_ios(category: &str) -> TextSize { for size in [TextSize::Small, TextSize::Default, TextSize::Large, TextSize::ExtraLarge] { if ios_text_size(size) == category { return size; } } if category.starts_with("accessibility") { TextSize::ExtraLarge } else if category.contains("extra") { TextSize::Large } else if matches!(category, "extra-small" | "small" | "medium") { TextSize::Small } else { TextSize::Default } } fn android_text_size(size: TextSize) -> &'static str { match size { TextSize::Small => "0.85", TextSize::Default => "1.0", TextSize::Large => "1.15", TextSize::ExtraLarge => "1.3", } } fn text_size_from_android(scale: f64) -> TextSize { if scale <= 0.9 { TextSize::Small } else if scale >= 1.25 { TextSize::ExtraLarge } else if scale >= 1.1 { TextSize::Large } else { TextSize::Default } } fn simctl(udid: &str, args: &[&str], operation: &str) -> Result { let mut full = vec!["simctl", args[0], udid]; full.extend_from_slice(&args[1..]); host::run("xcrun", &full, TIMEOUT, None).ok(operation) } fn ax_settings(ready: &Ready, udid: &str, args: &[&str]) -> Result { let Some(helper) = &ready.helpers.ax_settings else { bail!("Device accessibility requires a helper missing from this install. Set up device support again."); }; let helper = helper.display().to_string(); let mut full = vec!["simctl", "spawn", udid, &helper]; full.extend_from_slice(args); host::run("xcrun", &full, TIMEOUT, None).ok("accessibility") } fn ios_toggle_option(setting: Toggle) -> &'static str { match setting { Toggle::ReduceMotion => "reduce-motion", Toggle::IncreaseContrast => "increase-contrast", Toggle::ReduceTransparency => "reduce-transparency", Toggle::ShowBorders => "show-borders", Toggle::VoiceOver => "voiceover", Toggle::NetworkEnabled => "network", } } fn run_ios(ready: &Ready, udid: &str, action: &Action) -> Result<()> { match action { Action::SetAppearance(dark) => simctl(udid, &["ui", "appearance", if *dark { "dark" } else { "light" }], "appearance")?, Action::SetTextSize(size) => simctl(udid, &["ui", "content_size", ios_text_size(*size)], "text size")?, Action::SetToggle { setting: Toggle::IncreaseContrast, value } => simctl( udid, &["ui", "increase_contrast", if *value { "enabled" } else { "disabled" }], "increase contrast", )?, Action::SetToggle { setting, value } => { ax_settings(ready, udid, &["set", ios_toggle_option(*setting), if *value { "on" } else { "off" }])? } Action::SetLiquidGlass(tinted) => ax_settings(ready, udid, &["set", "liquid-glass", if *tinted { "tinted" } else { "clear" }])?, Action::SetColorFilter(filter) => ax_settings(ready, udid, &["set", "color-filter", filter.key()])?, Action::SetLocation { latitude, longitude } => { simctl(udid, &["location", "set", &format!("{latitude},{longitude}")], "location")? } Action::ClearLocation => simctl(udid, &["location", "clear"], "location")?, Action::SetPermission { app_id, permission, decision } => { if *permission == "notifications" { // simctl has no notification verb; serve-sim's CLI edits the plist for it. let Some(cli) = &ready.helpers.serve_sim_cli else { bail!("Device permission requires a helper missing from this install. Set up device support again."); }; let node = ready.node.display().to_string(); let cli = cli.display().to_string(); host::run(&node, &[&cli, "permissions", decision, permission, app_id, "-d", udid], TIMEOUT, None) .ok("permission")? } else { simctl(udid, &["privacy", decision, permission, app_id], "permission")? } } Action::OpenUrl(url) => simctl(udid, &["openurl", url], "open url")?, Action::LaunchApp(app) => simctl(udid, &["launch", app], "launch")?, Action::TerminateApp(app) => simctl(udid, &["terminate", app], "terminate")?, Action::SendPush { app_id, text } => { let payload = serde_json::json!({"aps": {"alert": text}}).to_string(); host::run("xcrun", &["simctl", "push", udid, app_id, "-"], TIMEOUT, Some(&payload)).ok("push")? } Action::SetOrientation(_) => return Err(unsupported(action, Platform::Ios)), }; Ok(()) } /// Android permissions behind each shared permission name. fn android_permissions(permission: &str) -> &'static [&'static str] { match permission { "camera" => &["android.permission.CAMERA"], "microphone" => &["android.permission.RECORD_AUDIO"], "photos" => &["android.permission.READ_MEDIA_IMAGES", "android.permission.READ_EXTERNAL_STORAGE"], "contacts" => &["android.permission.READ_CONTACTS", "android.permission.WRITE_CONTACTS"], "calendar" => &["android.permission.READ_CALENDAR", "android.permission.WRITE_CALENDAR"], "location" => &["android.permission.ACCESS_FINE_LOCATION", "android.permission.ACCESS_COARSE_LOCATION"], "notifications" => &["android.permission.POST_NOTIFICATIONS"], "motion" => &["android.permission.ACTIVITY_RECOGNITION"], _ => &[], } } /// The gravity vector that makes the emulator report each orientation, and /// the window manager's rotation index for the same. fn android_gravity(orientation: Orientation) -> (&'static str, &'static str) { match orientation { Orientation::Portrait => ("0:9.81:0", "0"), Orientation::LandscapeLeft => ("9.81:0:0", "1"), Orientation::PortraitUpsideDown => ("0:-9.81:0", "2"), Orientation::LandscapeRight => ("-9.81:0:0", "3"), } } fn adb(serial: &str, args: &[&str], operation: &str) -> Result { let mut full = vec!["-s", serial]; full.extend_from_slice(args); host::run("adb", &full, TIMEOUT, None).ok(operation) } fn shell(serial: &str, args: &[&str], operation: &str) -> Result { let mut full = vec!["shell"]; full.extend_from_slice(args); adb(serial, &full, operation) } fn run_android(serial: &str, action: &Action) -> Result<()> { match action { Action::SetAppearance(dark) => { shell(serial, &["cmd", "uimode", "night", if *dark { "yes" } else { "no" }], "appearance")?; } Action::SetTextSize(size) => { shell(serial, &["settings", "put", "system", "font_scale", android_text_size(*size)], "text size")?; } Action::SetToggle { setting: Toggle::NetworkEnabled, value } => { let state = if *value { "enable" } else { "disable" }; shell(serial, &["svc", "wifi", state], "network")?; shell(serial, &["svc", "data", state], "network")?; } Action::SetToggle { setting: Toggle::ReduceMotion, value } => { let scale = if *value { "0" } else { "1" }; for key in ["animator_duration_scale", "transition_animation_scale", "window_animation_scale"] { shell(serial, &["settings", "put", "global", key, scale], "reduce motion")?; } } Action::SetOrientation(orientation) => { let (gravity, rotation) = android_gravity(*orientation); // Locking the rotation only turns the window content on recent // images; the captured display stays put. Tilting the emulator's // accelerometer turns it for real. Physical devices get the lock. if serial.starts_with("emulator-") { shell(serial, &["settings", "put", "system", "accelerometer_rotation", "1"], "orientation")?; shell(serial, &["cmd", "window", "user-rotation", "free"], "orientation")?; adb(serial, &["emu", "sensor", "set", "acceleration", gravity], "orientation")?; } else { shell(serial, &["cmd", "window", "user-rotation", "lock", rotation], "orientation")?; } } Action::SetLocation { latitude, longitude } => { adb(serial, &["emu", "geo", "fix", &longitude.to_string(), &latitude.to_string()], "location")?; } // The emulator has no "clear": the last fix stays. Action::ClearLocation => {} Action::SetPermission { app_id, permission, decision } => { let permissions = android_permissions(permission); if permissions.is_empty() { return Err(unsupported(action, Platform::Android)); } let verb = if *decision == "grant" { "grant" } else { "revoke" }; for permission in permissions { // Not every app declares every permission of a group. let _ = shell(serial, &["pm", verb, app_id, permission], "permission"); } } Action::OpenUrl(url) => { shell(serial, &["am", "start", "-a", "android.intent.action.VIEW", "-d", url], "open url")?; } Action::LaunchApp(app) => { shell(serial, &["monkey", "-p", app, "-c", "android.intent.category.LAUNCHER", "1"], "launch")?; } Action::TerminateApp(app) => { shell(serial, &["am", "force-stop", app], "terminate")?; } Action::SetToggle { .. } | Action::SetLiquidGlass(_) | Action::SetColorFilter(_) | Action::SendPush { .. } => { return Err(unsupported(action, Platform::Android)); } } Ok(()) } /// The current settings and foreground app. What can't be read stays unknown. pub fn read(ready: &Ready, platform: Platform, device: &str) -> (Settings, Option) { match platform { Platform::Ios => (read_ios(ready, device), None), Platform::Android => read_android(device), } } fn read_ios(ready: &Ready, udid: &str) -> Settings { let ui = |option: &str| simctl(udid, &["ui", option], option).ok().map(|out| out.trim().to_lowercase()); let appearance = ui("appearance"); let content_size = ui("content_size"); let contrast = ui("increase_contrast"); let status: Option> = ax_settings(ready, udid, &["status"]).ok().and_then(|text| serde_json::from_str(&text).ok()); let on_off = |key: &str| -> Option { match status.as_ref()?.get(key)?.as_str() { "on" => Some(true), "off" => Some(false), _ => None, } }; Settings { dark: match appearance.as_deref() { Some("dark") => Some(true), Some("light") => Some(false), _ => None, }, text_size: content_size.as_deref().filter(|size| !size.is_empty()).map(text_size_from_ios), increase_contrast: contrast.map(|value| value == "enabled"), reduce_motion: on_off("reduce-motion"), reduce_transparency: on_off("reduce-transparency"), show_borders: on_off("show-borders"), voice_over: on_off("voiceover"), liquid_glass_tinted: match status.as_ref().and_then(|status| status.get("liquid-glass")).map(String::as_str) { Some("tinted") => Some(true), Some("clear") => Some(false), _ => None, }, color_filter: status.as_ref().and_then(|status| status.get("color-filter")).and_then(|value| ColorFilter::from_key(value)), network_enabled: None, } } fn read_android(serial: &str) -> (Settings, Option) { let read = |args: &'static [&'static str]| { let serial = serial.to_string(); std::thread::spawn(move || shell(&serial, args, args[0]).ok().map(|out| out.trim().to_string())) }; let night = read(&["cmd", "uimode", "night"]); let font = read(&["settings", "get", "system", "font_scale"]); let animator = read(&["settings", "get", "global", "animator_duration_scale"]); let wifi = read(&["settings", "get", "global", "wifi_on"]); // `dumpsys window windows` stopped printing the focus on API 36; the full dump still does. let focus = read(&["dumpsys", "window"]); let [night, font, animator, wifi, focus] = [night, font, animator, wifi, focus].map(|handle| handle.join().ok().flatten()); let scale = font.as_deref().filter(|value| *value != "null").and_then(|value| value.parse::().ok()); let settings = Settings { dark: match night.as_deref() { Some(text) if text.contains("yes") => Some(true), Some(text) if text.contains("no") => Some(false), _ => None, }, text_size: scale.map(text_size_from_android), reduce_motion: animator .as_deref() .filter(|value| *value != "null") .and_then(|value| value.parse::().ok()) .map(|value| value == 0.), network_enabled: match wifi.as_deref() { Some("1") => Some(true), Some("0") => Some(false), _ => None, }, ..Settings::default() }; (settings, focus.as_deref().and_then(focused_package).map(|id| ForegroundApp { id })) } /// The package of the focused window in `dumpsys window`. fn focused_package(dump: &str) -> Option { for line in dump.lines() { let line = line.trim(); let Some(rest) = line.strip_prefix("mCurrentFocus=").or_else(|| line.strip_prefix("mFocusedApp=")) else { continue; }; // `Window{4c5e u0 com.example/com.example.Main}` or an ActivityRecord. let Some(start) = rest.find(" u") else { continue }; let after = &rest[start + 2..]; let after = after.trim_start_matches(|c: char| c.is_ascii_digit()).trim_start(); if let Some(package) = after.split('/').next().filter(|package| !package.is_empty() && after.contains('/')) { return Some(package.split_whitespace().next().unwrap_or(package).to_string()); } } None } #[cfg(test)] mod tests { use super::{focused_package, text_size_from_android, text_size_from_ios}; use crate::device::TextSize; #[::core::prelude::v1::test] fn reads_the_focused_package() { let dump = " mCurrentFocus=Window{4c5e0e9 u0 com.google.android.apps.nexuslauncher/com.google.android.apps.nexuslauncher.NexusLauncherActivity}\n"; assert_eq!(focused_package(dump).as_deref(), Some("com.google.android.apps.nexuslauncher")); let dump = " mFocusedApp=ActivityRecord{1a2b u0 com.example/.Main t12}"; assert_eq!(focused_package(dump).as_deref(), Some("com.example")); assert_eq!(focused_package("mCurrentFocus=null"), None); } #[::core::prelude::v1::test] fn maps_text_sizes_onto_four_steps() { assert_eq!(text_size_from_ios("large"), TextSize::Default); assert_eq!(text_size_from_ios("accessibility-medium"), TextSize::ExtraLarge); assert_eq!(text_size_from_ios("extra-large"), TextSize::Large); assert_eq!(text_size_from_android(0.85), TextSize::Small); assert_eq!(text_size_from_android(1.0), TextSize::Default); assert_eq!(text_size_from_android(1.3), TextSize::ExtraLarge); } }