Vectrum
Sign In
388 lines · 17.2 KB · Rust
//! 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);
    }
}