diff --git a/.github/workflows/v2-tests.yml b/.github/workflows/v2-tests.yml index 7256700..ff25bac 100644 --- a/.github/workflows/v2-tests.yml +++ b/.github/workflows/v2-tests.yml @@ -148,6 +148,10 @@ jobs: working-directory: v2 run: npm run test:remote-paste + - name: Playback and expert shell regressions + working-directory: v2 + run: npm run test:playback-shell + frontend-mobile: name: Mobile frontend type check runs-on: ubuntu-latest diff --git a/CLAUDE.md b/CLAUDE.md index 07d1ce5..58be9f4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -62,7 +62,7 @@ These are load-bearing — break them and the safety story falls over. - **App lists live in JSON, not in code.** Add/edit packages in `v2/crates/core/data/app-lists/{common,shield,googletv}.json`. They are embedded at compile time via `include_str!` in `crates/core/src/commands/loader.rs` — editing the JSON requires a rebuild. Never hard-code packages in Rust. - **Snapshots are versioned.** `schema_version == 0` is rejected. `schema_version > current` is rejected. Bump the constant in `engine/snapshot.rs` when the schema changes and write an explicit migration. - **Snapshot reads are path-confined** to `snapshot_dir` via `canonicalize` + `starts_with`. Keep that check on any new read path that takes a user-supplied snapshot location — zip-slip / traversal protection is the same pattern in `adb/install.rs`. -- **The do-not-disable list is mandatory.** `engine::safety::classify` / `is_never_disable` must gate every disable code path (apply-snapshot, optimize wizard, memory-table Disable button, stock-launcher wizard, panic-recovery's inverse, …). Bypassing it bricks devices. +- **The do-not-disable list is mandatory for curated actions.** `engine::safety::classify` / `is_never_disable` must gate every typed disable code path (apply-snapshot, optimize wizard, memory-table Disable button, stock-launcher wizard, panic-recovery's inverse, …). Desktop expert shell is an explicit opt-in exception: arbitrary shell expressions can bypass its lexical checks. It must remain bounded, clearly warn about destructive effects, and never claim the curated actions' safety guarantee. Mobile does not expose it. - **Tauri commands return `Result`.** The error type is serialized to the frontend. Convert with `.map_err(|e| e.to_string())`. - **Frontend is Svelte 5 runes** (`$state`, `$derived`, `$effect`, `$props`) on SvelteKit in SPA mode (`adapter-static`). No legacy stores. @@ -92,7 +92,7 @@ The Linux runner needs `libwebkit2gtk-4.1-dev libssl-dev libgtk-3-dev libayatana The README walkthrough (`v2/screenshots/gallery.gif`) is generated, not hand-captured. **When you change v2 UI in a way that alters any captured screen, regenerate it and commit the result** — don't let the gallery drift from the real app: ``` -cd v2 && npm run screenshots # captures all 12 screens (demo data, dark theme) + rebuilds the GIF +cd v2 && npm run screenshots # captures 15 screens in both themes and rebuilds both GIFs ``` It runs offline against the demo fixture layer (`src/lib/demo-mock.ts`, gated behind `VITE_DEMO=1`) — no device needed. Most changes (CSS, layout, copy, rows) flow through with no tooling edits. Two cases need a touch-up: diff --git a/v2/ATVTOOLS-PARITY.md b/v2/ATVTOOLS-PARITY.md index e69c41b..ff4f45b 100644 --- a/v2/ATVTOOLS-PARITY.md +++ b/v2/ATVTOOLS-PARITY.md @@ -23,14 +23,16 @@ Research notes: aTV Tools is a **phone/tablet companion app** (Android 8.1+/iOS | Screen recording | ✅ (Pro, no DRM content) | ❌ | | Remote control / D-pad / mouse | ✅ (Pro) | partial (D-pad + typing, no mouse) | | **Send text to TV (type from keyboard)** | ✅ | ✅ | -| Permissions grant/revoke | ✅ | ❌ | +| Permissions grant/revoke | ✅ | ✅ | | Bulk cache clear | ✅ | ✅ | | Running apps + force-stop | ✅ | ✅ | -| Resource monitor | CPU/RAM/net/storage | RAM/temp/storage/display (no CPU/net) | -| Shell runner with bookmarks | ✅ | ❌ | +| Resource monitor | CPU/RAM/net/storage | ✅ CPU/RAM/net/temp/storage/display | +| Shell runner with bookmarks | ✅ | ✅ | | Screen mirroring / gamepad / media remote | ✅ (phone-centric) | — (out of scope for desktop) | | Open source / free | ❌ | ✅ | +**Playback report** (Playback tab, unreleased) shows codec configuration, reported HDR/display modes, and audio policy. These observations do not establish runtime decoder support, acceleration, or playback quality. + **Bottom line:** we beat aTV Tools on the *debloat/optimize/safety* core, they beat us on *general device utilities*. The gaps worth closing are the utilities that complement debloating; the phone-centric features aren't our product. ## Prioritized roadmap @@ -57,9 +59,9 @@ Shape: `adb -s X exec-out screencap -p > local.png`, save to a user folder, show **5. Force-stop** on memory-table rows (`am force-stop `) — trivial, pairs with the existing Disable button. **6. Send text to TV** — `input text ''` for typing Wi-Fi passwords/searches from the desktop keyboard. Small input box on the device header. (Escape carefully; relates to the package-validation work.) -**7. Shell runner with bookmarks** — an "Advanced" tab: command input → runs via the driver, shows combined output; bookmark list persisted locally. The catch-all that made aTV Tools sticky. +**7. Shell runner with bookmarks** — implemented on the unreleased desktop Shell tab. Explicit expert acknowledgment → editor → Run → bounded stdout/stderr. Presets/bookmarks populate the editor only. The lexical gate catches obvious protected-package commands but can be bypassed by shell expressions; this is an explicit exception to curated-action safety. Mobile shell is deferred. **8. Bulk cache clear** — `pm trim-caches 999999999999` (one call, no per-app loop). -**9. CPU + network monitor** — add `top -n1` / `/proc/stat` parse and `/proc/net/dev` deltas to the Health report. +**9. CPU + network monitor** — implemented by `resource_sample` on the unreleased desktop and mobile apps: two device-side samples with `/proc/uptime` elapsed time and per-interface network rates. Independent of the health report; mobile sampling is explicitly requested, not polled. ### P3 — Evaluate later **10. Screen recording** — `screenrecord` (3-min cap, no DRM), pull + save. Nice demo material. diff --git a/v2/CHANGELOG.md b/v2/CHANGELOG.md index 9a5805c..542ee85 100644 --- a/v2/CHANGELOG.md +++ b/v2/CHANGELOG.md @@ -19,10 +19,49 @@ When you add a new section, put it at the top; older releases go below. ## v2-2.2.0-beta.1 -Four reported bugs reach users for the first time, plus safer defaults and a -companion-app overhaul. Three of these fixes were written without the -reporter's hardware in hand — if you filed one of them, please say whether this -build works for you. +Four reported bugs reach users for the first time, plus a Playback tab, an +expert shell, and a companion-app overhaul. Three of those fixes were written +without the reporter's hardware in hand — if you filed one of them, please say +whether this build works for you. + +### Playback + +- **A new Playback tab** reports what the TV says it can do: which video + decoders it advertises, which display modes and HDR formats it lists, and + what its audio passthrough settings are set to. It reads configuration, and + it says so — an entry here is not proof that a codec is registered at + runtime, hardware-accelerated, or good enough to play a given file. +- **Tweaks gained an Audio Passthrough control** — Auto / Never / Always / + Manual, with the per-format allow-list when Manual is selected. A format code + the app doesn't have a name for is kept rather than quietly dropped from the + device's list, one change is written at a time, and every row is re-read from + the device afterwards, so what you see is what the TV reports, not what was + asked for. +- The Health tab now samples CPU load and per-interface network throughput. + Rates are computed from the device's own clock over the sampling window, so + a slow round-trip doesn't inflate them. The companion app samples on demand + rather than polling; reads stay Free and writes need Pro. + +### Expert shell + +- **A Shell tab for everything the curated screens don't cover**, off until you + turn it on and acknowledge what it is. Output and runtime are bounded (256 KiB + per stream, 30 seconds), a non-zero exit and whatever output arrived first are + both kept, and presets and bookmarks only fill the editor — nothing runs until + you press Run. +- It refuses the obvious ways to break a TV from a pasted command, but that + check is an anti-footgun, not a guarantee: a shell expression can get around + it. This is the one place in the app where the do-not-disable list is not + enforced, and it says so on screen. The companion app does not have it. + +### Snapshots + +- **A setting that was never set is now restored as never set.** Snapshots used + to record "unset" and "empty" the same way, so restoring one could not put a + setting back to the device's own default. New snapshots record the difference + and the preview shows what will be reset. Snapshots taken by earlier versions + behave exactly as they did before — an omission in an old file still means + "leave it alone", never "delete it". ### Devices and pairing diff --git a/v2/Cargo.lock b/v2/Cargo.lock index 982aee5..c7a6b2e 100644 --- a/v2/Cargo.lock +++ b/v2/Cargo.lock @@ -4388,6 +4388,7 @@ dependencies = [ "chrono", "ed25519-dalek", "pretty_assertions", + "quick-xml", "regex", "serde", "serde_json", diff --git a/v2/README.md b/v2/README.md index 3f20cf9..d17b933 100644 --- a/v2/README.md +++ b/v2/README.md @@ -25,7 +25,9 @@ This directory is the v2 workspace. v1 (`Shield-Optimizer.ps1` at the repo root) - Lists ADB devices with friendly model names + device-type detection - Full device management: profile, health report, launcher wizard (install/set-default/disable stock), optimize wizard with per-app defaults, full app list with disable/enable/force-stop/uninstall, Tweaks (HDMI-CEC, animations, display scaling), APK sideload, APK backup and cross-device clone, file manager, device screenshots, reboot + recovery, and snapshot save/preview/apply -**Remaining feature gaps vs. aTV Tools:** see [`ATVTOOLS-PARITY.md`](ATVTOOLS-PARITY.md). Headline items not yet shipped: screen recording, shell runner, permissions viewer, CPU + network monitor. +**Remaining feature gaps vs. aTV Tools:** see [`ATVTOOLS-PARITY.md`](ATVTOOLS-PARITY.md). Screen recording and a permissions viewer remain unimplemented. The unreleased desktop adds playback configuration reports, CPU/per-interface network samples, and an opt-in expert shell. Playback and resource reports plus audio policy controls are also available in the mobile companion; mobile does not expose arbitrary shell. + +Expert shell can erase data or make a device unusable. Its obvious-command checks are not a security boundary. Execution stops the local ADB client after 30 seconds or 256 KiB from either output stream, which does not guarantee remote work has stopped. Saved bookmarks stay in local app preferences; commands and output are not added to diagnostics logs. The behavior spec is at [`docs/FEATURES.md`](../docs/FEATURES.md). The ATV Optimizer Android app plan is in [`ATV-OPTIMIZER-ANDROID-PLAN.md`](ATV-OPTIMIZER-ANDROID-PLAN.md). diff --git a/v2/crates/core/Cargo.toml b/v2/crates/core/Cargo.toml index 4d3dcfa..0b1fbc6 100644 --- a/v2/crates/core/Cargo.toml +++ b/v2/crates/core/Cargo.toml @@ -18,6 +18,7 @@ tokio = { version = "1", features = ["io-util", "time", "macros", "rt-multi-thre anyhow = "1" thiserror = "1" regex = "1" +quick-xml = "0.39.4" chrono = { version = "0.4", features = ["serde"] } base64 = "0.22" ed25519-dalek = "2" diff --git a/v2/crates/core/src/adb/driver.rs b/v2/crates/core/src/adb/driver.rs index 330f61b..4a8cd36 100644 --- a/v2/crates/core/src/adb/driver.rs +++ b/v2/crates/core/src/adb/driver.rs @@ -49,6 +49,22 @@ pub struct AdbOutput { pub exit_code: Option, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ShellTermination { + Completed, + OutputLimit, + Timeout, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BoundedShellOutput { + pub stdout: String, + pub stderr: String, + pub exit_code: Option, + pub termination: ShellTermination, +} + impl AdbOutput { /// Returns `true` if exit code was 0. pub fn success(&self) -> bool { @@ -103,6 +119,13 @@ pub trait AdbDriver: Send + Sync { /// Run `adb -s shell `. async fn shell(&self, serial: &str, command: &str) -> AdbResult; + /// Expert shell execution must bound output and runtime at the transport. + async fn shell_bounded(&self, _serial: &str, _command: &str) -> AdbResult { + Err(AdbError::Unsupported { + operation: "shell_bounded", + }) + } + /// Run `adb ` and return raw stdout bytes — for binary output /// like `exec-out screencap -p`, where UTF-8 conversion would corrupt the /// data. Default reports unsupported so mocks without binary needs don't @@ -135,3 +158,31 @@ pub trait AdbDriver: Send + Sync { Err(AdbError::Unsupported { operation: "spawn" }) } } + +#[cfg(test)] +mod bounded_shell_tests { + use super::*; + + struct UnboundedDriver; + + #[async_trait] + impl AdbDriver for UnboundedDriver { + async fn raw(&self, _: &[&str]) -> AdbResult { + panic!("bounded shell must not delegate to raw") + } + + async fn shell(&self, _: &str, _: &str) -> AdbResult { + panic!("bounded shell must not delegate to shell") + } + } + + #[tokio::test] + async fn drivers_without_bounded_execution_are_unsupported() { + assert!(matches!( + UnboundedDriver.shell_bounded("serial", "id").await, + Err(AdbError::Unsupported { + operation: "shell_bounded" + }) + )); + } +} diff --git a/v2/crates/core/src/adb/mod.rs b/v2/crates/core/src/adb/mod.rs index c98afe9..d85fad4 100644 --- a/v2/crates/core/src/adb/mod.rs +++ b/v2/crates/core/src/adb/mod.rs @@ -7,13 +7,16 @@ pub mod remote_input; pub use batch::{batch_command, split_batch, BATCH_SEPARATOR}; pub use batch::{checked_batch_command, parse_checked_batch}; -pub use driver::{AdbByteStream, AdbDriver, AdbError, AdbOutput, AdbResult}; +pub use driver::{ + AdbByteStream, AdbDriver, AdbError, AdbOutput, AdbResult, BoundedShellOutput, ShellTermination, +}; pub use parse::{ parse_active_audio_device, parse_device_list, parse_disabled_packages_output, - parse_display_mode, parse_dumpsys_meminfo, parse_hardware_properties_temp, + parse_display_mode, parse_display_modes, parse_dumpsys_meminfo, parse_hardware_properties_temp, parse_installed_packages_output, parse_ls_output, parse_mdns_services, parse_meminfo_summary, - parse_permission_granted, parse_storage_info, parse_thermal_max_celsius, - parse_total_pss_by_process, parse_usage_stats, AppUsage, DisplayMode, FileEntry, MdnsService, - RamInfo, StorageInfo, MDNS_SERVICE_CONNECT, MDNS_SERVICE_LEGACY, MDNS_SERVICE_PAIRING, + parse_net_dev, parse_permission_granted, parse_proc_stat, parse_storage_info, + parse_thermal_max_celsius, parse_total_pss_by_process, parse_usage_stats, AppUsage, CpuSample, + DisplayMode, FileEntry, MdnsService, NetSample, RamInfo, StorageInfo, MDNS_SERVICE_CONNECT, + MDNS_SERVICE_LEGACY, MDNS_SERVICE_PAIRING, }; pub use remote_input::RemoteInputSession; diff --git a/v2/crates/core/src/adb/parse.rs b/v2/crates/core/src/adb/parse.rs index 69e1f8c..0358148 100644 --- a/v2/crates/core/src/adb/parse.rs +++ b/v2/crates/core/src/adb/parse.rs @@ -451,14 +451,38 @@ pub fn parse_storage_info(df_output: &str) -> StorageInfo { pub struct DisplayMode { pub resolution: Option, pub refresh_hz: Option, - /// Decoded HDR types from `mSupportedHdrTypes=[…]`. Empty = SDR only. + /// Decoded HDR types from `mSupportedHdrTypes=[…]`; empty may mean unavailable. pub hdr_types: Vec, } +fn selected_display_record(dumpsys_display: &str) -> Option<&str> { + // DisplayDeviceInfo.toString emits one line, including nested mode/HDR objects. + let records: std::collections::BTreeSet<_> = dumpsys_display + .lines() + .filter_map(|line| line.split_once("DisplayDeviceInfo{")) + .map(|(_, record)| record.trim()) + .collect(); + if records.is_empty() { + return Some(dumpsys_display); + } + if records.len() == 1 { + return records.first().copied(); + } + let mut defaults = records.into_iter().filter(|record| { + record + .split(',') + .any(|field| field.trim().trim_end_matches('}') == "FLAG_DEFAULT_DISPLAY") + }); + let selected = defaults.next()?; + // ALLOWED_TO_BE_DEFAULT_DISPLAY only indicates eligibility, not selection. + defaults.next().is_none().then_some(selected) +} + /// Parse `dumpsys display` for the active display's resolution + refresh rate + /// HDR capabilities. The active mode id is in DisplayDeviceInfo; supportedModes /// maps id → {width, height, fps}. pub fn parse_display_mode(dumpsys_display: &str) -> DisplayMode { + let dumpsys_display = selected_display_record(dumpsys_display).unwrap_or(""); static MODE_ID: LazyLock = LazyLock::new(|| Regex::new(r"modeId\s+(\d+)").unwrap()); static MODE_ENTRY: LazyLock = LazyLock::new(|| { Regex::new(r"id=(\d+),\s*width=(\d+),\s*height=(\d+),\s*fps=([\d.]+)").unwrap() @@ -514,6 +538,149 @@ pub fn parse_display_mode(dumpsys_display: &str) -> DisplayMode { } } +/// Parse *every* mode from `dumpsys display`'s `supportedModes`, flagging the +/// active one. +/// +/// `parse_display_mode` above answers "what is the panel doing right now" and +/// throws the rest away. The playback report needs the whole list instead — +/// whether a 23.976 Hz mode exists at all is what decides if 24p film can be +/// shown at its native cadence, and that mode is by definition not the active +/// one while the UI is on screen. +/// +/// Duplicate (width, height, fps) triples are collapsed: a display commonly +/// advertises the same mode under several ids, and the caller cares about +/// distinct capabilities, not id count. +pub fn parse_display_modes(dumpsys_display: &str) -> Vec { + use crate::engine::media::DisplayModeEntry; + let dumpsys_display = selected_display_record(dumpsys_display).unwrap_or(""); + + static MODE_ID: LazyLock = LazyLock::new(|| Regex::new(r"modeId\s+(\d+)").unwrap()); + static MODE_ENTRY: LazyLock = LazyLock::new(|| { + Regex::new(r"id=(\d+),\s*width=(\d+),\s*height=(\d+),\s*fps=([\d.]+)").unwrap() + }); + + let active_id = MODE_ID + .captures(dumpsys_display) + .and_then(|c| c.get(1)) + .and_then(|m| m.as_str().parse::().ok()); + + let mut out: Vec = Vec::new(); + for caps in MODE_ENTRY.captures_iter(dumpsys_display) { + let Ok(mode_id) = caps[1].parse::() else { + continue; + }; + let (Ok(width), Ok(height), Ok(fps)) = ( + caps[2].parse::(), + caps[3].parse::(), + caps[4].parse::(), + ) else { + continue; + }; + let fps = (fps * 1000.0).round() / 1000.0; + let active = Some(mode_id) == active_id; + // Same capability under a second id: keep the entry, but let the + // active flag win so the UI can still mark the running mode. + if let Some(existing) = out + .iter_mut() + .find(|m| m.width == width && m.height == height && m.fps == fps) + { + existing.active |= active; + continue; + } + out.push(DisplayModeEntry { + width, + height, + fps, + active, + }); + } + out.sort_by(|a, b| { + (b.width, b.height).cmp(&(a.width, a.height)).then( + b.fps + .partial_cmp(&a.fps) + .unwrap_or(std::cmp::Ordering::Equal), + ) + }); + out +} + +/// Aggregate CPU counters from one `/proc/stat` sample. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct CpuSample { + /// Jiffies spent doing anything other than idle/iowait. + pub busy: u64, + /// Jiffies across every state, idle included. + pub total: u64, +} + +/// Parse the aggregate `cpu` line of `/proc/stat`. +/// +/// Fields are: user nice system idle iowait irq softirq steal guest guest_nice. +/// `idle` and `iowait` (indices 3 and 4) count as not-busy; everything else is +/// busy. `guest` time is already included in `user`, so summing every field +/// would double-count it — the total stops at `steal`. +pub fn parse_proc_stat(proc_stat: &str) -> Option { + let line = proc_stat + .lines() + .map(str::trim) + .find(|l| l.starts_with("cpu ") || *l == "cpu")?; + let values: Vec = line + .split_whitespace() + .skip(1) + .take(8) + .map(|v| v.parse::()) + .collect::>() + .ok()?; + if values.len() < 4 { + return None; + } + let total = values + .iter() + .try_fold(0u64, |sum, value| sum.checked_add(*value))?; + let idle = values[3].checked_add(values.get(4).copied().unwrap_or(0))?; + Some(CpuSample { + busy: total - idle, + total, + }) +} + +/// Byte counters for one network interface. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct NetSample { + pub rx_bytes: u64, + pub tx_bytes: u64, +} + +/// Keep interfaces separate: tunnel traffic can also appear on its physical link. +pub fn parse_net_dev(proc_net_dev: &str) -> std::collections::BTreeMap { + let mut samples = std::collections::BTreeMap::new(); + for line in proc_net_dev.lines() { + let Some((iface, counters)) = line.split_once(':') else { + continue; + }; + let iface = iface.trim(); + if iface.is_empty() || iface == "lo" || iface.contains(char::is_whitespace) { + continue; + } + let fields: Vec<&str> = counters.split_whitespace().collect(); + // rx_bytes is field 0; tx_bytes is field 8. + if fields.len() < 9 { + continue; + } + let (Ok(rx), Ok(tx)) = (fields[0].parse::(), fields[8].parse::()) else { + continue; + }; + samples.insert( + iface.to_string(), + NetSample { + rx_bytes: rx, + tx_bytes: tx, + }, + ); + } + samples +} + /// Parse `dumpsys audio` for the first `Devices: ` row — the current /// active output device. Returns the uppercased label (HDMI / BUILTIN_SPEAKER /// / etc.) or `None` if the section isn't present. @@ -784,6 +951,147 @@ DisplayDeviceInfo{"Built-in Screen": uniqueId="local:0", 3840 x 2160, modeId 20, assert_eq!(mode.hdr_types, vec!["Dolby Vision", "HDR10", "HDR10+"]); } + #[test] + fn parses_every_display_mode_and_flags_the_active_one() { + // Same shape as the real dumpsys, plus a 23.976 mode — the one the + // playback report exists to find, and never the active one in practice. + let input = r#" +DisplayDeviceInfo{"Built-in Screen": 3840 x 2160, modeId 20, defaultModeId 20, supportedModes [{id=1, width=3840, height=2160, fps=23.976023}, {id=2, width=3840, height=2160, fps=29.97003}, {id=20, width=3840, height=2160, fps=59.94006}], ...} +"#; + let modes = parse_display_modes(input); + assert_eq!(modes.len(), 3); + // Sorted by resolution then fps, descending. + assert_eq!(modes[0].fps, 59.94); + assert_eq!(modes[2].fps, 23.976); + assert!(modes[0].active); + assert!(!modes[1].active && !modes[2].active); + assert!(modes.iter().filter(|m| m.is_film_rate()).count() == 1); + } + + #[test] + fn duplicate_modes_collapse_but_keep_the_active_flag() { + // The same capability advertised under two ids, the *second* of which + // is the active one — the flag has to survive the merge. + let input = "modeId 7, supportedModes [{id=3, width=1920, height=1080, fps=60.0}, \ + {id=7, width=1920, height=1080, fps=60.0}]"; + let modes = parse_display_modes(input); + assert_eq!(modes.len(), 1); + assert!(modes[0].active); + } + + #[test] + fn display_modes_degrade_to_empty_rather_than_guessing() { + assert!(parse_display_modes("").is_empty()); + assert!(parse_display_modes("no modes here").is_empty()); + // Modes present but no active id: every mode is reported, none active. + let modes = + parse_display_modes("supportedModes [{id=1, width=3840, height=2160, fps=24.0}]"); + assert_eq!(modes.len(), 1); + assert!(!modes[0].active); + } + + #[test] + fn display_reports_scope_modes_and_hdr_to_the_same_default_record() { + let input = concat!( + "DisplayDeviceInfo{\"Auxiliary\": modeId 1, supportedModes [{id=1, width=1920, height=1080, fps=24.0}], HdrCapabilities{mSupportedHdrTypes=[1]}}\n", + " mInfo=DisplayDeviceInfo{\"Main\": modeId 1, supportedModes [{id=1, width=3840, height=2160, fps=60.0}], HdrCapabilities{mSupportedHdrTypes=[2]}, FLAG_DEFAULT_DISPLAY}\n", + "Logical display modes [{id=1, width=1280, height=720, fps=24.0}]" + ); + let active = parse_display_mode(input); + assert_eq!(active.resolution.as_deref(), Some("3840x2160")); + assert_eq!(active.refresh_hz, Some(60.0)); + assert_eq!(active.hdr_types, vec!["HDR10"]); + let modes = parse_display_modes(input); + assert_eq!(modes.len(), 1); + assert!(modes[0].active); + assert!(!modes[0].is_film_rate()); + } + + #[test] + fn multiple_displays_without_a_unique_default_are_unavailable() { + for flag in [ + "", + ", FLAG_ALLOWED_TO_BE_DEFAULT_DISPLAY", + ", FLAG_DEFAULT_DISPLAY", + ] { + let input = format!( + "DisplayDeviceInfo{{\"One\": modeId 1, supportedModes [{{id=1, width=3840, height=2160, fps=60.0}}], HdrCapabilities{{mSupportedHdrTypes=[2]}}{flag}}}\n\ + DisplayDeviceInfo{{\"Two\": modeId 1, supportedModes [{{id=1, width=1920, height=1080, fps=24.0}}]{flag}}}" + ); + assert!(parse_display_modes(&input).is_empty()); + let active = parse_display_mode(&input); + assert_eq!(active.resolution, None); + assert_eq!(active.refresh_hz, None); + assert!(active.hdr_types.is_empty()); + } + } + + #[test] + fn repeated_single_display_records_do_not_create_ambiguity() { + let record = "DisplayDeviceInfo{\"Main\": modeId 1, supportedModes [{id=1, width=3840, height=2160, fps=60.0}], FLAG_ALLOWED_TO_BE_DEFAULT_DISPLAY}"; + let input = format!("{record}\n mInfo={record}"); + assert_eq!(parse_display_modes(&input).len(), 1); + assert_eq!(parse_display_mode(&input).refresh_hz, Some(60.0)); + } + + #[test] + fn one_eligible_display_does_not_prove_the_current_default() { + let input = concat!( + "DisplayDeviceInfo{\"One\": modeId 1, supportedModes [{id=1, width=3840, height=2160, fps=60.0}], FLAG_ALLOWED_TO_BE_DEFAULT_DISPLAY}\n", + "DisplayDeviceInfo{\"Two\": modeId 2, supportedModes [{id=2, width=1920, height=1080, fps=24.0}]}" + ); + assert!(parse_display_modes(input).is_empty()); + assert_eq!(parse_display_mode(input).refresh_hz, None); + } + + #[test] + fn proc_stat_counts_iowait_as_idle() { + // user nice system idle iowait irq softirq steal + let sample = + parse_proc_stat("cpu 100 20 30 700 50 5 5 0\ncpu0 1 2 3 4 5 6 7 8\n").unwrap(); + assert_eq!(sample.total, 910); + // idle(700) + iowait(50) are not busy. + assert_eq!(sample.busy, 160); + } + + #[test] + fn proc_stat_ignores_per_core_lines_and_missing_input() { + // `cpu0` must not be mistaken for the aggregate `cpu` line. + assert_eq!(parse_proc_stat("cpu0 1 2 3 4 5 6 7 8"), None); + assert_eq!(parse_proc_stat(""), None); + assert_eq!(parse_proc_stat("cpu 1 2"), None); + } + + #[test] + fn net_dev_preserves_interfaces_and_skips_loopback() { + let input = "\ +Inter-| Receive | Transmit\n\ + face |bytes packets errs drop fifo frame compressed multicast|bytes packets errs drop fifo colls carrier compressed\n\ + lo: 999999 100 0 0 0 0 0 0 999999 100 0 0 0 0 0 0\n\ + eth0: 100000 500 0 0 0 0 0 0 20000 300 0 0 0 0 0 0\n\ + wlan0: 50000 250 0 0 0 0 0 0 10000 150 0 0 0 0 0 0\n"; + let samples = parse_net_dev(input); + assert_eq!(samples.len(), 2); + assert_eq!(samples["eth0"].rx_bytes, 100_000); + assert_eq!(samples["wlan0"].tx_bytes, 10_000); + } + + #[test] + fn net_dev_with_no_usable_interfaces_is_empty() { + // Header only, loopback only, and garbage all mean "no reading" — + // distinct from a real zero, which would misreport as idle traffic. + assert!(parse_net_dev("").is_empty()); + assert!(parse_net_dev("Inter-| Receive | Transmit").is_empty()); + assert!(parse_net_dev(" lo: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16").is_empty()); + assert!(parse_net_dev("eth0: invalid 2 3 4 5 6 7 8 9").is_empty()); + } + + #[test] + fn proc_stat_rejects_invalid_or_overflowing_counters() { + assert_eq!(parse_proc_stat("cpu 1 bad 3 4"), None); + assert_eq!(parse_proc_stat("cpu 18446744073709551615 1 0 0"), None); + } + #[test] fn parses_meminfo_summary_kb() { let input = "\ diff --git a/v2/crates/core/src/commands/health.rs b/v2/crates/core/src/commands/health.rs index 310bb76..534b0e9 100644 --- a/v2/crates/core/src/commands/health.rs +++ b/v2/crates/core/src/commands/health.rs @@ -4,9 +4,13 @@ use serde::Serialize; use tauri::State; use crate::adb::{ - batch_command, parse_active_audio_device, parse_display_mode, parse_hardware_properties_temp, - parse_meminfo_summary, parse_storage_info, parse_thermal_max_celsius, - parse_total_pss_by_process, split_batch, DisplayMode, RamInfo, StorageInfo, + batch_command, parse_active_audio_device, parse_display_mode, parse_display_modes, + parse_hardware_properties_temp, parse_meminfo_summary, parse_net_dev, parse_proc_stat, + parse_storage_info, parse_thermal_max_celsius, parse_total_pss_by_process, split_batch, + DisplayMode, RamInfo, StorageInfo, +}; +use crate::engine::media::{ + build_capabilities, parse_media_codecs, surround_mode, MediaCapabilities, }; use super::AppState; @@ -42,6 +46,170 @@ pub async fn health_report( health_report_for(state.inner(), &serial).await } +/// Every `media_codecs*.xml` the platform might ship, in one `cat`. +/// +/// The file is split across vendor / system / product partitions and its exact +/// path differs per build, so globbing all the plausible locations at once is +/// both cheaper and more portable than probing them in turn. Unmatched globs +/// and missing files must not discard readable entries from the other paths. +/// The trailing no-op preserves that partial output in a checked batch. +/// +/// `/vendor/odm/etc` and `/odm/etc` are not optional extras: they are where a +/// Shield TV Pro (mdarcy, Android 11) actually keeps the file — `/vendor/etc` +/// holds only `media_profiles` there. Verified against hardware; dropping them +/// makes the whole report read as "decoder list unavailable" on the device +/// this app is named after. +const MEDIA_CODECS_GLOB: &str = "cat /vendor/etc/media_codecs*.xml \ + /vendor/odm/etc/media_codecs*.xml /odm/etc/media_codecs*.xml \ + /system/etc/media_codecs*.xml /etc/media_codecs*.xml \ + /product/etc/media_codecs*.xml; :"; + +/// Device-reported codec configuration, display modes, and audio settings. +#[tauri::command] +pub async fn media_report( + state: State<'_, AppState>, + serial: String, +) -> Result { + media_report_for(state.inner(), &serial).await +} + +async fn media_report_for(state: &AppState, serial: &str) -> Result { + use std::time::Duration; + use tokio::time::timeout; + + let adb = state.adb_snapshot().await; + let cmd = crate::adb::batch::checked_batch_command(&[ + "dumpsys display", + MEDIA_CODECS_GLOB, + "settings get global encoded_surround_output", + "settings get global encoded_surround_output_enabled_formats", + "settings get secure match_content_frame_rate", + ]); + + let batched = timeout(Duration::from_secs(30), adb.shell(serial, &cmd)) + .await + .map_err(|_| "Playback report timed out after 30 seconds.".to_string())? + .map_err(|e| format!("media report: {e}"))? + .stdout; + + let sections = crate::adb::batch::parse_checked_batch(&batched, 5, &[2, 3, 4])?; + + // A `settings get` on an unset key prints the literal "null"; treat that + // and an empty section as "not set" so the engine sees `None` either way. + let setting = |raw: &str| { + let v = raw.trim(); + (!v.is_empty() && v != "null").then(|| v.to_string()) + }; + + Ok(build_capabilities( + &parse_media_codecs(§ions[1]), + parse_display_mode(§ions[0]).hdr_types, + parse_display_modes(§ions[0]), + surround_mode( + setting(§ions[2]).as_deref(), + setting(§ions[3]).as_deref(), + ), + setting(§ions[4]), + )) +} + +/// CPU load and network throughput over a short sampling window. +#[derive(Serialize)] +pub struct ResourceSample { + /// Aggregate CPU busy time across the window, 0-100. `None` when + /// `/proc/stat` was unreadable or the counters did not advance. + pub cpu_percent: Option, + pub interfaces: Vec, + pub interval_ms: Option, +} + +#[derive(Serialize)] +pub struct InterfaceRate { + pub name: String, + pub rx_bytes_per_s: Option, + pub tx_bytes_per_s: Option, +} + +/// `resource_sample` — CPU % and network throughput. +/// +/// Deliberately *not* folded into `health_report`: rate counters need two +/// reads a second apart, and charging every health refresh an extra second of +/// wall clock to carry two numbers would be a bad trade. The Health tab calls +/// this separately without re-running the whole report. +#[tauri::command] +pub async fn resource_sample( + state: State<'_, AppState>, + serial: String, +) -> Result { + resource_sample_for(state.inner(), &serial).await +} + +async fn resource_sample_for(state: &AppState, serial: &str) -> Result { + use std::time::Duration; + use tokio::time::timeout; + + let adb = state.adb_snapshot().await; + let cmd = crate::adb::checked_batch_command(&[ + "cat /proc/uptime", + "cat /proc/stat", + "cat /proc/net/dev", + "sleep 1", + "cat /proc/uptime", + "cat /proc/stat", + "cat /proc/net/dev", + ]); + + let batched = timeout(Duration::from_secs(30), adb.shell(serial, &cmd)) + .await + .map_err(|_| "Resource sample timed out after 30 seconds.".to_string())? + .map_err(|e| format!("resource sample: {e}"))? + .stdout; + + let sections = crate::adb::parse_checked_batch(&batched, 7, &[3])?; + let uptime = |raw: &str| raw.split_whitespace().next()?.parse::().ok(); + let elapsed = uptime(§ions[0]) + .zip(uptime(§ions[4])) + .filter(|(a, b)| a.is_finite() && b.is_finite() && *a >= 0.0 && b > a) + .map(|(a, b)| b - a); + let cpu_percent = match (parse_proc_stat(§ions[1]), parse_proc_stat(§ions[5])) { + (Some(a), Some(b)) => b + .total + .checked_sub(a.total) + .zip(b.busy.checked_sub(a.busy)) + .filter(|(total, busy)| *total > 0 && busy <= total) + .map(|(total, busy)| ((busy as f64 / total as f64) * 1000.0).round() / 10.0), + _ => None, + }; + + let first = parse_net_dev(§ions[2]); + let second = parse_net_dev(§ions[6]); + let names: std::collections::BTreeSet<_> = first.keys().chain(second.keys()).collect(); + let interfaces = names + .into_iter() + .map(|name| { + let rates = first.get(name).zip(second.get(name)).zip(elapsed); + let rate = |later: u64, earlier: u64, seconds: f64| { + later + .checked_sub(earlier) + .map(|delta| (delta as f64 / seconds).round() as u64) + }; + InterfaceRate { + name: name.clone(), + rx_bytes_per_s: rates + .and_then(|((a, b), seconds)| rate(b.rx_bytes, a.rx_bytes, seconds)), + tx_bytes_per_s: rates + .and_then(|((a, b), seconds)| rate(b.tx_bytes, a.tx_bytes, seconds)), + } + }) + .collect(); + + Ok(ResourceSample { + cpu_percent, + interfaces, + interval_ms: elapsed.map(|seconds| (seconds * 1000.0).round() as u64), + }) +} + /// `app_list_for_device` — return the merged app list for a given device type. /// Read-only; doesn't touch the device. Used by the Profile view. #[tauri::command] @@ -319,6 +487,296 @@ mod tests { assert_eq!(report.audio_device, None); } + const DISPLAY: &str = "DisplayDeviceInfo{\"Built-in Screen\": 3840 x 2160, modeId 20, \ +supportedModes [{id=1, width=3840, height=2160, fps=23.976023}, {id=20, width=3840, height=2160, \ +fps=59.94006}], HdrCapabilities{mSupportedHdrTypes=[1, 2, 3]}}"; + const CODECS: &str = r#" + + +"#; + + #[test] + fn the_codec_glob_covers_the_path_a_real_shield_uses() { + // Regression guard, found on hardware: a Shield TV Pro (mdarcy, + // Android 11) keeps media_codecs.xml under /vendor/odm/etc — its + // /vendor/etc has only media_profiles. Dropping this path makes the + // whole report degrade to "decoder list unavailable" on the exact + // device this app targets. + assert!(MEDIA_CODECS_GLOB.contains("/vendor/odm/etc/media_codecs")); + assert!(MEDIA_CODECS_GLOB.contains("/vendor/etc/media_codecs")); + } + + fn media_batch(sections: &[(&str, i32)]) -> String { + sections + .iter() + .map(|(body, status)| format!("{body}\n{}{status}", crate::adb::batch::BATCH_STATUS)) + .collect::>() + .join(&format!("\n{BATCH_SEPARATOR}\n")) + } + + #[tokio::test] + async fn media_report_rejects_failed_and_truncated_settings_reads() { + for index in 2..5 { + let mut sections = [ + (DISPLAY, 0), + (CODECS, 0), + ("null", 0), + ("null", 0), + ("null", 0), + ]; + sections[index] = ("", 1); + let state = + state_with(MockAdb::default().on_shell(BATCH_SEPARATOR, &media_batch(§ions))); + assert!(media_report_for(&state, "serial") + .await + .unwrap_err() + .contains("failed")); + + sections[index] = ("Error: settings unavailable", 0); + let state = + state_with(MockAdb::default().on_shell(BATCH_SEPARATOR, &media_batch(§ions))); + assert!(media_report_for(&state, "serial") + .await + .unwrap_err() + .contains("reported an error")); + } + let complete = media_batch(&[ + (DISPLAY, 0), + (CODECS, 0), + ("null", 0), + ("null", 0), + ("null", 0), + ]); + let truncated = complete + .rsplit_once(crate::adb::batch::BATCH_STATUS) + .unwrap() + .0; + let state = state_with(MockAdb::default().on_shell(BATCH_SEPARATOR, truncated)); + assert!(media_report_for(&state, "serial") + .await + .unwrap_err() + .contains("did not complete")); + } + + #[tokio::test] + async fn media_report_keeps_codecs_when_optional_display_read_fails() { + let state = state_with(MockAdb::default().on_shell( + BATCH_SEPARATOR, + &media_batch(&[("", 1), (CODECS, 0), ("null", 0), ("null", 0), ("null", 0)]), + )); + let caps = media_report_for(&state, "serial").await.unwrap(); + assert!(caps.modes.is_empty()); + assert!(caps.video.iter().any(|v| v.advertised)); + } + + #[cfg(unix)] + #[test] + fn codec_glob_preserves_readable_xml_among_missing_paths() { + let directory = tempfile::tempdir().unwrap(); + std::fs::write(directory.path().join("readable.xml"), CODECS).unwrap(); + let command = MEDIA_CODECS_GLOB.replacen("cat ", "cat readable.xml ", 1); + let output = std::process::Command::new("sh") + .current_dir(directory.path()) + .args(["-c", &crate::adb::batch::checked_batch_command(&[&command])]) + .output() + .unwrap(); + let text = String::from_utf8(output.stdout).unwrap(); + let sections = crate::adb::batch::parse_checked_batch(&text, 1, &[0]).unwrap(); + assert!(!parse_media_codecs(§ions[0]).is_empty()); + } + + #[tokio::test] + async fn media_report_decodes_every_section_from_one_round_trip() { + let mock = MockAdb::default().on_shell( + BATCH_SEPARATOR, + &media_batch(&[(DISPLAY, 0), (CODECS, 0), ("3", 0), ("5,6,14", 0), ("2", 0)]), + ); + let log = mock.shell_log(); + let state = state_with(mock); + + let caps = media_report_for(&state, "serial") + .await + .unwrap_or_else(|e| panic!("media report: {e}")); + + assert_eq!(caps.hdr_types, ["Dolby Vision", "HDR10", "HLG"]); + assert_eq!(caps.modes.len(), 2); + assert!(caps.modes.iter().any(|m| m.is_film_rate())); + assert_eq!(caps.match_content_frame_rate.as_deref(), Some("2")); + assert_eq!(caps.audio.mode, crate::engine::SurroundMode::Manual); + assert!(caps + .audio + .enabled_formats + .iter() + .any(|f| f.contains("TrueHD"))); + + let hevc = caps.video.iter().find(|v| v.mime == "video/hevc").unwrap(); + assert!(hevc.advertised && hevc.acceleration_unknown); + let av1 = caps.video.iter().find(|v| v.mime == "video/av01").unwrap(); + assert!(av1.advertised && av1.software); + + let calls = log.lock().unwrap(); + assert_eq!( + calls.len(), + 1, + "the report must cost one round-trip: {calls:?}" + ); + assert!(calls[0].contains("media_codecs")); + } + + #[tokio::test] + async fn media_report_treats_a_null_setting_as_unset() { + // `settings get` prints the literal "null" for an absent key — it must + // not reach the engine as the string "null". + let state = state_with(MockAdb::default().on_shell( + BATCH_SEPARATOR, + &media_batch(&[ + (DISPLAY, 0), + (CODECS, 0), + ("null", 0), + ("null", 0), + ("null", 0), + ]), + )); + let caps = media_report_for(&state, "serial").await.unwrap(); + assert_eq!(caps.match_content_frame_rate, None); + assert_eq!(caps.audio.mode, crate::engine::SurroundMode::Unset); + assert_eq!(caps.audio.raw_formats, None); + } + + #[tokio::test] + async fn media_report_survives_an_unreadable_codec_file() { + // Everything else still renders, and nothing is claimed about codecs. + let state = state_with(MockAdb::default().on_shell( + BATCH_SEPARATOR, + &media_batch(&[(DISPLAY, 0), ("", 0), ("0", 0), ("", 0), ("2", 0)]), + )); + let caps = media_report_for(&state, "serial").await.unwrap(); + assert_eq!(caps.modes.len(), 2); + assert!(caps + .verdicts + .iter() + .any(|v| v.title == "Decoder list unavailable")); + assert!(!caps.verdicts.iter().any(|v| v.title.contains("AV1"))); + } + + #[tokio::test] + async fn media_report_errors_when_the_device_returns_nothing() { + let state = state_with(MockAdb::default().on_shell(BATCH_SEPARATOR, "")); + assert!(media_report_for(&state, "serial").await.is_err()); + } + + const STAT_A: &str = "cpu 100 0 100 800 0 0 0 0"; + const STAT_B: &str = "cpu 200 0 200 1600 0 0 0 0"; + const NET_A: &str = " eth0: 1000 5 0 0 0 0 0 0 500 3 0 0 0 0 0 0"; + const NET_B: &str = " eth0: 3000 9 0 0 0 0 0 0 1500 7 0 0 0 0 0 0"; + + fn resource_batch(parts: &[&str]) -> String { + parts + .iter() + .map(|part| format!("{part}\n{}0", crate::adb::batch::BATCH_STATUS)) + .collect::>() + .join(&format!("\n{BATCH_SEPARATOR}\n")) + } + + #[tokio::test] + async fn resource_sample_derives_rates_from_two_device_side_reads() { + let mock = MockAdb::default().on_shell( + BATCH_SEPARATOR, + &resource_batch(&["100.00 0", STAT_A, NET_A, "", "102.00 0", STAT_B, NET_B]), + ); + let log = mock.shell_log(); + let state = state_with(mock); + + let sample = resource_sample_for(&state, "serial").await.unwrap(); + // busy delta 200, total delta 1000 → 20%. + assert_eq!(sample.cpu_percent, Some(20.0)); + assert_eq!(sample.interval_ms, Some(2000)); + assert_eq!(sample.interfaces[0].name, "eth0"); + assert_eq!(sample.interfaces[0].rx_bytes_per_s, Some(1000)); + assert_eq!(sample.interfaces[0].tx_bytes_per_s, Some(500)); + + let calls = log.lock().unwrap(); + assert_eq!(calls.len(), 1); + assert!( + calls[0].contains("sleep 1"), + "the window must be device-side: {}", + calls[0] + ); + } + + #[tokio::test] + async fn resource_sample_reports_none_rather_than_a_bogus_spike() { + // Counters that went backwards (interface reset / wrap) and identical + // CPU samples must not produce negative or infinite rates. + let state = state_with(MockAdb::default().on_shell( + BATCH_SEPARATOR, + &resource_batch(&["100 0", STAT_A, NET_B, "", "101 0", STAT_A, NET_A]), + )); + let sample = resource_sample_for(&state, "serial").await.unwrap(); + assert_eq!( + sample.cpu_percent, None, + "no elapsed jiffies means no reading" + ); + assert_eq!(sample.interfaces[0].rx_bytes_per_s, None); + assert_eq!(sample.interfaces[0].tx_bytes_per_s, None); + } + + #[tokio::test] + async fn resource_sample_degrades_when_proc_is_unreadable() { + let response = resource_batch(&["", "", "", "", "", "", ""]).replacen( + &format!("{}0", crate::adb::batch::BATCH_STATUS), + &format!("{}1", crate::adb::batch::BATCH_STATUS), + 1, + ); + let state = state_with(MockAdb::default().on_shell(BATCH_SEPARATOR, &response)); + let sample = resource_sample_for(&state, "serial").await.unwrap(); + assert_eq!(sample.cpu_percent, None); + assert!(sample.interfaces.is_empty()); + assert_eq!(sample.interval_ms, None); + } + + #[tokio::test] + async fn resource_sample_keeps_tunnels_separate_and_missing_interfaces_unknown() { + let net_a = format!("{NET_A}\ntun0: 1000 0 0 0 0 0 0 0 500\nwlan0: 100 0 0 0 0 0 0 0 100"); + let net_b = format!("{NET_B}\ntun0: 3000 0 0 0 0 0 0 0 1500\nwlan1: 100 0 0 0 0 0 0 0 100"); + let state = state_with(MockAdb::default().on_shell( + BATCH_SEPARATOR, + &resource_batch(&["100 0", STAT_A, &net_a, "", "101 0", STAT_B, &net_b]), + )); + let sample = resource_sample_for(&state, "serial").await.unwrap(); + assert_eq!(sample.interfaces.len(), 4); + assert_eq!(sample.interfaces[0].rx_bytes_per_s, Some(2000)); + assert_eq!(sample.interfaces[1].rx_bytes_per_s, Some(2000)); + assert_eq!(sample.interfaces[2].rx_bytes_per_s, None); + assert_eq!(sample.interfaces[3].rx_bytes_per_s, None); + } + + #[tokio::test] + async fn resource_sample_rejects_invalid_cpu_deltas_and_elapsed_time() { + for (uptime, stat) in [ + ("99 0", STAT_A), + ("NaN 0", "cpu 300 0 300 500 0 0 0 0"), + ("", "cpu 0 0 0 0"), + ] { + let state = state_with(MockAdb::default().on_shell( + BATCH_SEPARATOR, + &resource_batch(&["100 0", STAT_A, NET_A, "", uptime, stat, NET_B]), + )); + let sample = resource_sample_for(&state, "serial").await.unwrap(); + assert_eq!(sample.cpu_percent, None); + assert_eq!(sample.interval_ms, None); + assert_eq!(sample.interfaces[0].rx_bytes_per_s, None); + } + } + + #[tokio::test] + async fn resource_sample_rejects_truncated_batches() { + let state = state_with( + MockAdb::default().on_shell(BATCH_SEPARATOR, &resource_batch(&["100 0", STAT_A])), + ); + assert!(resource_sample_for(&state, "serial").await.is_err()); + } + #[tokio::test] async fn a_failed_batch_preserves_the_transport_error() { let state = state_with(MockAdb::default().on_shell_err(BATCH_SEPARATOR, "device offline")); diff --git a/v2/crates/core/src/commands/mod.rs b/v2/crates/core/src/commands/mod.rs index cf75380..ac68688 100644 --- a/v2/crates/core/src/commands/mod.rs +++ b/v2/crates/core/src/commands/mod.rs @@ -15,6 +15,7 @@ pub mod optimize; pub mod reboot; pub mod recovery; pub mod screenshot; +pub mod shell; pub mod snapshot; pub mod state; pub mod tuning; @@ -179,6 +180,19 @@ pub mod test_support { #[async_trait] impl AdbDriver for MockAdb { + async fn shell_bounded( + &self, + serial: &str, + command: &str, + ) -> AdbResult { + let output = self.shell(serial, command).await?; + Ok(crate::adb::driver::BoundedShellOutput { + stdout: output.stdout, + stderr: output.stderr, + exit_code: output.exit_code, + termination: crate::adb::driver::ShellTermination::Completed, + }) + } async fn raw(&self, args: &[&str]) -> AdbResult { let command = args.join(" "); self.raw_log.lock().unwrap().push(command.clone()); diff --git a/v2/crates/core/src/commands/shell.rs b/v2/crates/core/src/commands/shell.rs new file mode 100644 index 0000000..8b87d72 --- /dev/null +++ b/v2/crates/core/src/commands/shell.rs @@ -0,0 +1,189 @@ +//! Free-form ADB shell runner. +//! +//! The catch-all escape hatch: anything the curated UI doesn't cover, the user +//! can type here. +//! +//! Every other disable path in the app goes through a typed command that +//! consults `engine::safety`. This one cannot, so it calls +//! `engine::safety::shell_command_blocked` before anything reaches the device +//! — but see that function's docs before trusting it: the check is an +//! anti-footgun that catches accidental destructive commands, **not** a +//! boundary that holds against someone trying to get around it. It is not one, +//! and does not need to be: the user running this app already has `adb shell` +//! against the same device. + +use serde::Serialize; +use tauri::State; + +use crate::engine::shell_command_blocked; +use crate::license::Feature; + +use super::AppState; +use crate::adb::driver::ShellTermination; + +/// Result of one shell invocation. Both streams are surfaced because on-device +/// tools (`pm`, `settings`, `cmd`) split their output across them +/// inconsistently — showing only stdout hides half the failures. +#[derive(Debug, Serialize)] +pub struct ShellRunResult { + pub stdout: String, + pub stderr: String, + pub exit_code: Option, + pub termination: ShellTermination, + /// The safety gate refused to run this. Nothing was sent to the device. + pub blocked: bool, + /// Why it was refused, when `blocked`. + pub blocked_reason: Option, +} + +/// `run_shell` — run an arbitrary command on the device. +#[tauri::command] +pub async fn run_shell( + state: State<'_, AppState>, + serial: String, + command: String, +) -> Result { + state.require_pro(Feature::ShellRunner)?; + run_shell_impl(state.inner(), &serial, &command).await +} + +async fn run_shell_impl( + state: &AppState, + serial: &str, + command: &str, +) -> Result { + let trimmed = command.trim(); + if trimmed.is_empty() { + return Err("Enter a command to run.".into()); + } + + if let Some((package, reason)) = shell_command_blocked(trimmed) { + return Ok(ShellRunResult { + stdout: String::new(), + stderr: String::new(), + exit_code: None, + termination: ShellTermination::Completed, + blocked: true, + blocked_reason: Some(format!( + "Refused: this command could damage {package}, which is on the \ + do-not-disable list. {reason}" + )), + }); + } + + let adb = state.adb_snapshot().await; + let out = adb + .shell_bounded(serial, trimmed) + .await + .map_err(|e| e.to_string())?; + + Ok(ShellRunResult { + stdout: out.stdout, + stderr: out.stderr, + exit_code: out.exit_code, + termination: out.termination, + blocked: false, + blocked_reason: None, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::commands::test_support::{state_with, MockAdb}; + + #[tokio::test] + async fn runs_a_plain_command_and_returns_both_streams() { + let mock = MockAdb::default().on_shell("getprop ro.product.model", "SHIELD Android TV\n"); + let log = mock.shell_log(); + let state = state_with(mock); + + let r = run_shell_impl(&state, "serial", "getprop ro.product.model") + .await + .unwrap(); + assert!(!r.blocked); + assert_eq!(r.stdout.trim(), "SHIELD Android TV"); + assert_eq!(log.lock().unwrap().len(), 1); + } + + #[tokio::test] + async fn a_blocked_command_never_reaches_the_device() { + // The invariant this module exists to hold: refusal happens before the + // driver is touched, so an empty shell log is the assertion that + // matters, not just the flag on the result. + let mock = MockAdb::default(); + let log = mock.shell_log(); + let state = state_with(mock); + + let r = run_shell_impl( + &state, + "serial", + "pm disable-user --user 0 com.android.systemui", + ) + .await + .unwrap(); + assert!(r.blocked); + assert!(r + .blocked_reason + .as_ref() + .unwrap() + .contains("com.android.systemui")); + assert!(r + .blocked_reason + .as_ref() + .unwrap() + .contains("do-not-disable")); + assert!( + log.lock().unwrap().is_empty(), + "a refused command must not be sent to the device" + ); + } + + #[tokio::test] + async fn a_chained_command_hiding_a_disable_is_still_refused_whole() { + // The harmless first statement must not buy the destructive one a ride. + let mock = MockAdb::default(); + let log = mock.shell_log(); + let state = state_with(mock); + + let r = run_shell_impl(&state, "serial", "echo hi; pm uninstall com.android.shell") + .await + .unwrap(); + assert!(r.blocked); + assert!(log.lock().unwrap().is_empty()); + } + + #[tokio::test] + async fn disabling_an_unprotected_package_is_allowed_through() { + let mock = MockAdb::default() + .on_shell("pm disable-user", "Package ... new state: disabled-user\n"); + let state = state_with(mock); + + let r = run_shell_impl( + &state, + "serial", + "pm disable-user --user 0 com.facebook.katana", + ) + .await + .unwrap(); + assert!(!r.blocked); + assert!(r.stdout.contains("disabled-user")); + } + + #[tokio::test] + async fn an_empty_command_is_rejected_before_the_driver() { + let mock = MockAdb::default(); + let log = mock.shell_log(); + let state = state_with(mock); + + assert!(run_shell_impl(&state, "serial", " ").await.is_err()); + assert!(log.lock().unwrap().is_empty()); + } + + #[tokio::test] + async fn a_transport_failure_surfaces_as_an_error_not_an_empty_result() { + let state = state_with(MockAdb::default().on_shell_err("id", "device offline")); + let err = run_shell_impl(&state, "serial", "id").await.unwrap_err(); + assert!(err.contains("device offline")); + } +} diff --git a/v2/crates/core/src/commands/snapshot.rs b/v2/crates/core/src/commands/snapshot.rs index ead9c38..a931717 100644 --- a/v2/crates/core/src/commands/snapshot.rs +++ b/v2/crates/core/src/commands/snapshot.rs @@ -20,28 +20,31 @@ use super::{is_valid_setting_key, quote_shell_arg, AppState}; /// Read the device's current values for the tracked setting keys, keyed the /// same way snapshots store them (`"."`). Used so apply-plans can -/// skip settings already at the target value. Best-effort — a failed read -/// yields an empty map (everything treated as needing a write). +/// skip settings already at the target value. Every read must complete before +/// a missing value can be treated as absent. async fn current_settings_map( adb: &dyn crate::adb::AdbDriver, serial: &str, -) -> std::collections::BTreeMap { +) -> Result, String> { let keys = tracked_setting_keys(); - let cmd = keys + let commands = keys .iter() .map(|(ns, key)| format!("settings get {ns} {key}")) - .collect::>() - .join("; "); + .collect::>(); + let cmd = checked_batch_command(&commands.iter().map(String::as_str).collect::>()); let mut map = std::collections::BTreeMap::new(); - if let Ok(out) = adb.shell(serial, &cmd).await { - for ((ns, key), raw) in keys.iter().zip(out.stdout.lines()) { - let v = raw.trim(); - if !v.is_empty() && v != "null" { - map.insert(format!("{ns}.{key}"), v.to_string()); - } + let out = adb + .shell(serial, &cmd) + .await + .map_err(|e| format!("read snapshot settings: {e}"))?; + let required = (0..keys.len()).collect::>(); + let sections = parse_checked_batch(&out.stdout, keys.len(), &required)?; + for ((ns, key), value) in keys.iter().zip(sections) { + if value != "null" { + map.insert(format!("{ns}.{key}"), value); } } - map + Ok(map) } /// Read the installed and disabled package lists in one round-trip. Both @@ -157,7 +160,7 @@ pub async fn list_snapshots(state: State<'_, AppState>) -> Result>() - .join("; "); - let mut settings = std::collections::BTreeMap::new(); - if let Ok(out) = adb.shell(&serial, &cmd).await { - for ((ns, key), raw) in keys.iter().zip(out.stdout.lines()) { - let v = raw.trim(); - if !v.is_empty() && v != "null" { - settings.insert(format!("{ns}.{key}"), v.to_string()); - } - } - } + .map(|(ns, key)| format!("{ns}.{key}")) + .filter(|key| !settings.contains_key(key)) + .collect(); // Detect device type the same way list_devices does — we'll just refetch // here for snapshot purposes since it's cheap. @@ -250,6 +241,7 @@ pub async fn save_snapshot( disabled_packages, current_launcher, settings, + absent_settings, }; // Write to disk. @@ -290,7 +282,7 @@ pub async fn save_snapshot( device_serial: snap.device_serial, device_type: snap.device_type, disabled_count: snap.disabled_packages.len(), - settings_count: snap.settings.len(), + settings_count: snap.settings.len() + snap.absent_settings.len(), launcher: snap.current_launcher, }) } @@ -330,7 +322,7 @@ pub async fn preview_apply( let (installed_pkgs, disabled_pkgs) = installed_and_disabled(adb.as_ref(), &serial).await?; let device = crate::commands::devices::device_profile_impl(state.inner(), &serial).await?; - let current_settings = current_settings_map(adb.as_ref(), &serial).await; + let current_settings = current_settings_map(adb.as_ref(), &serial).await?; let plan = compute_apply_plan( &snap, @@ -351,6 +343,7 @@ pub struct ApplyResult { pub launcher_set: bool, pub launcher_message: Option, pub settings_written: Vec, + pub settings_deleted: Vec, pub settings_failed: Vec, pub summary: String, } @@ -417,7 +410,7 @@ pub async fn apply_snapshot( let (installed_pkgs, disabled_pkgs) = installed_and_disabled(adb.as_ref(), &serial).await?; let device = crate::commands::devices::device_profile_impl(state.inner(), &serial).await?; - let current_settings = current_settings_map(adb.as_ref(), &serial).await; + let current_settings = current_settings_map(adb.as_ref(), &serial).await?; let plan = compute_apply_plan( &snap, &ApplyPlanInputs { @@ -461,14 +454,49 @@ pub async fn apply_snapshot( } } - // 3. Write tracked settings — batch into a single shell call. + let (settings_written, settings_deleted, settings_failed) = + apply_settings_from_plan(adb.as_ref(), &serial, &plan).await; + + let summary = format!( + "Disabled {} packages ({} failed). Launcher: {}. {} settings written, {} reset ({} failed).", + packages_disabled.len(), + packages_failed.len(), + if launcher_set { "set" } else { "unchanged" }, + settings_written.len(), + settings_deleted.len(), + settings_failed.len() + ); + + Ok(ApplyResult { + packages_disabled, + packages_failed, + launcher_set, + launcher_message, + settings_written, + settings_deleted, + settings_failed, + summary, + }) +} + +async fn apply_settings_from_plan( + adb: &dyn crate::adb::AdbDriver, + serial: &str, + plan: &SnapshotApplyPlan, +) -> (Vec, Vec, Vec) { let mut settings_written = Vec::new(); + let mut settings_deleted = Vec::new(); let mut settings_failed = Vec::new(); // Apply one setting per shell call. Batching with `;` meant a single failing // `settings put` (e.g. a SecurityException on a protected key) was invisible // — `adb shell` still exits 0, so the old code reported every key written. // Per-key lets us report exactly which succeeded, and check the output. - for (k, v) in &plan.settings_to_write { + let writes = plan + .settings_to_write + .iter() + .map(|(key, value)| (key, Some(value))); + let deletes = plan.settings_to_delete.iter().map(|key| (key, None)); + for (k, value) in writes.chain(deletes) { // Key shape is `"ns.subkey"` per the snapshot schema. let Some((ns, key)) = k.split_once('.') else { settings_failed.push(format!("{k}: malformed key (expected ns.subkey)")); @@ -484,39 +512,31 @@ pub async fn apply_snapshot( } // Single-quote the value so shell metacharacters inside it are inert; // legitimate content (numbers, spaces, device names) passes through. - let quoted_v = quote_shell_arg(v); - match adb - .shell(&serial, &format!("settings put {ns} {key} {quoted_v}")) - .await - { - Ok(out) if !out.shell_reported_failure() => settings_written.push(k.clone()), + let command = match value { + Some(value) => format!("settings put {ns} {key} {}", quote_shell_arg(value)), + None => format!("settings delete {ns} {key}"), + }; + match adb.shell(serial, &command).await { + Ok(out) if !out.shell_reported_failure() => { + if value.is_some() { + settings_written.push(k.clone()); + } else { + settings_deleted.push(k.clone()); + } + } Ok(out) => settings_failed.push(format!("{k}: {}", out.combined().trim())), Err(e) => settings_failed.push(format!("{k}: {e}")), } } - let summary = format!( - "Disabled {} packages ({} failed). Launcher: {}. {} settings written.", - packages_disabled.len(), - packages_failed.len(), - if launcher_set { "set" } else { "unchanged" }, - settings_written.len() - ); - - Ok(ApplyResult { - packages_disabled, - packages_failed, - launcher_set, - launcher_message, - settings_written, - settings_failed, - summary, - }) + (settings_written, settings_deleted, settings_failed) } #[cfg(test)] mod tests { - use super::{current_settings_map, disable_from_plan, installed_and_disabled}; + use super::{ + apply_settings_from_plan, current_settings_map, disable_from_plan, installed_and_disabled, + }; use crate::adb::BATCH_SEPARATOR; use crate::commands::test_support::MockAdb; @@ -587,9 +607,13 @@ mod tests { async fn current_settings_map_pairs_keys_to_values_in_order() { // tracked_setting_keys order: window/transition/animator scale, // 4x hdmi, match_content_frame_rate, long_press_timeout. - let mock = - MockAdb::default().on_shell("settings get", "0.5\n0.5\n0.5\n1\nnull\n0\n1\n2\n400\n"); - let map = current_settings_map(&mock, "serial").await; + let mock = MockAdb::default().on_shell( + "settings get", + &batched(&[ + "0.5", "0.5", "0.5", "1", "null", "0", "1", "2", "400", "null", "", + ]), + ); + let map = current_settings_map(&mock, "serial").await.unwrap(); assert_eq!( map.get("global.window_animation_scale").map(String::as_str), Some("0.5") @@ -610,6 +634,60 @@ mod tests { ); // "null" line is dropped, not stored. assert!(!map.contains_key("global.hdmi_control_auto_wakeup_enabled")); + assert_eq!( + map.get("global.encoded_surround_output_enabled_formats") + .map(String::as_str), + Some("") + ); + } + + #[tokio::test] + async fn settings_capture_rejects_failed_and_truncated_reads() { + let complete = batched(&vec!["null"; super::tracked_setting_keys().len()]); + let status = crate::adb::batch::BATCH_STATUS; + for output in [ + complete.replacen(&format!("{status}0"), &format!("{status}1"), 1), + "null\n".into(), + ] { + let mock = MockAdb::default().on_shell("settings get", &output); + assert!(current_settings_map(&mock, "serial").await.is_err()); + } + let mock = MockAdb::default().on_shell_err("settings get", "offline"); + assert!(current_settings_map(&mock, "serial").await.is_err()); + } + + #[tokio::test] + async fn setting_apply_distinguishes_empty_put_delete_and_failure() { + let plan = super::SnapshotApplyPlan { + packages_to_disable: vec![], + packages_already_disabled: vec![], + packages_not_installed: vec![], + launcher_to_set: None, + cross_device_warning: None, + settings_already_set: vec![], + settings_to_write: [("global.empty".into(), String::new())].into(), + settings_to_delete: vec![ + "global.unset".into(), + "global.denied".into(), + "global.bad;key".into(), + ], + }; + let mock = MockAdb::default() + .on_shell_failure("settings delete global denied", "Error: permission denied"); + let log = mock.shell_log(); + let (written, deleted, failed) = apply_settings_from_plan(&mock, "serial", &plan).await; + assert_eq!(written, ["global.empty"]); + assert_eq!(deleted, ["global.unset"]); + assert_eq!(failed.len(), 2); + let calls = log.lock().unwrap(); + assert_eq!( + *calls, + [ + "settings put global empty ''", + "settings delete global unset", + "settings delete global denied" + ] + ); } #[tokio::test] diff --git a/v2/crates/core/src/commands/tuning.rs b/v2/crates/core/src/commands/tuning.rs index 3fc1c9f..64dfb59 100644 --- a/v2/crates/core/src/commands/tuning.rs +++ b/v2/crates/core/src/commands/tuning.rs @@ -29,45 +29,63 @@ pub struct TweaksState { /// `0` = none, `1`–`4` = at most N. Frees RAM, but Android resets it on /// reboot (see issue #11) — the UI says so. pub background_process_limit: Option, + /// Encoded-audio passthrough: `0` Auto, `1` Never, `2` Always, `3` Manual. + /// Decides whether TrueHD / DTS-HD reach the receiver untouched or get + /// decoded to PCM on the device first. + pub encoded_surround_output: Option, + /// Comma-separated `AudioFormat.ENCODING_*` values — the allow-list that + /// applies only when the mode is Manual. + pub encoded_surround_output_enabled_formats: Option, } /// `get_tweaks` — batch-fetch all Tweaks-relevant settings in one shell call. #[tauri::command] pub async fn get_tweaks(state: State<'_, AppState>, serial: String) -> Result { let adb = state.adb_snapshot().await; - let cmd = "settings get global hdmi_control_enabled; \ - settings get global hdmi_control_auto_wakeup_enabled; \ - settings get global hdmi_control_auto_device_off_enabled; \ - settings get global hdmi_system_audio_control_enabled; \ - settings get secure match_content_frame_rate; \ - settings get secure long_press_timeout; \ - settings get global window_animation_scale; \ - settings get global transition_animation_scale; \ - settings get global animator_duration_scale; \ - settings get global background_process_limit"; + get_tweaks_for(adb.as_ref(), &serial).await +} + +async fn get_tweaks_for( + adb: &dyn crate::adb::AdbDriver, + serial: &str, +) -> Result { + let commands = [ + "settings get global hdmi_control_enabled", + "settings get global hdmi_control_auto_wakeup_enabled", + "settings get global hdmi_control_auto_device_off_enabled", + "settings get global hdmi_system_audio_control_enabled", + "settings get secure match_content_frame_rate", + "settings get secure long_press_timeout", + "settings get global window_animation_scale", + "settings get global transition_animation_scale", + "settings get global animator_duration_scale", + "settings get global background_process_limit", + "settings get global encoded_surround_output", + "settings get global encoded_surround_output_enabled_formats", + ]; + let cmd = crate::adb::checked_batch_command(&commands); let out = adb - .shell(&serial, cmd) + .shell(serial, &cmd) .await .map_err(|e| format!("settings get: {e}"))?; - let mut lines = out.stdout.lines().map(|s| { - let v = s.trim(); - if v.is_empty() || v == "null" { - None - } else { - Some(v.to_string()) - } - }); + let required = (0..commands.len()).collect::>(); + let sections = crate::adb::parse_checked_batch(&out.stdout, commands.len(), &required)?; + let mut values = sections + .into_iter() + .map(|value| (value != "null").then_some(value)); Ok(TweaksState { - hdmi_control_enabled: lines.next().flatten(), - hdmi_control_auto_wakeup_enabled: lines.next().flatten(), - hdmi_control_auto_device_off_enabled: lines.next().flatten(), - hdmi_system_audio_control_enabled: lines.next().flatten(), - match_content_frame_rate: lines.next().flatten(), - long_press_timeout: lines.next().flatten(), - window_animation_scale: lines.next().flatten(), - transition_animation_scale: lines.next().flatten(), - animator_duration_scale: lines.next().flatten(), - background_process_limit: lines.next().flatten(), + hdmi_control_enabled: values.next().flatten(), + hdmi_control_auto_wakeup_enabled: values.next().flatten(), + hdmi_control_auto_device_off_enabled: values.next().flatten(), + hdmi_system_audio_control_enabled: values.next().flatten(), + match_content_frame_rate: values.next().flatten(), + long_press_timeout: values.next().flatten(), + window_animation_scale: values.next().flatten(), + transition_animation_scale: values.next().flatten(), + animator_duration_scale: values.next().flatten(), + background_process_limit: values.next().flatten(), + encoded_surround_output: values.next().flatten(), + encoded_surround_output_enabled_formats: values.next().flatten(), }) } @@ -393,7 +411,50 @@ pub async fn set_private_dns( #[cfg(test)] mod tests { - use super::{build_setting_command, is_valid_dns_hostname}; + use super::{build_setting_command, get_tweaks_for, is_valid_dns_hostname}; + use crate::adb::{batch::BATCH_STATUS, BATCH_SEPARATOR}; + use crate::commands::test_support::MockAdb; + + fn settings_output(values: &[&str]) -> String { + values + .iter() + .map(|value| format!("{value}\n{BATCH_STATUS}0\n")) + .collect::>() + .join(&format!("{BATCH_SEPARATOR}\n")) + } + + #[tokio::test] + async fn tweaks_preserve_empty_values_without_shifting_audio_settings() { + let output = settings_output(&[ + "", "1", "0", "1", "null", "400", "0.5", "1", "1", "null", "3", "5,6,99", + ]); + let adb = MockAdb::default().on_shell("settings get", &output); + let tweaks = get_tweaks_for(&adb, "serial").await.unwrap(); + assert_eq!(tweaks.hdmi_control_enabled.as_deref(), Some("")); + assert_eq!( + tweaks.hdmi_control_auto_wakeup_enabled.as_deref(), + Some("1") + ); + assert_eq!(tweaks.match_content_frame_rate, None); + assert_eq!(tweaks.encoded_surround_output.as_deref(), Some("3")); + assert_eq!( + tweaks.encoded_surround_output_enabled_formats.as_deref(), + Some("5,6,99") + ); + } + + #[tokio::test] + async fn tweaks_reject_failed_or_truncated_readback() { + let output = settings_output(&["null"; 12]); + let failed = output.replacen(&format!("{BATCH_STATUS}0"), &format!("{BATCH_STATUS}1"), 1); + let truncated = settings_output(&["null"; 11]); + for output in [failed, truncated, "null\n".into()] { + let adb = MockAdb::default().on_shell("settings get", &output); + assert!(get_tweaks_for(&adb, "serial").await.is_err()); + } + let adb = MockAdb::default().on_shell_err("settings get", "device offline"); + assert!(get_tweaks_for(&adb, "serial").await.is_err()); + } #[test] fn accepts_real_dot_hostnames() { diff --git a/v2/crates/core/src/engine/media.rs b/v2/crates/core/src/engine/media.rs new file mode 100644 index 0000000..b352817 --- /dev/null +++ b/v2/crates/core/src/engine/media.rs @@ -0,0 +1,363 @@ +//! Observations from device codec configuration, display modes, and audio settings. +use quick_xml::{ + events::{BytesStart, Event}, + Reader, +}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Decoder { + pub name: String, + pub mime: String, + pub software: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct VideoFormat { + pub label: String, + pub mime: String, + pub advertised: bool, + pub software: bool, + pub acceleration_unknown: bool, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct DisplayModeEntry { + pub width: u32, + pub height: u32, + pub fps: f64, + pub active: bool, +} + +impl DisplayModeEntry { + pub fn is_film_rate(&self) -> bool { + (23.9..=24.1).contains(&self.fps) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SurroundMode { + Auto, + Never, + Always, + Manual, + Unset, + Unknown, +} + +impl SurroundMode { + pub fn from_raw(raw: Option<&str>) -> Self { + match raw.map(str::trim) { + Some("0") => Self::Auto, + Some("1") => Self::Never, + Some("2") => Self::Always, + Some("3") => Self::Manual, + None | Some("" | "null") => Self::Unset, + _ => Self::Unknown, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct AudioPassthrough { + pub mode: SurroundMode, + pub enabled_formats: Vec, + pub raw_formats: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum VerdictLevel { + Good, + Warn, + Info, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Verdict { + pub level: VerdictLevel, + pub title: String, + pub detail: String, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct MediaCapabilities { + pub video: Vec, + pub hdr_types: Vec, + pub modes: Vec, + pub audio: AudioPassthrough, + pub match_content_frame_rate: Option, + pub verdicts: Vec, +} + +const KNOWN_VIDEO: &[(&str, &str)] = &[ + ("video/avc", "H.264 / AVC"), + ("video/hevc", "HEVC / H.265"), + ("video/x-vnd.on2.vp9", "VP9"), + ("video/av01", "AV1"), + ("video/dolby-vision", "Dolby Vision"), + ("video/mpeg2", "MPEG-2"), +]; + +fn attribute(tag: &BytesStart<'_>, key: &[u8]) -> Option { + tag.attributes() + .filter_map(Result::ok) + .find(|a| a.key.as_ref() == key) + .and_then(|a| a.unescape_value().ok().map(|v| v.into_owned())) +} + +fn add_decoder(out: &mut Vec, name: &str, mime: &str, software: bool) { + for mime in mime.split(',').map(str::trim).filter(|m| !m.is_empty()) { + let decoder = Decoder { + name: name.to_string(), + mime: mime.to_ascii_lowercase(), + software, + }; + if !out.contains(&decoder) { + out.push(decoder); + } + } +} + +/// The input may contain several concatenated XML documents. Missing entries +/// are not proof of runtime codec absence: vendor includes can be unreadable. +pub fn parse_media_codecs(xml: &str) -> Vec { + let mut reader = Reader::from_str(xml); + let mut out = Vec::new(); + let mut in_decoders = false; + let mut current: Option<(String, bool)> = None; + loop { + match reader.read_event() { + Ok(Event::Start(tag) | Event::Empty(tag)) => match tag.name().as_ref() { + b"Decoders" => in_decoders = true, + b"Encoders" => { + in_decoders = false; + current = None; + } + b"MediaCodec" if in_decoders => { + current = attribute(&tag, b"name").map(|name| { + let lower = name.to_ascii_lowercase(); + let software = lower.starts_with("omx.google.") + || lower.starts_with("c2.android.") + || attribute(&tag, b"software-codec").as_deref() == Some("true"); + (name, software) + }); + if let (Some((name, software)), Some(mime)) = + (¤t, attribute(&tag, b"type")) + { + add_decoder(&mut out, name, &mime, *software); + } + } + b"Type" if in_decoders => { + if let (Some((name, software)), Some(mime)) = + (¤t, attribute(&tag, b"name")) + { + add_decoder(&mut out, name, &mime, *software); + } + } + _ => {} + }, + Ok(Event::End(tag)) => match tag.name().as_ref() { + b"MediaCodec" => current = None, + b"Decoders" | b"Encoders" => { + in_decoders = false; + current = None; + } + _ => {} + }, + Ok(Event::Eof) | Err(_) => break, + _ => {} + } + } + out +} + +pub fn video_formats(decoders: &[Decoder]) -> Vec { + KNOWN_VIDEO + .iter() + .map(|(mime, label)| { + let matching: Vec<_> = decoders.iter().filter(|d| d.mime == *mime).collect(); + VideoFormat { + label: (*label).into(), + mime: (*mime).into(), + advertised: !matching.is_empty(), + software: matching.iter().any(|d| d.software), + acceleration_unknown: matching.iter().any(|d| !d.software), + } + }) + .collect() +} + +fn audio_format_label(code: &str) -> String { + match code.trim() { + "5" => "Dolby Digital (AC-3)".into(), + "6" => "Dolby Digital Plus (E-AC-3)".into(), + "7" => "DTS".into(), + "8" => "DTS-HD".into(), + "14" => "Dolby TrueHD".into(), + "17" => "Dolby AC-4".into(), + "18" => "Dolby Atmos over DD+ (E-AC-3 JOC)".into(), + "19" => "Dolby MAT".into(), + "26" => "MPEG-H LC L4".into(), + "27" => "DTS UHD P1".into(), + other => format!("Format {other}"), + } +} + +pub fn surround_mode(mode_raw: Option<&str>, formats_raw: Option<&str>) -> AudioPassthrough { + let raw_formats = formats_raw + .map(str::trim) + .filter(|s| !s.is_empty() && *s != "null") + .map(str::to_string); + let enabled_formats = raw_formats + .as_deref() + .map(|raw| { + raw.split(',') + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(audio_format_label) + .collect() + }) + .unwrap_or_default(); + AudioPassthrough { + mode: SurroundMode::from_raw(mode_raw), + enabled_formats, + raw_formats, + } +} + +pub fn build_capabilities( + decoders: &[Decoder], + hdr_types: Vec, + modes: Vec, + audio: AudioPassthrough, + match_content_frame_rate: Option, +) -> MediaCapabilities { + let mut verdicts = vec![Verdict { + level: VerdictLevel::Info, + title: "Configuration, not a playback test".into(), + detail: "Codec entries describe available configuration. They do not verify runtime registration, acceleration, profiles, DRM, or playback performance.".into(), + }]; + if decoders.is_empty() { + verdicts.push(Verdict { + level: VerdictLevel::Info, + title: "Decoder list unavailable".into(), + detail: "No decoder entries were read. Codec support could not be determined.".into(), + }); + } + if let Some(mode) = modes.iter().find(|m| m.is_film_rate()) { + verdicts.push(Verdict { + level: VerdictLevel::Info, + title: format!("24p mode reported ({:.3} Hz)", mode.fps), + detail: "A matching display mode is available. Actual switching depends on the player, device, and display; this setting alone does not guarantee film-rate output.".into(), + }); + } + if audio.mode == SurroundMode::Never { + verdicts.push(Verdict { + level: VerdictLevel::Info, + title: "Encoded surround passthrough is off".into(), + detail: "The setting disables encoded surround output. PCM channel count and quality depend on the app and output path; this does not by itself imply downmixing.".into(), + }); + } + MediaCapabilities { + video: video_formats(decoders), + hdr_types, + modes, + audio, + match_content_frame_rate, + verdicts, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn reads_multiline_nested_types_comments_and_concatenated_documents() { + let xml = r#" + + + + + + + + + + "#; + let decoders = parse_media_codecs(xml); + assert_eq!(decoders.len(), 3); + assert!(!decoders[0].software); + assert!(decoders[2].software); + assert!(!decoders + .iter() + .any(|d| d.name.contains("encoder") || d.name == "fake")); + } + + #[test] + fn vendor_name_does_not_prove_hardware_acceleration() { + let xml = r#""#; + let video = video_formats(&parse_media_codecs(xml)); + let hevc = video.iter().find(|v| v.mime == "video/hevc").unwrap(); + assert!(hevc.advertised && hevc.acceleration_unknown && !hevc.software); + let av1 = video.iter().find(|v| v.mime == "video/av01").unwrap(); + assert!(!av1.advertised); + } + + #[test] + fn software_metadata_and_mixed_decoders_preserve_presence() { + let xml = r#" + + + + "#; + let video = video_formats(&parse_media_codecs(xml)); + let hevc = video.iter().find(|v| v.mime == "video/hevc").unwrap(); + assert!(hevc.advertised && hevc.software && hevc.acceleration_unknown); + } + + #[test] + fn duplicate_documents_do_not_duplicate_decoders() { + let xml = r#""#; + assert_eq!( + parse_media_codecs(&format!("{xml}{xml}")), + parse_media_codecs(xml) + ); + } + + #[test] + fn empty_and_malformed_xml_do_not_panic_or_invent_support() { + assert!(parse_media_codecs("").is_empty()); + assert!(parse_media_codecs("cat: permission denied").is_empty()); + assert!(parse_media_codecs(" bool { never_disable_reason(package).is_some() } +/// Destructive package verbs. Each one takes a package away from the user in a +/// way the never-disable list exists to prevent — `hide` and `suspend` are +/// included because they reach the same end state as `disable` by another +/// name. +const DESTRUCTIVE_VERBS: &[&str] = &[ + "disable", + "disable-user", + "disable-until-used", + "uninstall", + "uninstall-system-updates", + "clear", + "hide", + "suspend", +]; + +/// Guard for the free-form shell runner: does this command *obviously* try to +/// take a never-disable package away? +/// +/// **This is an anti-footgun, not a security boundary.** Read that literally +/// before relying on it for anything. +/// +/// What it catches is the paste-from-a-forum-post case: someone runs +/// `pm disable-user com.android.systemui`, or the same thing chained behind a +/// harmless statement, without knowing it bricks the device. It handles the +/// spellings that show up in real posts — `;` / `&&` chaining, `cmd package`, +/// simple quoting, `$(…)` — because it compares whole tokens rather than +/// modelling shell grammar. +/// +/// What it does **not** catch, verified rather than assumed: +/// +/// ```text +/// P=com.android.systemui; pm disable-user $P // variable indirection +/// pm disable-user com.android.system''ui // split quoting +/// pm disable-user com.android.sys* // glob +/// ``` +/// +/// Closing those would mean writing a shell parser, and a shell parser can +/// always be out-argued. The trade is deliberate and it is fine here, because +/// **there is no privilege boundary at this seam**: the user already has +/// `adb shell` on their own machine, pointed at their own device. Someone +/// determined to disable System UI does not need to defeat this function. +/// +/// The never-disable list is genuinely *enforced* on the structured paths — +/// `apps::disable_package`, the optimize wizard, apply-snapshot. This only +/// stops the free-form box from becoming an easy way to do the same damage by +/// accident. If you ever need a real boundary here, this is not it. +/// +/// It requires *both* a destructive verb and a protected package, so read-only +/// inspection (`dumpsys package com.android.systemui`) stays allowed. +/// +/// Returns the reason to show the user, or `None` when the command is clear. +pub fn shell_command_blocked(command: &str) -> Option<(String, &'static str)> { + // This recognizes literal arguments, not shell expansion or evaluation. + let tokens: Vec<&str> = command + .split(|c: char| { + c.is_whitespace() || matches!(c, ';' | '&' | '|' | '(' | ')' | '\'' | '"' | '`') + }) + .filter(|t| !t.is_empty()) + .collect(); + + let has_verb = tokens + .iter() + .any(|t| DESTRUCTIVE_VERBS.contains(&t.to_ascii_lowercase().as_str())); + if !has_verb { + return None; + } + tokens.iter().find_map(|t| { + let package = t.split('/').next().unwrap_or(t); + never_disable_reason(package).map(|reason| (package.to_string(), reason)) + }) +} + fn never_disable_reason(package: &str) -> Option<&'static str> { // Bricking-tier: framework, system UI, settings, ADB-adjacent, package + // permission infrastructure, base Google services. Order matches what @@ -234,6 +306,114 @@ const CAUTION: &[(&str, &str)] = &[ mod tests { use super::*; + #[test] + fn shell_runner_blocks_disabling_a_never_disable_package() { + let (pkg, reason) = + shell_command_blocked("pm disable-user --user 0 com.android.systemui").unwrap(); + assert_eq!(pkg, "com.android.systemui"); + assert!(reason.contains("System UI")); + } + + #[test] + fn shell_runner_blocks_literal_clear_updates_and_component_targets() { + for command in [ + "pm clear com.android.systemui", + "pm uninstall-system-updates com.android.settings", + "pm disable-user com.android.systemui/.SystemUIService", + "pm disable-until-used com.android.shell", + ] { + assert!(shell_command_blocked(command).is_some(), "{command}"); + } + } + + #[test] + fn shell_runner_blocks_every_destructive_spelling() { + for cmd in [ + "pm disable com.android.systemui", + "pm disable-user com.android.systemui", + "pm uninstall --user 0 com.android.systemui", + "pm hide com.android.systemui", + "pm suspend com.android.systemui", + "cmd package disable com.android.systemui", + "PM DISABLE com.android.systemui", + ] { + assert!(shell_command_blocked(cmd).is_some(), "must block: {cmd}"); + } + } + + #[test] + fn shell_runner_blocks_chained_and_quoted_attempts() { + // The hole a naive prefix check would leave: hide the destructive + // statement behind a harmless first one, or quote the package. + assert!(shell_command_blocked("echo hi; pm disable-user com.android.systemui").is_some()); + assert!(shell_command_blocked("id && pm uninstall com.android.settings").is_some()); + assert!(shell_command_blocked("pm disable-user 'com.android.systemui'").is_some()); + assert!(shell_command_blocked("pm disable-user \"com.android.systemui\"").is_some()); + assert!(shell_command_blocked("pm disable-user android").is_some()); + } + + #[test] + fn shell_runner_gate_does_not_pretend_to_stop_deliberate_evasion() { + // Pinned on purpose. These all reach the device, and that is the + // documented contract — `shell_command_blocked` is an anti-footgun, + // not a boundary. Catching them would require a shell parser, which + // can always be out-argued, and there is nothing to defend anyway: + // the user already has `adb shell` against their own device. + // + // If a future change makes one of these block, that is a behavior + // change to think about, not a bug fix — and this test failing is the + // prompt to re-read the docs on the function before "fixing" it. + for evasion in [ + "P=com.android.systemui; pm disable-user $P", // variable indirection + "pm disable-user com.android.system''ui", // split quoting + "pm disable-user com.android.sys*", // glob + ] { + assert_eq!( + shell_command_blocked(evasion), + None, + "gate is documented as not catching this: {evasion}" + ); + } + } + + #[test] + fn shell_runner_allows_reading_a_protected_package() { + // Both halves of the rule matter: a protected package with no + // destructive verb is an inspection, and must go through. + assert_eq!( + shell_command_blocked("dumpsys package com.android.systemui"), + None + ); + assert_eq!(shell_command_blocked("pm path com.android.systemui"), None); + assert_eq!(shell_command_blocked("pm list packages"), None); + } + + #[test] + fn shell_runner_allows_disabling_an_unprotected_package() { + // The verb alone must not block — debloating from the shell is the + // whole point of the tab. + assert_eq!( + shell_command_blocked("pm disable-user --user 0 com.facebook.katana"), + None + ); + assert_eq!( + shell_command_blocked("pm uninstall --user 0 com.netflix.ninja"), + None + ); + } + + #[test] + fn shell_runner_gate_agrees_with_the_never_disable_list() { + // The gate must not drift from the list it enforces. + for (pkg, _) in NEVER_DISABLE { + assert!(is_never_disable(pkg)); + assert!( + shell_command_blocked(&format!("pm disable-user {pkg}")).is_some(), + "shell gate missed a never-disable package: {pkg}" + ); + } + } + #[test] fn framework_is_never_disable() { assert!(matches!(classify("android"), Safety::NeverDisable { .. })); diff --git a/v2/crates/core/src/engine/snapshot.rs b/v2/crates/core/src/engine/snapshot.rs index c88b48e..32f482a 100644 --- a/v2/crates/core/src/engine/snapshot.rs +++ b/v2/crates/core/src/engine/snapshot.rs @@ -13,9 +13,7 @@ use super::detection::DeviceType; /// Current snapshot schema version. Bump when the structure changes; the /// reader will refuse future versions explicitly. -// v2 added the optional `label` field. v1 snapshots still load (label -// defaults to None via serde) — that's the migration; no transform needed. -pub const SCHEMA_VERSION: u32 = 2; +pub const SCHEMA_VERSION: u32 = 3; /// Setting keys we track in a snapshot — matches v1's `$Script:SnapshotSettingKeys`. pub fn tracked_setting_keys() -> &'static [(&'static str, &'static str)] { @@ -29,6 +27,8 @@ pub fn tracked_setting_keys() -> &'static [(&'static str, &'static str)] { ("global", "hdmi_system_audio_control_enabled"), ("secure", "match_content_frame_rate"), ("secure", "long_press_timeout"), + ("global", "encoded_surround_output"), + ("global", "encoded_surround_output_enabled_formats"), ] } @@ -51,6 +51,8 @@ pub struct Snapshot { /// Key format: `"."` (e.g. `"global.window_animation_scale"`). /// Values are the raw strings the device returned. pub settings: BTreeMap, + #[serde(default)] + pub absent_settings: Vec, } /// Errors that arise from snapshot parsing / application. @@ -82,7 +84,24 @@ impl Snapshot { supported: SCHEMA_VERSION, }); } - serde_json::from_value(value).map_err(|e| SnapshotError::Malformed(e.to_string())) + let mut snapshot: Self = + serde_json::from_value(value).map_err(|e| SnapshotError::Malformed(e.to_string()))?; + if schema < 3 { + // Older captures omitted both absent and unreadable settings. Neither + // omission authorizes deleting a value on the target device. + snapshot.absent_settings.clear(); + snapshot.schema_version = SCHEMA_VERSION; + } + if snapshot + .absent_settings + .iter() + .any(|key| snapshot.settings.contains_key(key)) + { + return Err(SnapshotError::Malformed( + "setting cannot be both present and absent".into(), + )); + } + Ok(snapshot) } pub fn to_json(&self) -> Result { @@ -105,6 +124,7 @@ pub struct SnapshotApplyPlan { /// Settings whose current device value differs from the snapshot — /// these will be written. Same key format as `Snapshot::settings`. pub settings_to_write: BTreeMap, + pub settings_to_delete: Vec, /// Settings already at the snapshot's value on the device — no-op, counted /// so the preview doesn't overstate the work. pub settings_already_set: Vec, @@ -119,9 +139,8 @@ pub struct ApplyPlanInputs<'a> { pub target_device_type: DeviceType, pub currently_disabled: &'a [String], pub currently_installed: &'a [String], - /// Current device values for the snapshot's setting keys, so the plan can - /// skip settings already at the target value. Empty map = treat all as - /// needing a write (back-compat). + /// Successfully read current tracked values. Missing keys are absent; + /// callers must not substitute an empty map for a failed read. pub current_settings: &'a BTreeMap, } @@ -175,12 +194,22 @@ pub fn compute_apply_plan(snap: &Snapshot, inputs: &ApplyPlanInputs<'_>) -> Snap } } + let mut settings_to_delete = Vec::new(); + for key in &snap.absent_settings { + if inputs.current_settings.contains_key(key) { + settings_to_delete.push(key.clone()); + } else { + settings_already_set.push(key.clone()); + } + } + SnapshotApplyPlan { packages_to_disable: to_disable, packages_already_disabled: already_disabled, packages_not_installed: not_installed, launcher_to_set: snap.current_launcher.clone(), settings_to_write, + settings_to_delete, settings_already_set, cross_device_warning, } @@ -208,6 +237,7 @@ mod tests { disabled_packages: vec!["com.foo".into(), "com.bar".into(), "com.missing".into()], current_launcher: Some("com.spocky.projengmenu".to_string()), settings, + absent_settings: Vec::new(), } } @@ -247,6 +277,80 @@ mod tests { assert_eq!(parsed.label.as_deref(), Some("before debloat")); } + #[test] + fn absent_and_empty_settings_roundtrip_and_plan_separately() { + let mut snap = sample_snapshot(); + let empty_key = "global.encoded_surround_output_enabled_formats".to_string(); + let absent_key = "global.encoded_surround_output".to_string(); + snap.settings.insert(empty_key.clone(), String::new()); + snap.absent_settings.push(absent_key.clone()); + let parsed = Snapshot::from_json(&snap.to_json().unwrap()).unwrap(); + assert_eq!(parsed.settings.get(&empty_key), Some(&String::new())); + assert_eq!(parsed.absent_settings, std::slice::from_ref(&absent_key)); + let current = BTreeMap::from([ + (absent_key.clone(), "3".into()), + ("global.unrelated".into(), "keep".into()), + ]); + let plan = compute_apply_plan( + &parsed, + &ApplyPlanInputs { + target_device_type: DeviceType::Shield, + currently_disabled: &[], + currently_installed: &[], + current_settings: ¤t, + }, + ); + assert_eq!(plan.settings_to_delete, [absent_key]); + assert_eq!(plan.settings_to_write.get(&empty_key), Some(&String::new())); + assert!(!plan.settings_to_write.contains_key("global.unrelated")); + let absent = BTreeMap::new(); + let unchanged = compute_apply_plan( + &parsed, + &ApplyPlanInputs { + target_device_type: DeviceType::Shield, + currently_disabled: &[], + currently_installed: &[], + current_settings: &absent, + }, + ); + assert!(unchanged.settings_to_delete.is_empty()); + assert!(unchanged + .settings_already_set + .contains(&"global.encoded_surround_output".into())); + } + + #[test] + fn legacy_omissions_never_become_deletions() { + for version in [1, 2] { + let mut snap = sample_snapshot(); + snap.schema_version = version; + let mut json = serde_json::to_value(snap).unwrap(); + json.as_object_mut().unwrap().remove("absent_settings"); + let migrated = Snapshot::from_json(&json.to_string()).unwrap(); + assert_eq!(migrated.schema_version, SCHEMA_VERSION); + assert!(migrated.absent_settings.is_empty()); + let current = BTreeMap::from([("global.encoded_surround_output".into(), "3".into())]); + let plan = compute_apply_plan( + &migrated, + &ApplyPlanInputs { + target_device_type: DeviceType::Shield, + currently_disabled: &[], + currently_installed: &[], + current_settings: ¤t, + }, + ); + assert!(plan.settings_to_delete.is_empty()); + } + } + + #[test] + fn rejects_conflicting_present_and_absent_values() { + let mut snap = sample_snapshot(); + snap.absent_settings + .push("global.window_animation_scale".into()); + assert!(Snapshot::from_json(&snap.to_json().unwrap()).is_err()); + } + #[test] fn rejects_zero_schema_version() { let payload = r#"{ @@ -265,7 +369,7 @@ mod tests { err, SnapshotError::UnsupportedSchema { found: 0, - supported: 2 + supported: 3 } )); } @@ -287,7 +391,7 @@ mod tests { match err { SnapshotError::UnsupportedSchema { found: 999, - supported: 2, + supported: 3, } => {} other => panic!("wrong error: {other:?}"), } diff --git a/v2/crates/core/src/license.rs b/v2/crates/core/src/license.rs index cdaa1e0..e2384db 100644 --- a/v2/crates/core/src/license.rs +++ b/v2/crates/core/src/license.rs @@ -415,6 +415,7 @@ pub enum Feature { Sideload, FileManager, BackupClone, + ShellRunner, } impl Feature { @@ -432,6 +433,7 @@ impl Feature { Feature::Sideload => "sideload", Feature::FileManager => "file_manager", Feature::BackupClone => "backup_clone", + Feature::ShellRunner => "shell_runner", } } } diff --git a/v2/mobile/src-tauri/src/lib.rs b/v2/mobile/src-tauri/src/lib.rs index e804bc6..4036c7d 100644 --- a/v2/mobile/src-tauri/src/lib.rs +++ b/v2/mobile/src-tauri/src/lib.rs @@ -214,6 +214,8 @@ pub fn run() { devices::device_profile, devices::rename_device, health::health_report, + health::media_report, + health::resource_sample, health::app_list_for_device, health::report_all, launcher::list_launchers, diff --git a/v2/mobile/src/lib/api.ts b/v2/mobile/src/lib/api.ts index c1661b1..a90d662 100644 --- a/v2/mobile/src/lib/api.ts +++ b/v2/mobile/src/lib/api.ts @@ -9,6 +9,7 @@ import { Channel, invoke } from "@tauri-apps/api/core"; import { parseSafety } from "../../../shared/safety"; +import type { MediaCapabilities } from "../../../shared/media"; import { logCall, summarizeArgs } from "./log"; import { connectionGeneration, emitConnectionLost, isConnectionLostError } from "./connectionEvents"; import type { @@ -30,6 +31,7 @@ import type { DisplayScaleResult, Entitlement, HealthReport, + ResourceSample, LauncherStatus, LicenseInfo, OptimizeMode, @@ -96,6 +98,8 @@ export const api = { // ---- Health / catalog ---- healthReport: (serial: string) => call("health_report", { serial }), + mediaReport: (serial: string) => call("media_report", { serial }), + resourceSample: (serial: string) => call("resource_sample", { serial }), appListForDevice: (deviceType: DeviceType) => call("app_list_for_device", { deviceType }), packageStates: (serial: string, packages: string[]) => diff --git a/v2/mobile/src/lib/types.ts b/v2/mobile/src/lib/types.ts index 3b607e3..5f17977 100644 --- a/v2/mobile/src/lib/types.ts +++ b/v2/mobile/src/lib/types.ts @@ -235,6 +235,10 @@ export interface TweaksState { transition_animation_scale: string | null; animator_duration_scale: string | null; background_process_limit: string | null; + /// Encoded audio passthrough: "0" Auto, "1" Never, "2" Always, "3" Manual. + encoded_surround_output: string | null; + /// Comma-separated AudioFormat encodings; applies only in Manual mode. + encoded_surround_output_enabled_formats: string | null; } export interface WriteResult { @@ -242,6 +246,16 @@ export interface WriteResult { message: string; } +export interface ResourceSample { + cpu_percent: number | null; + interfaces: Array<{ + name: string; + rx_bytes_per_s: number | null; + tx_bytes_per_s: number | null; + }>; + interval_ms: number | null; +} + /// Must stay in lockstep with the Rust `DisplayScalePreset` serde renames. export type DisplayScalePreset = "uhd_4k" | "fhd_1080p" | "reset"; @@ -290,6 +304,7 @@ export interface SnapshotApplyPlan { packages_not_installed: string[]; launcher_to_set: string | null; settings_to_write: Record; + settings_to_delete: string[]; settings_already_set: string[]; } @@ -299,6 +314,7 @@ export interface ApplyResult { launcher_set: boolean; launcher_message: string | null; settings_written: string[]; + settings_deleted: string[]; settings_failed: string[]; summary: string; } diff --git a/v2/mobile/src/screens/Diagnostics.svelte b/v2/mobile/src/screens/Diagnostics.svelte index 5b209a6..a1f22bf 100644 --- a/v2/mobile/src/screens/Diagnostics.svelte +++ b/v2/mobile/src/screens/Diagnostics.svelte @@ -4,7 +4,8 @@ import { session } from "../lib/session.svelte"; import type { Screen } from "../lib/router.svelte"; import { tierOf } from "../lib/safety"; - import type { Safety } from "../lib/types"; + import type { ResourceSample, Safety } from "../lib/types"; + import { formatSupport, matchContentLabel, type MediaCapabilities } from "../../../shared/media"; import FindRemoteButton from "../components/FindRemoteButton.svelte"; let { navigate, back }: { @@ -26,6 +27,68 @@ let catalogNames = $state>({}); let catalogRequest = 0; let destroyed = false; + let playback = $state(null); + let playbackError = $state(""); + let playbackBusy = $state(false); + let playbackRequest = 0; + let resources = $state(null); + let resourceError = $state(""); + let resourceBusy = $state(false); + let resourceRequest = 0; + + $effect(() => { + void session.serial; + void session.generation; + void session.liveness; + ++playbackRequest; + ++resourceRequest; + playback = null; + resources = null; + playbackError = ""; + resourceError = ""; + playbackBusy = false; + resourceBusy = false; + }); + + async function readPlayback() { + if (playbackBusy || resourceBusy || !session.isConnected) return; + const serial = session.serial; + const generation = session.generation; + const request = ++playbackRequest; + const current = () => !destroyed && request === playbackRequest && serial === session.serial && generation === session.generation && session.isConnected; + playbackBusy = true; + playbackError = ""; + playback = null; + try { + const report = await api.mediaReport(serial); + if (current()) playback = report; + } catch (error) { + if (current()) playbackError = String(error); + } finally { + if (current()) playbackBusy = false; + } + } + + async function sampleResources() { + if (resourceBusy || playbackBusy || !session.isConnected) return; + const serial = session.serial; + const generation = session.generation; + const request = ++resourceRequest; + const current = () => !destroyed && request === resourceRequest && serial === session.serial && generation === session.generation && session.isConnected; + resourceBusy = true; + resourceError = ""; + resources = null; + try { + const sample = await api.resourceSample(serial); + if (current()) resources = sample; + } catch (error) { + if (current()) resourceError = String(error); + } finally { + if (current()) resourceBusy = false; + } + } + + const rate = (value: number | null) => value == null ? "Unavailable" : `${(value / 1024).toFixed(1)} KiB/s`; let liveMode = $state(false); let liveTimer: ReturnType | undefined; @@ -132,6 +195,8 @@ destroyed = true; ++safetyRequest; ++catalogRequest; + ++playbackRequest; + ++resourceRequest; stopLive(); }); @@ -400,9 +465,57 @@ {/if} + {#if session.connectedDevice} +
+
+
+ Playback report + +
+

Advertised configuration, not a playback test. Actual decoding and passthrough depend on the app, content and connected display or receiver.

+ {#if playbackError}{/if} + {#if playback} +
+ {#each playback.video as format (format.mime)} +
{format.label}
{formatSupport(format).label}
+ {/each} +
Advertised HDR
{playback.hdr_types.length ? playback.hdr_types.join(", ") : "Unknown / not reported"}
+
Display modes
{playback.modes.length ? playback.modes.map((mode) => `${mode.width}×${mode.height} @ ${mode.fps} Hz${mode.active ? " (active)" : ""}`).join(", ") : "Unknown / not reported"}
+
Surround policy
{playback.audio.mode === "unset" ? "Default / unset" : playback.audio.mode}
+
Manual formats
{playback.audio.enabled_formats.join(", ") || "None / unset"}
+
Frame-rate policy
{playback.match_content_frame_rate == null ? "Default / unset" : (matchContentLabel[playback.match_content_frame_rate] ?? `Unknown (${playback.match_content_frame_rate})`)}
+
+ {#each playback.verdicts as verdict} +

{verdict.title} {verdict.detail}

+ {/each} + {/if} +
+
+
+ CPU & network + +
+

One device-side sample per tap. Interfaces are shown separately because VPN traffic can also appear on the physical interface.

+ {#if resourceError}{/if} + {#if resources} +
+
CPU use
{resources.cpu_percent == null ? "Unavailable" : `${resources.cpu_percent.toFixed(1)}%`}
+
Sample duration
{resources.interval_ms == null ? "Unavailable" : `${(resources.interval_ms / 1000).toFixed(2)} s`}
+ {#each resources.interfaces as network (network.name)} +
{network.name}
Receive {rate(network.rx_bytes_per_s)} · Send {rate(network.tx_bytes_per_s)}
+ {/each} +
+ {#if resources.interfaces.length === 0}

No network counters available.

{/if} + {/if} +
+
+ {/if} diff --git a/v2/src/lib/components/ShellTab.svelte b/v2/src/lib/components/ShellTab.svelte new file mode 100644 index 0000000..8692df2 --- /dev/null +++ b/v2/src/lib/components/ShellTab.svelte @@ -0,0 +1,299 @@ + + +
+
+

Shell

+ +
+

+ Expert mode runs arbitrary commands via adb shell and can erase data + or make the device unusable. This is an exception to the app's protected-package + safeguards. A basic check catches some obvious dangerous commands, but shell + expressions can bypass it. Output is limited to 256 KiB per stream and execution + to 30 seconds; stopping local ADB does not guarantee remote work has stopped. +

+ + + + +
+ {#each PRESETS as p (p.label)} + + {/each} +
+ +
+ + +
+ + {#if bookmarks.length} +

Bookmarks

+
+ {#each bookmarks as b (b.label)} +
+ + {b.command} + +
+ {/each} +
+ {/if} + + {#if err} +

{err}

+ {:else if result} + {#if result.blocked} +

{result.blocked_reason}

+ {:else} + {#if result.termination !== "completed"} +

{result.termination === "timeout" ? "Stopped after 30 seconds." : "Stopped at the output limit."} Partial output is shown below. The command may already have changed the device.

+ {/if} +

+ Output + {#if result.exit_code !== null && result.exit_code !== 0} + exit {result.exit_code} + {/if} +

+ {#if result.stdout.trim()} +
{result.stdout}
+ {/if} + {#if result.stderr.trim()} +
{result.stderr}
+ {/if} + {#if !result.stdout.trim() && !result.stderr.trim()} +

The command produced no output.

+ {/if} + {/if} + {/if} +
+ + diff --git a/v2/src/lib/components/TweaksTab.svelte b/v2/src/lib/components/TweaksTab.svelte index 30705ae..cbe8e0c 100644 --- a/v2/src/lib/components/TweaksTab.svelte +++ b/v2/src/lib/components/TweaksTab.svelte @@ -1,5 +1,5 @@