//! 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<String> {
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<String> {
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<String> {
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<String> {
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<ForegroundApp>) {
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<std::collections::HashMap<String, String>> =
ax_settings(ready, udid, &["status"]).ok().and_then(|text| serde_json::from_str(&text).ok());
let on_off = |key: &str| -> Option<bool> {
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<ForegroundApp>) {
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::<f64>().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::<f64>().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<String> {
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);
}
}