From db81e19f7ca186b6a207fd2065ef9f0cc252314b Mon Sep 17 00:00:00 2001 From: arielpineiro Date: Wed, 9 Sep 2026 22:49:30 +0200 Subject: [PATCH 1/5] Playback report, audio passthrough, resource sampling, shell runner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four additions to v2, all reading facts off the device rather than assuming them. Playback tab (engine/media.rs): rolls up the decoder list from media_codecs*.xml, every supported display mode, HDR formats, and the encoded-surround settings into one report, then derives a verdict — whether 24p can actually reach the panel, whether lossless audio is being decoded on the way out, what a format falls back to. Two layers, kept apart on purpose: derived facts work on any Android TV, while curated per-family notes (a Shield will not tell you its SoC has no AV1 block) are additive and rendered separately. An unreadable codec list is reported as unknown, never as unsupported. That distinction is load-bearing: a Shield TV Pro keeps the file under /vendor/odm/etc, so a glob missing that path made the whole report read as "no AV1" on the device this app is named after. Tweaks: encoded_surround_output plus a format allow-list, for sinks that under-report what they can decode and leave TrueHD/DTS-HD downmixed. Health: CPU and network rates from two /proc reads either side of a device-side sleep, in one round trip. Kept out of health_report so a refresh never pays the sampling second. Shell tab: command runner with read-only presets and local bookmarks. engine::safety::shell_command_blocked refuses commands that plainly disable or remove a never-disable package before anything is sent — an anti-footgun, not a boundary, and its docs and tests say so explicitly. parse_display_modes now returns every mode instead of only the active one; 23.976 is never the active mode while the UI is on screen, which is exactly why it needed finding. --- CLAUDE.md | 2 +- v2/ATVTOOLS-PARITY.md | 12 +- v2/HANDOFF.md | 2 +- v2/crates/core/src/adb/mod.rs | 10 +- v2/crates/core/src/adb/parse.rs | 222 ++++ v2/crates/core/src/commands/health.rs | 331 +++++- v2/crates/core/src/commands/mod.rs | 1 + v2/crates/core/src/commands/shell.rs | 220 ++++ v2/crates/core/src/commands/tuning.rs | 13 +- v2/crates/core/src/engine/media.rs | 1035 +++++++++++++++++++ v2/crates/core/src/engine/mod.rs | 7 +- v2/crates/core/src/engine/safety.rs | 159 +++ v2/crates/core/src/engine/snapshot.rs | 5 + v2/crates/core/src/license.rs | 2 + v2/mobile/src/lib/types.ts | 5 + v2/screenshots/README.md | 2 +- v2/screenshots/capture.mjs | 28 +- v2/src-tauri/src/lib.rs | 5 +- v2/src/lib/api.ts | 9 + v2/src/lib/components/MediaTab.svelte | 326 ++++++ v2/src/lib/components/ShellTab.svelte | 285 +++++ v2/src/lib/components/TweaksTab.svelte | 123 +++ v2/src/lib/demo-mock.ts | 124 +++ v2/src/lib/prefs.ts | 33 + v2/src/lib/types.ts | 64 ++ v2/src/routes/devices/[serial]/+page.svelte | 62 +- 26 files changed, 3058 insertions(+), 29 deletions(-) create mode 100644 v2/crates/core/src/commands/shell.rs create mode 100644 v2/crates/core/src/engine/media.rs create mode 100644 v2/src/lib/components/MediaTab.svelte create mode 100644 v2/src/lib/components/ShellTab.svelte diff --git a/CLAUDE.md b/CLAUDE.md index cf876a6..e784b23 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -49,7 +49,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 all 14 screens (demo data, dark theme) + rebuilds the GIF ``` 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..8ef10aa 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 capability report** (Media tab) has no aTV Tools counterpart — codec/HDR/display-mode/passthrough facts read off the device, plus a verdict that names what is costing 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** — ✅ shipped as the Shell tab. Command input → driver → stdout/stderr, read-only presets, bookmarks in `localStorage`. `engine::safety::shell_command_blocked` refuses any command that would disable/uninstall a never-disable package *before* it reaches the device, so the free-form box cannot route around the safety list. **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** — ✅ shipped as the `resource_sample` command: two `/proc/stat` + `/proc/net/dev` reads either side of a device-side `sleep 1`, in one round trip. Kept out of `health_report` so a refresh never pays the sampling second. ### P3 — Evaluate later **10. Screen recording** — `screenrecord` (3-min cap, no DRM), pull + save. Nice demo material. diff --git a/v2/HANDOFF.md b/v2/HANDOFF.md index 9fd371b..9412690 100644 --- a/v2/HANDOFF.md +++ b/v2/HANDOFF.md @@ -2,7 +2,7 @@ v2 is a shipping desktop app. Current version: **2.1.0**. -65 Tauri commands registered (`v2/src-tauri/src/lib.rs`). Full release pipeline live: installers built for macOS/Linux/Windows on every `v2-*` tag push via `.github/workflows/v2-release.yml`; macOS also distributed via Homebrew tap (`bryanroscoe/homebrew-shield-optimizer`). +68 Tauri commands registered (`v2/src-tauri/src/lib.rs`). Full release pipeline live: installers built for macOS/Linux/Windows on every `v2-*` tag push via `.github/workflows/v2-release.yml`; macOS also distributed via Homebrew tap (`bryanroscoe/homebrew-shield-optimizer`). ## Roadmap diff --git a/v2/crates/core/src/adb/mod.rs b/v2/crates/core/src/adb/mod.rs index 41f8b38..8587c37 100644 --- a/v2/crates/core/src/adb/mod.rs +++ b/v2/crates/core/src/adb/mod.rs @@ -10,10 +10,10 @@ pub use batch::{checked_batch_command, parse_checked_batch}; pub use driver::{AdbByteStream, AdbDriver, AdbError, AdbOutput, AdbResult}; 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_installed_packages_output, parse_ls_output, parse_meminfo_summary, - parse_permission_granted, parse_storage_info, parse_thermal_max_celsius, - parse_total_pss_by_process, parse_usage_stats, AppUsage, DisplayMode, FileEntry, RamInfo, - StorageInfo, + parse_display_mode, parse_display_modes, parse_dumpsys_meminfo, parse_hardware_properties_temp, + parse_installed_packages_output, parse_ls_output, parse_meminfo_summary, 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, + NetSample, RamInfo, StorageInfo, }; pub use remote_input::RemoteInputSession; diff --git a/v2/crates/core/src/adb/parse.rs b/v2/crates/core/src/adb/parse.rs index a5b51dd..8e22aba 100644 --- a/v2/crates/core/src/adb/parse.rs +++ b/v2/crates/core/src/adb/parse.rs @@ -425,6 +425,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; + + 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::().unwrap_or(0)) + .collect(); + if values.len() < 4 { + return None; + } + let total: u64 = values.iter().sum(); + let idle = values[3] + values.get(4).copied().unwrap_or(0); + Some(CpuSample { + busy: total.saturating_sub(idle), + total, + }) +} + +/// Byte counters summed across every real network interface. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct NetSample { + pub rx_bytes: u64, + pub tx_bytes: u64, +} + +/// Parse `/proc/net/dev`, summing all interfaces except loopback. +/// +/// Loopback is excluded because on-device IPC (including ADB's own traffic on +/// some transports) flows over it, which would swamp the number the user +/// actually wants: how much is going over the wire. +pub fn parse_net_dev(proc_net_dev: &str) -> Option { + let mut sample = NetSample { + rx_bytes: 0, + tx_bytes: 0, + }; + let mut saw_interface = false; + 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; + }; + saw_interface = true; + sample.rx_bytes += rx; + sample.tx_bytes += tx; + } + saw_interface.then_some(sample) +} + /// 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. @@ -618,6 +761,85 @@ 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 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_sums_real_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 sample = parse_net_dev(input).unwrap(); + assert_eq!(sample.rx_bytes, 150_000); + assert_eq!(sample.tx_bytes, 30_000); + } + + #[test] + fn net_dev_with_no_usable_interfaces_is_none() { + // Header only, loopback only, and garbage all mean "no reading" — + // distinct from a real zero, which would misreport as idle traffic. + assert_eq!(parse_net_dev(""), None); + assert_eq!(parse_net_dev("Inter-| Receive | Transmit"), None); + assert!(parse_net_dev(" lo: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16").is_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..6f4240e 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,172 @@ 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 fail silently — `batch_command` already discards stderr, +/// and `parse_media_codecs` tolerates the concatenation of several documents. +/// +/// `/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"; + +/// `media_report` — what this device can actually decode and output. +/// +/// `device_type` comes from the caller rather than a fresh `getprop` round +/// trip: the frontend already holds the profiled device, and re-deriving it +/// here would fork the single canonical detection path. +#[tauri::command] +pub async fn media_report( + state: State<'_, AppState>, + serial: String, + device_type: crate::engine::DeviceType, +) -> Result { + media_report_for(state.inner(), &serial, device_type).await +} + +async fn media_report_for( + state: &AppState, + serial: &str, + device_type: crate::engine::DeviceType, +) -> Result { + use std::time::Duration; + use tokio::time::timeout; + + let adb = state.adb_snapshot().await; + let cmd = 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 = split_batch(&batched, 5); + if sections.iter().all(|section| section.trim().is_empty()) { + return Err("The TV returned no playback data. Retry the report.".into()); + } + + // 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]), + device_type, + )) +} + +/// 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 rx_bytes_per_s: Option, + pub tx_bytes_per_s: Option, + /// The nominal sampling window, so the UI can label the reading. + pub interval_ms: u64, +} + +/// Device-side sampling window. Both samples and the sleep between them run +/// inside one shell, so the delta is measured on the device and Wi-Fi latency +/// never lands in the denominator. +const SAMPLE_INTERVAL_MS: u64 = 1000; + +/// `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 and can poll it 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 = batch_command(&[ + "cat /proc/stat", + "cat /proc/net/dev", + &format!("sleep {}", SAMPLE_INTERVAL_MS as f64 / 1000.0), + "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 = split_batch(&batched, 5); + + // Counter deltas use saturating subtraction throughout: an interface going + // down mid-window (or a 32-bit counter wrapping) would otherwise underflow + // into a nonsense spike rather than reading as zero. + let cpu_percent = match (parse_proc_stat(§ions[0]), parse_proc_stat(§ions[3])) { + (Some(a), Some(b)) => { + let total = b.total.saturating_sub(a.total); + let busy = b.busy.saturating_sub(a.busy); + (total > 0).then(|| ((busy as f64 / total as f64) * 1000.0).round() / 10.0) + } + _ => None, + }; + + let (rx_bytes_per_s, tx_bytes_per_s) = + match (parse_net_dev(§ions[1]), parse_net_dev(§ions[4])) { + (Some(a), Some(b)) => { + let per_s = |later: u64, earlier: u64| { + later.saturating_sub(earlier) * 1000 / SAMPLE_INTERVAL_MS + }; + ( + Some(per_s(b.rx_bytes, a.rx_bytes)), + Some(per_s(b.tx_bytes, a.tx_bytes)), + ) + } + _ => (None, None), + }; + + Ok(ResourceSample { + cpu_percent, + rx_bytes_per_s, + tx_bytes_per_s, + interval_ms: SAMPLE_INTERVAL_MS, + }) +} + /// `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 +489,161 @@ 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")); + } + + #[tokio::test] + async fn media_report_decodes_every_section_from_one_round_trip() { + let mock = MockAdb::default().on_shell( + BATCH_SEPARATOR, + &batched(&[DISPLAY, CODECS, "3", "5,6,14", "2"]), + ); + let log = mock.shell_log(); + let state = state_with(mock); + + let caps = media_report_for(&state, "serial", crate::engine::DeviceType::Shield) + .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.hardware); + let av1 = caps.video.iter().find(|v| v.mime == "video/av01").unwrap(); + assert!(!av1.hardware && 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, + &batched(&[DISPLAY, CODECS, "null", "null", "null"]), + )); + let caps = media_report_for(&state, "serial", crate::engine::DeviceType::Shield) + .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, &batched(&[DISPLAY, "", "0", "", "2"])), + ); + let caps = media_report_for(&state, "serial", crate::engine::DeviceType::Shield) + .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", crate::engine::DeviceType::Shield) + .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"; + + #[tokio::test] + async fn resource_sample_derives_rates_from_two_device_side_reads() { + let mock = MockAdb::default().on_shell( + BATCH_SEPARATOR, + &batched(&[STAT_A, NET_A, "", 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.rx_bytes_per_s, Some(2000)); + assert_eq!(sample.tx_bytes_per_s, Some(1000)); + + 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, + &batched(&[STAT_A, NET_B, "", 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.rx_bytes_per_s, Some(0)); + assert_eq!(sample.tx_bytes_per_s, Some(0)); + } + + #[tokio::test] + async fn resource_sample_degrades_when_proc_is_unreadable() { + let state = state_with(MockAdb::default().on_shell(BATCH_SEPARATOR, &batched(&["", ""]))); + let sample = resource_sample_for(&state, "serial").await.unwrap(); + assert_eq!(sample.cpu_percent, None); + assert_eq!(sample.rx_bytes_per_s, None); + assert_eq!(sample.interval_ms, SAMPLE_INTERVAL_MS); + } + #[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 03a5709..ae4f3ab 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; diff --git a/v2/crates/core/src/commands/shell.rs b/v2/crates/core/src/commands/shell.rs new file mode 100644 index 0000000..0463dab --- /dev/null +++ b/v2/crates/core/src/commands/shell.rs @@ -0,0 +1,220 @@ +//! 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; + +/// 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, + /// 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, +} + +/// Cap on returned output. A stray `logcat` or `dumpsys` with no filter can +/// emit tens of megabytes; the frontend has to render whatever comes back, so +/// the truncation happens here rather than after it has crossed the IPC +/// boundary and blown up the webview. +const MAX_OUTPUT_BYTES: usize = 256 * 1024; + +/// `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, + blocked: true, + blocked_reason: Some(format!( + "Refused: this command would disable or remove {package}, which is on the \ + do-not-disable list. {reason}" + )), + }); + } + + let adb = state.adb_snapshot().await; + let out = adb + .shell(serial, trimmed) + .await + .map_err(|e| format!("{trimmed}: {e}"))?; + + Ok(ShellRunResult { + stdout: truncate(out.stdout), + stderr: truncate(out.stderr), + exit_code: out.exit_code, + blocked: false, + blocked_reason: None, + }) +} + +/// Trim to [`MAX_OUTPUT_BYTES`] on a char boundary, with a visible marker so a +/// truncated dump is never mistaken for the whole thing. +fn truncate(mut s: String) -> String { + if s.len() <= MAX_OUTPUT_BYTES { + return s; + } + let mut cut = MAX_OUTPUT_BYTES; + while cut > 0 && !s.is_char_boundary(cut) { + cut -= 1; + } + s.truncate(cut); + s.push_str("\n… output truncated at 256 KB …"); + s +} + +#[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")); + } + + #[test] + fn oversized_output_is_truncated_on_a_char_boundary_and_marked() { + // Multi-byte chars straddling the cut would panic a naive truncate. + let huge = "é".repeat(MAX_OUTPUT_BYTES); + let out = truncate(huge); + assert!(out.len() < MAX_OUTPUT_BYTES + 64); + assert!(out.ends_with("… output truncated at 256 KB …")); + } + + #[test] + fn output_under_the_cap_is_returned_untouched() { + assert_eq!(truncate("short".into()), "short"); + } +} diff --git a/v2/crates/core/src/commands/tuning.rs b/v2/crates/core/src/commands/tuning.rs index 3fc1c9f..f615ed8 100644 --- a/v2/crates/core/src/commands/tuning.rs +++ b/v2/crates/core/src/commands/tuning.rs @@ -29,6 +29,13 @@ 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. @@ -44,7 +51,9 @@ pub async fn get_tweaks(state: State<'_, AppState>, serial: String) -> Result, serial: String) -> Result bool { + let lower = name.to_ascii_lowercase(); + lower.starts_with("omx.google.") + || lower.starts_with("c2.android.") + || lower.starts_with("omx.ffmpeg.") + } +} + +/// A video format rolled up across every decoder that advertises it. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct VideoFormat { + /// Display label, e.g. "HEVC / H.265". + pub label: String, + pub mime: String, + /// At least one vendor (silicon-backed) decoder advertises it. + pub hardware: bool, + /// At least one platform software decoder advertises it. + pub software: bool, +} + +impl VideoFormat { + /// Neither hardware nor software decoder present. + pub fn unsupported(&self) -> bool { + !self.hardware && !self.software + } +} + +/// One entry from `dumpsys display`'s `supportedModes`. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct DisplayModeEntry { + pub width: u32, + pub height: u32, + pub fps: f64, + /// The mode the display is currently running. + pub active: bool, +} + +impl DisplayModeEntry { + /// Whether this mode can present 24p film at its native cadence. Covers + /// both 23.976 (NTSC-pulled 24) and true 24.000, which is what film + /// sources actually ship as. + pub fn is_film_rate(&self) -> bool { + (23.9..=24.1).contains(&self.fps) + } +} + +/// `settings get global encoded_surround_output`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SurroundMode { + /// `0` — negotiate with the connected sink over HDMI/eARC. + Auto, + /// `1` — never pass encoded audio through; decode everything to PCM. + Never, + /// `2` — always pass through, whatever the sink reports. + Always, + /// `3` — pass through only the formats in the enabled-formats list. + Manual, + /// Key absent — Android treats this as Auto. + Unset, +} + +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, + _ => Self::Unset, + } + } + + pub fn label(self) -> &'static str { + match self { + Self::Auto => "Auto", + Self::Never => "Never", + Self::Always => "Always", + Self::Manual => "Manual", + Self::Unset => "Auto (unset)", + } + } +} + +/// Encoded-audio passthrough configuration. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct AudioPassthrough { + pub mode: SurroundMode, + /// Friendly names of the formats in `encoded_surround_output_enabled_formats`. + /// Only meaningful when `mode` is `Manual`. + pub enabled_formats: Vec, + /// The raw comma-separated setting value, kept so the UI can show what is + /// actually on the device rather than only our interpretation. + pub raw_formats: Option, +} + +/// Severity of a verdict line. Drives colour only — no behavior depends on it. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum VerdictLevel { + /// Working as it should. + Good, + /// Costs quality or will surprise the user. Actionable. + Warn, + /// Worth knowing, nothing to fix. + Info, +} + +/// One line of the playback verdict. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Verdict { + pub level: VerdictLevel, + pub title: String, + pub detail: String, + /// Curated per-device knowledge appended to a derived verdict. Rendered + /// visually apart so the user can tell "your device reports this" from + /// "we know this about this device family". + pub note: Option, +} + +/// Everything the Media tab renders, in one payload. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct MediaCapabilities { + pub video: Vec, + /// From `mSupportedHdrTypes` — what the *display chain* accepts, which is + /// not the same question as what the device can decode. + pub hdr_types: Vec, + pub modes: Vec, + pub audio: AudioPassthrough, + /// `secure.match_content_frame_rate` — `0` Never / `1` Seamless / `2` Always. + pub match_content_frame_rate: Option, + pub verdicts: Vec, +} + +/// Video MIME types we roll up, in the order the UI shows them. Anything the +/// device advertises outside this list is ignored rather than surfaced with a +/// raw MIME string — the tab answers "can it play my library", not "dump every +/// codec". +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"), +]; + +/// Scan `media_codecs*.xml` for decoder entries. +/// +/// Deliberately a tolerant line scanner rather than a real XML parse: the file +/// is concatenated from several vendor/system copies before it reaches us (one +/// `cat` over a glob), so the input is not a single well-formed document and +/// any strict parser would reject it outright. Encoder entries are skipped by +/// tracking which `` / `` block we are inside. +pub fn parse_media_codecs(xml: &str) -> Vec { + let mut out: Vec = Vec::new(); + // None until the first block marker — some vendor files list `` + // before any block wrapper, and those are decoders in practice. + let mut in_encoders = false; + + for line in xml.lines() { + let trimmed = line.trim(); + if trimmed.starts_with(" Option { + let needle = format!("{key}=\""); + let start = tag.find(&needle)? + needle.len(); + let rest = &tag[start..]; + let end = rest.find('"')?; + Some(rest[..end].to_string()) +} + +/// Roll decoders up into the fixed [`KNOWN_VIDEO`] list. Formats the device +/// never mentions are still returned, flagged unsupported — "no AV1" is the +/// answer the user came for, and an absent row would not say it. +pub fn video_formats(decoders: &[Decoder]) -> Vec { + KNOWN_VIDEO + .iter() + .map(|(mime, label)| { + let matching: Vec<&Decoder> = decoders.iter().filter(|d| d.mime == *mime).collect(); + VideoFormat { + label: (*label).to_string(), + mime: (*mime).to_string(), + hardware: matching.iter().any(|d| d.hardware), + software: matching.iter().any(|d| !d.hardware), + } + }) + .collect() +} + +/// `AudioFormat.ENCODING_*` constants, as written into +/// `encoded_surround_output_enabled_formats`. Only the encodings that can +/// appear in a surround passthrough list are mapped; anything else renders as +/// its raw number rather than being dropped, so an unmapped value stays +/// visible instead of silently vanishing from the UI. +fn audio_format_label(code: &str) -> String { + match code.trim() { + "5" => "Dolby Digital (AC-3)".to_string(), + "6" => "Dolby Digital Plus (E-AC-3)".to_string(), + "7" => "DTS".to_string(), + "8" => "DTS-HD".to_string(), + "14" => "Dolby TrueHD".to_string(), + "17" => "Dolby AC-4".to_string(), + "18" => "Dolby Atmos over DD+ (E-AC-3 JOC)".to_string(), + "19" => "Dolby MAT".to_string(), + "26" => "DTS:X (DTS-UHD)".to_string(), + other => format!("Format {other}"), + } +} + +/// Build the passthrough view from the two `settings get` reads. +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, + } +} + +/// Assemble the full capability payload, verdicts included. +pub fn build_capabilities( + decoders: &[Decoder], + hdr_types: Vec, + modes: Vec, + audio: AudioPassthrough, + match_content_frame_rate: Option, + device_type: DeviceType, +) -> MediaCapabilities { + let video = video_formats(decoders); + let verdicts = verdicts( + &video, + &hdr_types, + &modes, + &audio, + match_content_frame_rate.as_deref(), + device_type, + ); + MediaCapabilities { + video, + hdr_types, + modes, + audio, + match_content_frame_rate, + verdicts, + } +} + +fn find<'a>(video: &'a [VideoFormat], mime: &str) -> Option<&'a VideoFormat> { + video.iter().find(|v| v.mime == mime) +} + +/// Curated per-device-family knowledge for one verdict topic. Returns `None` +/// when we have nothing to add beyond what the device reported — the common +/// case, and the reason this stays a small table rather than a data file. +fn device_notes(topic: &str, device_type: DeviceType) -> Option { + match (device_type, topic) { + (DeviceType::Shield, "av1") => Some( + "The Shield's Tegra X1/X1+ has no AV1 decode block, and no firmware update can \ + add one. AV1 streams fall back to software decoding — fine at 1080p, \ + unreliable above it." + .into(), + ), + (DeviceType::Shield, "dolby_vision") => Some( + "Profiles 5 and 8 play natively. Profile 7 — the dual-layer format UHD Blu-ray \ + remuxes use — plays the base layer only: the enhancement layer is discarded, so \ + FEL titles render from a base grade that was never meant to be shown alone. \ + Converting Profile 7 to 8.1 before playback avoids that." + .into(), + ), + _ => None, + } +} + +/// Derive the verdict lines. Order is deliberate — the things most likely to +/// be silently costing quality come first. +fn verdicts( + video: &[VideoFormat], + hdr_types: &[String], + modes: &[DisplayModeEntry], + audio: &AudioPassthrough, + match_content_frame_rate: Option<&str>, + device_type: DeviceType, +) -> Vec { + let mut out = Vec::new(); + + // --- 24p cadence. The single most common silent quality loss. + let film_mode = modes.iter().find(|m| m.is_film_rate()); + match (film_mode, match_content_frame_rate) { + (None, _) if modes.is_empty() => {} + (None, _) => out.push(Verdict { + level: VerdictLevel::Warn, + title: "No 24p output mode".into(), + detail: "The display chain advertises no 23.976 / 24 Hz mode, so film content is \ + rate-converted to the panel's rate. That is where 3:2 judder comes from." + .into(), + note: None, + }), + (Some(mode), Some("2")) => out.push(Verdict { + level: VerdictLevel::Good, + title: format!("24p handled ({:.3} Hz mode available)", mode.fps), + detail: "Match Content Frame Rate is set to Always, so film switches to its native \ + cadence instead of being pulled to the panel rate." + .into(), + note: None, + }), + (Some(mode), Some("1")) => out.push(Verdict { + level: VerdictLevel::Info, + title: format!("24p available ({:.3} Hz), seamless switches only", mode.fps), + detail: "Match Content Frame Rate is Seamless only: the device switches rate solely \ + when it can do so without a black frame. On links that cannot, film stays \ + at the panel rate and judders." + .into(), + note: None, + }), + (Some(mode), _) => out.push(Verdict { + level: VerdictLevel::Warn, + title: format!("24p mode exists ({:.3} Hz) but is never used", mode.fps), + detail: "Match Content Frame Rate is Never or unset, so every film is converted to \ + the panel's refresh rate — 3:2 judder on all 24p content. Set it to Always \ + on the Tweaks tab." + .into(), + note: None, + }), + } + + // --- Encoded audio passthrough. + match audio.mode { + SurroundMode::Never => out.push(Verdict { + level: VerdictLevel::Warn, + title: "Encoded surround passthrough is off".into(), + detail: "Every soundtrack is decoded to PCM on the device, so lossless formats never \ + reach the receiver — a TrueHD or DTS-HD track arrives downmixed." + .into(), + note: None, + }), + SurroundMode::Manual => out.push(Verdict { + level: VerdictLevel::Info, + title: "Surround passthrough is on a manual allow-list".into(), + detail: if audio.enabled_formats.is_empty() { + "Mode is Manual but the allow-list is empty, so nothing is passed through." + .to_string() + } else { + format!( + "Only these pass through: {}. Anything else is decoded on the device.", + audio.enabled_formats.join(", ") + ) + }, + note: None, + }), + SurroundMode::Always => out.push(Verdict { + level: VerdictLevel::Info, + title: "Surround passthrough is forced".into(), + detail: "Encoded audio is sent regardless of what the sink reports supporting. If the \ + receiver cannot decode a format, that track plays silent." + .into(), + note: None, + }), + SurroundMode::Auto | SurroundMode::Unset => out.push(Verdict { + level: VerdictLevel::Good, + title: "Surround passthrough negotiates automatically".into(), + detail: "The device sends whatever the connected receiver or TV reports it can \ + decode. Switch to Manual only if a sink misreports its support." + .into(), + note: None, + }), + } + + // Whether the device gave us a decoder list at all. `media_codecs*.xml` + // is world-readable on every build we know of, but a restricted or + // relocated vendor partition would leave us with nothing — and "we could + // not read it" must never render as "the format is unsupported". Every + // codec-derived verdict below is gated on this. + let codecs_known = video.iter().any(|v| !v.unsupported()); + if !codecs_known { + out.push(Verdict { + level: VerdictLevel::Info, + title: "Decoder list unavailable".into(), + detail: "The device did not return a readable media_codecs XML, so codec support \ + could not be determined. This is not the same as a format being \ + unsupported — nothing is being claimed either way." + .into(), + note: None, + }); + } + + // --- Dolby Vision. + let dv_decoder = + codecs_known && find(video, "video/dolby-vision").is_some_and(|v| !v.unsupported()); + let dv_display = hdr_types + .iter() + .any(|h| h.eq_ignore_ascii_case("dolby vision")); + if dv_decoder || dv_display { + out.push(Verdict { + level: VerdictLevel::Good, + title: "Dolby Vision available".into(), + detail: match (dv_decoder, dv_display) { + (true, true) => "The device advertises a Dolby Vision decoder and the display \ + chain accepts Dolby Vision." + .into(), + (true, false) => "The device advertises a Dolby Vision decoder, but the current \ + display chain does not report accepting it — check the TV \ + input and the cable." + .into(), + _ => "The display chain accepts Dolby Vision. The device reports no dedicated \ + Dolby Vision decoder entry, which is normal on some builds." + .into(), + }, + note: device_notes("dolby_vision", device_type), + }); + } else if codecs_known { + out.push(Verdict { + level: VerdictLevel::Info, + title: "No Dolby Vision".into(), + detail: "Neither a Dolby Vision decoder nor Dolby Vision display support is reported. \ + Dolby Vision titles fall back to their HDR10 layer where one exists." + .into(), + note: None, + }); + } + + // --- AV1. + if let Some(av1) = find(video, "video/av01").filter(|_| codecs_known) { + if av1.hardware { + out.push(Verdict { + level: VerdictLevel::Good, + title: "AV1 decoded in hardware".into(), + detail: "AV1 streams play without loading the CPU.".into(), + note: None, + }); + } else { + out.push(Verdict { + level: VerdictLevel::Warn, + title: if av1.software { + "AV1 in software only".into() + } else { + "No AV1 support".into() + }, + detail: if av1.software { + "No hardware AV1 decoder is advertised. AV1 falls back to CPU decoding, which \ + stutters above 1080p on TV-class silicon." + .into() + } else { + "No AV1 decoder is advertised at all. AV1 sources will not play; services \ + that offer AV1 need to be steered to an H.264 or HEVC ladder." + .into() + }, + note: device_notes("av1", device_type), + }); + } + } + + // --- HEVC, the format the library is actually in. + if let Some(hevc) = find(video, "video/hevc").filter(|_| codecs_known) { + if !hevc.hardware { + out.push(Verdict { + level: VerdictLevel::Warn, + title: "HEVC not hardware-accelerated".into(), + detail: "No vendor HEVC decoder is advertised. High-bitrate 4K HEVC will not play \ + reliably through software decoding." + .into(), + note: None, + }); + } + } + + // --- HDR, last: it describes the current link, not a fixed capability. + if hdr_types.is_empty() && !modes.is_empty() { + out.push(Verdict { + level: VerdictLevel::Info, + title: "Display chain reports SDR only".into(), + detail: "No HDR formats are advertised on the current link. This tracks whatever the \ + device is connected to right now — a powered-off TV or a receiver in the \ + middle commonly reads this way." + .into(), + note: None, + }); + } + + out +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + /// Shaped like a real Shield `media_codecs.xml`: vendor decoders, an + /// encoder block that must be ignored, and Android's software fallbacks. + const SHIELD_XML: &str = r#" + + + + + + + + + + + + + + + + + +"#; + + fn shield_video() -> Vec { + video_formats(&parse_media_codecs(SHIELD_XML)) + } + + fn modes_with_film() -> Vec { + vec![ + DisplayModeEntry { + width: 3840, + height: 2160, + fps: 59.94, + active: true, + }, + DisplayModeEntry { + width: 3840, + height: 2160, + fps: 23.976, + active: false, + }, + ] + } + + fn auto_audio() -> AudioPassthrough { + surround_mode(Some("0"), None) + } + + fn titles(v: &[Verdict]) -> Vec<&str> { + v.iter().map(|x| x.title.as_str()).collect() + } + + #[test] + fn parses_decoders_and_skips_encoders() { + let decoders = parse_media_codecs(SHIELD_XML); + assert!(decoders + .iter() + .any(|d| d.name == "OMX.Nvidia.h265.decode" && d.mime == "video/hevc"),); + // The encoder block declares AV1 too — only the decoder entry counts. + let av1: Vec<&Decoder> = decoders.iter().filter(|d| d.mime == "video/av01").collect(); + assert_eq!(av1.len(), 1); + assert_eq!(av1[0].name, "c2.android.av1.decoder"); + assert!(!decoders.iter().any(|d| d.name.contains("encoder"))); + } + + #[test] + fn classifies_the_component_names_a_real_shield_advertises() { + // Verbatim from a Shield TV Pro (mdarcy, Android 11). The `.secure` + // DRM variants sit on the same silicon and must not read as software, + // and `OMX.google.*` fallbacks must not read as hardware — that pair + // is what makes the AV1 and HEVC verdicts come out right. + let xml = r#" + + + + + + + +"#; + let video = video_formats(&parse_media_codecs(xml)); + let by_mime = |m: &str| video.iter().find(|v| v.mime == m).unwrap().clone(); + + let hevc = by_mime("video/hevc"); + assert!(hevc.hardware && hevc.software); + assert!(by_mime("video/dolby-vision").hardware); + assert!(by_mime("video/x-vnd.on2.vp9").hardware); + // H.264 only has the Google software fallback in this excerpt. + let avc = by_mime("video/avc"); + assert!(!avc.hardware && avc.software); + // The device advertises no AV1 decoder of any kind. + assert!(by_mime("video/av01").unsupported()); + } + + #[test] + fn vendor_prefixes_are_hardware_and_platform_prefixes_are_not() { + let decoders = parse_media_codecs(SHIELD_XML); + let by_name = |n: &str| decoders.iter().find(|d| d.name == n).unwrap().hardware; + assert!(by_name("OMX.Nvidia.h265.decode")); + assert!(by_name("OMX.dolby.vision.decoder")); + assert!(!by_name("c2.android.av1.decoder")); + assert!(!by_name("c2.android.avc.decoder")); + } + + #[test] + fn a_format_with_both_decoder_kinds_reports_both() { + // H.264 has a vendor decoder *and* the platform fallback. + let avc = shield_video() + .into_iter() + .find(|v| v.mime == "video/avc") + .unwrap(); + assert!(avc.hardware); + assert!(avc.software); + assert!(!avc.unsupported()); + } + + #[test] + fn multi_mime_type_attribute_expands_to_one_decoder_each() { + let decoders = parse_media_codecs( + r#""#, + ); + assert_eq!(decoders.len(), 2); + assert!(decoders.iter().all(|d| d.hardware)); + assert_eq!(decoders[0].mime, "video/avc"); + assert_eq!(decoders[1].mime, "video/hevc"); + } + + #[test] + fn concatenated_files_do_not_duplicate_or_desync_the_encoder_block() { + // `cat` over a glob yields several documents back to back. The second + // file's `` has to re-open decoder scanning after the first + // file's `` block. + let doubled = format!("{SHIELD_XML}\n{SHIELD_XML}"); + assert_eq!(parse_media_codecs(&doubled), parse_media_codecs(SHIELD_XML)); + } + + #[test] + fn malformed_entries_are_skipped_not_fatal() { + let xml = r#" + + + + "#; + let decoders = parse_media_codecs(xml); + assert_eq!(decoders.len(), 1); + assert_eq!(decoders[0].name, "OMX.ok.decode"); + } + + #[test] + fn an_unreadable_codec_file_yields_no_decoders_rather_than_panicking() { + assert!(parse_media_codecs("").is_empty()); + assert!(parse_media_codecs("cat: /vendor/etc/media_codecs.xml: No such file").is_empty()); + } + + #[test] + fn formats_the_device_never_mentions_are_reported_unsupported() { + // The point of the tab: "no AV1" has to be a visible row, not an + // absent one. Strip AV1 from the fixture and it must still appear. + let decoders: Vec = parse_media_codecs(SHIELD_XML) + .into_iter() + .filter(|d| d.mime != "video/av01") + .collect(); + let av1 = video_formats(&decoders) + .into_iter() + .find(|v| v.mime == "video/av01") + .unwrap(); + assert!(av1.unsupported()); + } + + #[test] + fn shield_av1_is_software_only_and_carries_the_soc_note() { + let v = verdicts( + &shield_video(), + &["HDR10".into()], + &modes_with_film(), + &auto_audio(), + Some("2"), + DeviceType::Shield, + ); + let av1 = v.iter().find(|x| x.title.contains("AV1")).unwrap(); + assert_eq!(av1.level, VerdictLevel::Warn); + assert_eq!(av1.title, "AV1 in software only"); + assert!(av1.note.as_ref().unwrap().contains("Tegra")); + } + + #[test] + fn curated_notes_are_scoped_to_the_device_family() { + // Same hardware facts, unknown device: derived verdict identical, + // curated note absent. This is the guardrail on the curated layer. + let args = (shield_video(), vec!["HDR10".to_string()], modes_with_film()); + let shield = verdicts( + &args.0, + &args.1, + &args.2, + &auto_audio(), + Some("2"), + DeviceType::Shield, + ); + let other = verdicts( + &args.0, + &args.1, + &args.2, + &auto_audio(), + Some("2"), + DeviceType::GoogleTv, + ); + assert_eq!(titles(&shield), titles(&other)); + let av1_note = |v: &[Verdict]| { + v.iter() + .find(|x| x.title.contains("AV1")) + .unwrap() + .note + .clone() + }; + assert!(av1_note(&shield).is_some()); + assert_eq!(av1_note(&other), None); + } + + #[test] + fn dolby_vision_note_names_the_profile_7_base_layer_behavior() { + let v = verdicts( + &shield_video(), + &["Dolby Vision".into(), "HDR10".into()], + &modes_with_film(), + &auto_audio(), + Some("2"), + DeviceType::Shield, + ); + let dv = v.iter().find(|x| x.title.contains("Dolby Vision")).unwrap(); + assert_eq!(dv.level, VerdictLevel::Good); + let note = dv.note.as_ref().unwrap(); + assert!(note.contains("Profile 7")); + assert!(note.contains("base layer only")); + } + + #[test] + fn film_rate_verdict_tracks_the_match_content_setting() { + let level_for = |setting: Option<&str>| { + let v = verdicts( + &shield_video(), + &["HDR10".into()], + &modes_with_film(), + &auto_audio(), + setting, + DeviceType::Shield, + ); + v[0].level + }; + assert_eq!(level_for(Some("2")), VerdictLevel::Good); + assert_eq!(level_for(Some("1")), VerdictLevel::Info); + assert_eq!(level_for(Some("0")), VerdictLevel::Warn); + // Unset behaves like Never — Android does not switch rate by default. + assert_eq!(level_for(None), VerdictLevel::Warn); + } + + #[test] + fn a_device_with_no_film_rate_mode_is_flagged_even_when_matching_is_on() { + let modes = vec![DisplayModeEntry { + width: 3840, + height: 2160, + fps: 60.0, + active: true, + }]; + let v = verdicts( + &shield_video(), + &["HDR10".into()], + &modes, + &auto_audio(), + Some("2"), + DeviceType::Shield, + ); + assert_eq!(v[0].level, VerdictLevel::Warn); + assert_eq!(v[0].title, "No 24p output mode"); + } + + #[test] + fn true_24_counts_as_film_rate_alongside_23_976() { + assert!(DisplayModeEntry { + width: 1, + height: 1, + fps: 24.0, + active: false + } + .is_film_rate()); + assert!(DisplayModeEntry { + width: 1, + height: 1, + fps: 23.976, + active: false + } + .is_film_rate()); + assert!(!DisplayModeEntry { + width: 1, + height: 1, + fps: 25.0, + active: false + } + .is_film_rate()); + assert!(!DisplayModeEntry { + width: 1, + height: 1, + fps: 30.0, + active: false + } + .is_film_rate()); + } + + #[test] + fn no_display_modes_at_all_produces_no_cadence_claim() { + // A truncated `dumpsys display` must not be read as "no 24p mode". + let v = verdicts( + &shield_video(), + &[], + &[], + &auto_audio(), + Some("2"), + DeviceType::Shield, + ); + assert!(!v.iter().any(|x| x.title.contains("24p"))); + // …and it must not claim the link is SDR either. + assert!(!v.iter().any(|x| x.title.contains("SDR only"))); + } + + #[test] + fn surround_modes_map_from_the_raw_setting() { + assert_eq!(SurroundMode::from_raw(Some("0")), SurroundMode::Auto); + assert_eq!(SurroundMode::from_raw(Some("1")), SurroundMode::Never); + assert_eq!(SurroundMode::from_raw(Some("2")), SurroundMode::Always); + assert_eq!(SurroundMode::from_raw(Some("3")), SurroundMode::Manual); + assert_eq!(SurroundMode::from_raw(None), SurroundMode::Unset); + assert_eq!(SurroundMode::from_raw(Some("null")), SurroundMode::Unset); + } + + #[test] + fn manual_format_list_decodes_to_friendly_names() { + let audio = surround_mode(Some("3"), Some("5,6,7,8,14,18")); + assert_eq!( + audio.enabled_formats, + [ + "Dolby Digital (AC-3)", + "Dolby Digital Plus (E-AC-3)", + "DTS", + "DTS-HD", + "Dolby TrueHD", + "Dolby Atmos over DD+ (E-AC-3 JOC)", + ] + ); + assert_eq!(audio.raw_formats.as_deref(), Some("5,6,7,8,14,18")); + } + + #[test] + fn unmapped_audio_codes_stay_visible_rather_than_disappearing() { + let audio = surround_mode(Some("3"), Some("14,999")); + assert_eq!(audio.enabled_formats, ["Dolby TrueHD", "Format 999"]); + } + + #[test] + fn passthrough_off_is_a_warning_and_names_the_consequence() { + let v = verdicts( + &shield_video(), + &["HDR10".into()], + &modes_with_film(), + &surround_mode(Some("1"), None), + Some("2"), + DeviceType::Shield, + ); + let audio = v.iter().find(|x| x.title.contains("passthrough")).unwrap(); + assert_eq!(audio.level, VerdictLevel::Warn); + assert!(audio.detail.contains("TrueHD")); + } + + #[test] + fn manual_mode_with_an_empty_allow_list_says_nothing_passes_through() { + let v = verdicts( + &shield_video(), + &["HDR10".into()], + &modes_with_film(), + &surround_mode(Some("3"), None), + Some("2"), + DeviceType::Shield, + ); + let audio = v.iter().find(|x| x.title.contains("allow-list")).unwrap(); + assert!(audio.detail.contains("empty")); + } + + #[test] + fn build_capabilities_carries_every_input_through() { + let caps = build_capabilities( + &parse_media_codecs(SHIELD_XML), + vec!["Dolby Vision".into(), "HDR10".into()], + modes_with_film(), + surround_mode(Some("0"), None), + Some("2".into()), + DeviceType::Shield, + ); + assert_eq!(caps.video.len(), KNOWN_VIDEO.len()); + assert_eq!(caps.hdr_types, ["Dolby Vision", "HDR10"]); + assert_eq!(caps.modes.len(), 2); + assert_eq!(caps.match_content_frame_rate.as_deref(), Some("2")); + assert!(!caps.verdicts.is_empty()); + } + + #[test] + fn a_device_that_reports_nothing_still_produces_a_usable_payload() { + // Everything unreadable: no codecs, no modes, no HDR, no settings. + // The tab must render rather than blow up or invent claims. + let caps = build_capabilities( + &[], + vec![], + vec![], + surround_mode(None, None), + None, + DeviceType::Unknown, + ); + assert!(caps.video.iter().all(VideoFormat::unsupported)); + // The audio setting is the one fact still known; everything codec- + // derived must abstain rather than assert absence. + assert_eq!( + titles(&caps.verdicts), + [ + "Surround passthrough negotiates automatically", + "Decoder list unavailable" + ] + ); + } + + #[test] + fn an_unreadable_codec_list_never_reads_as_unsupported() { + // The distinction this whole gate exists for: `unsupported()` on the + // row means "not advertised", but with no list at all the verdicts + // must not turn that into a claim about AV1, HEVC or Dolby Vision. + let caps = build_capabilities( + &[], + vec!["HDR10".into()], + modes_with_film(), + auto_audio(), + Some("2".into()), + DeviceType::Shield, + ); + for banned in [ + "No AV1 support", + "AV1 in software only", + "HEVC not hardware-accelerated", + "No Dolby Vision", + ] { + assert!( + !titles(&caps.verdicts).contains(&banned), + "must not claim {banned:?} without a decoder list: {:?}", + titles(&caps.verdicts) + ); + } + assert!(caps + .verdicts + .iter() + .any(|v| v.title == "Decoder list unavailable")); + // Facts from other sources are unaffected. + assert!(caps + .verdicts + .iter() + .any(|v| v.title.contains("24p handled"))); + } +} diff --git a/v2/crates/core/src/engine/mod.rs b/v2/crates/core/src/engine/mod.rs index 8e1b5cf..30aef9f 100644 --- a/v2/crates/core/src/engine/mod.rs +++ b/v2/crates/core/src/engine/mod.rs @@ -8,6 +8,7 @@ pub mod app_lists; pub mod detection; pub mod launcher; +pub mod media; pub mod optimize; pub mod safety; pub mod snapshot; @@ -19,8 +20,12 @@ pub use launcher::{ is_last_enabled_home_handler, is_valid_package_name, launcher_catalog, launcher_rows, stock_launcher_catalog, LauncherEntry, LauncherStatus, }; +pub use media::{ + build_capabilities, parse_media_codecs, surround_mode, video_formats, AudioPassthrough, + Decoder, DisplayModeEntry, MediaCapabilities, SurroundMode, Verdict, VerdictLevel, VideoFormat, +}; pub use optimize::{compute_plan, OptimizeInputs, OptimizePlan}; -pub use safety::{classify as classify_safety, is_never_disable, Safety}; +pub use safety::{classify as classify_safety, is_never_disable, shell_command_blocked, Safety}; pub use snapshot::{Snapshot, SnapshotApplyPlan, SnapshotError, SCHEMA_VERSION}; pub use types::{ ActionMethod, AppEntry, Device, DeviceProperties, DeviceStatus, OptimizeAction, OptimizeMode, diff --git a/v2/crates/core/src/engine/safety.rs b/v2/crates/core/src/engine/safety.rs index 8b2ab54..f1c130a 100644 --- a/v2/crates/core/src/engine/safety.rs +++ b/v2/crates/core/src/engine/safety.rs @@ -44,6 +44,69 @@ pub fn is_never_disable(package: &str) -> 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", "uninstall", "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)> { + // Split on everything that separates or delimits an argument, so quoting + // and chaining cannot smuggle a package past the token comparison. + 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| never_disable_reason(t).map(|reason| ((*t).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 @@ -232,6 +295,102 @@ const CAUTION: &[(&str, &str)] = &[ #[cfg(test)] mod tests { + #[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_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}" + ); + } + } + use super::*; #[test] diff --git a/v2/crates/core/src/engine/snapshot.rs b/v2/crates/core/src/engine/snapshot.rs index c88b48e..eccff78 100644 --- a/v2/crates/core/src/engine/snapshot.rs +++ b/v2/crates/core/src/engine/snapshot.rs @@ -29,6 +29,11 @@ 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"), + // Appended, never inserted: `current_settings_map` pairs these keys + // with `settings get` output lines positionally, so reordering would + // silently mis-assign every value after the change. + ("global", "encoded_surround_output"), + ("global", "encoded_surround_output_enabled_formats"), ] } 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/lib/types.ts b/v2/mobile/src/lib/types.ts index 207cbc1..fb5b5ff 100644 --- a/v2/mobile/src/lib/types.ts +++ b/v2/mobile/src/lib/types.ts @@ -238,6 +238,11 @@ 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. + /// Read by `get_tweaks`; no mobile control exposes it yet. + encoded_surround_output: string | null; + /// Comma-separated AudioFormat encodings; applies only in Manual mode. + encoded_surround_output_enabled_formats: string | null; } export interface WriteResult { diff --git a/v2/screenshots/README.md b/v2/screenshots/README.md index d171291..74347f2 100644 --- a/v2/screenshots/README.md +++ b/v2/screenshots/README.md @@ -8,7 +8,7 @@ npm run screenshots # from v2/ That runs two steps: -1. **`screenshots/capture.mjs`** — boots the dev server with `VITE_DEMO=1` (fixture-backed `invoke()`, see [`../src/lib/demo-mock.ts`](../src/lib/demo-mock.ts) — no device needed), then drives a headless Chromium through all twelve screens at a fixed retina viewport in **both themes**: dark frames to `frames/`, light frames to `frames-light/` (the light run sets `prefers-color-scheme: light`, which the app's Auto theme follows). +1. **`screenshots/capture.mjs`** — boots the dev server with `VITE_DEMO=1` (fixture-backed `invoke()`, see [`../src/lib/demo-mock.ts`](../src/lib/demo-mock.ts) — no device needed), then drives a headless Chromium through all fourteen screens at a fixed retina viewport in **both themes**: dark frames to `frames/`, light frames to `frames-light/` (the light run sets `prefers-color-scheme: light`, which the app's Auto theme follows). 2. **`screenshots/build-gif.sh`** — stitches each frame set into a GIF with ffmpeg (two-pass palette): `frames/` → `gallery.gif`, `frames-light/` → `gallery-light.gif`. ## Requirements diff --git a/v2/screenshots/capture.mjs b/v2/screenshots/capture.mjs index ad353d0..b140154 100644 --- a/v2/screenshots/capture.mjs +++ b/v2/screenshots/capture.mjs @@ -63,50 +63,60 @@ async function captureScreens(page, shot) { await page.getByText("3840x2160", { exact: false }).first().waitFor(); await shot("health"); - // 4. Launcher. + // 4. Playback capabilities. + await page.locator("#tab-media").click(); + await page.getByText("Video decoding", { exact: false }).first().waitFor(); + await shot("playback"); + + // 5. Launcher. await page.locator("#tab-launcher").click(); await page.getByText("Projectivy Launcher", { exact: false }).first().waitFor(); await shot("launcher"); - // 5. App list. + // 6. App list. await page.locator("#tab-apps").click(); await page.getByText("App List", { exact: false }).first().waitFor(); await page.waitForTimeout(400); await shot("app-list"); - // 6. Optimize wizard — needs a click to load the plan. + // 7. Optimize wizard — needs a click to load the plan. await page.locator("#tab-optimize").click(); await page.getByRole("button", { name: "Optimize", exact: true }).click(); await page.getByText("Run", { exact: false }).first().waitFor().catch(() => {}); await page.waitForTimeout(600); await shot("optimize"); - // 7. Tweaks. + // 8. Tweaks. await page.locator("#tab-tweaks").click(); await page.getByText("HDMI", { exact: false }).first().waitFor(); await shot("tweaks"); - // 8. Remote. + // 9. Remote. await page.locator("#tab-remote").click(); await page.getByText("Live typing", { exact: false }).first().waitFor(); await shot("remote"); - // 9. Files. + // 10. Files. await page.locator("#tab-files").click(); await page.getByText("Download", { exact: true }).first().waitFor(); await shot("files"); - // 9. Install APK. + // 11. Install APK. await page.locator("#tab-sideload").click(); await page.getByText("Install APK", { exact: false }).first().waitFor(); await shot("install-apk"); - // 10. Snapshot (per-device). + // 12. Snapshot (per-device). await page.locator("#tab-snapshot").click(); await page.waitForTimeout(400); await shot("snapshot"); - // 11. Global snapshots page. + // 13. Shell runner. + await page.locator("#tab-shell").click(); + await page.getByText("Disabled packages", { exact: false }).first().waitFor(); + await shot("shell"); + + // 14. Global snapshots page. await page.goto(`${BASE}/snapshots`, { waitUntil: "networkidle" }); await page.waitForTimeout(500); await shot("snapshots"); diff --git a/v2/src-tauri/src/lib.rs b/v2/src-tauri/src/lib.rs index ff4d085..caa175d 100644 --- a/v2/src-tauri/src/lib.rs +++ b/v2/src-tauri/src/lib.rs @@ -15,7 +15,7 @@ use adb::SubprocessAdb; use commands::{backup, files, install, scan, sideload, update, AppState}; use shield_optimizer_core::adb::{AdbDriver, AdbError, AdbOutput, AdbResult}; use shield_optimizer_core::commands::{ - apps, devices, health, input, launcher, loader, optimize, reboot, recovery, screenshot, + apps, devices, health, input, launcher, loader, optimize, reboot, recovery, screenshot, shell, snapshot, tuning, }; use shield_optimizer_core::license::Entitlement; @@ -112,6 +112,8 @@ pub fn run() { devices::pair_device, devices::rename_device, health::health_report, + health::media_report, + health::resource_sample, health::app_list_for_device, health::report_all, install::adb_status, @@ -161,6 +163,7 @@ pub fn run() { snapshot::snapshot_dir_path, recovery::panic_recovery, reboot::reboot_device, + shell::run_shell, tuning::get_tweaks, tuning::write_setting, tuning::set_display_scaling, diff --git a/v2/src/lib/api.ts b/v2/src/lib/api.ts index b613d65..dfb0292 100644 --- a/v2/src/lib/api.ts +++ b/v2/src/lib/api.ts @@ -24,6 +24,7 @@ import type { InstallApkResult, InstallResult, LauncherStatus, + MediaCapabilities, OptimizeMode, OptimizePlan, OtherPackage, @@ -34,6 +35,7 @@ import type { RebootMode, RebootResult, RecoveryResult, + ResourceSample, RestartResult, Safety, ScanResult, @@ -41,6 +43,7 @@ import type { SendTextResult, SetLauncherResult, SettingNamespace, + ShellRunResult, SnapshotApplyPlan, SnapshotFile, TweaksState, @@ -187,6 +190,12 @@ export const api = { rebootDevice: (serial: string, mode: RebootMode) => invoke("reboot_device", { serial, mode }), + mediaReport: (serial: string, deviceType: DeviceType) => + invoke("media_report", { serial, deviceType }), + resourceSample: (serial: string) => invoke("resource_sample", { serial }), + runShell: (serial: string, command: string) => + invoke("run_shell", { serial, command }), + getTweaks: (serial: string) => invoke("get_tweaks", { serial }), writeSetting: ( serial: string, diff --git a/v2/src/lib/components/MediaTab.svelte b/v2/src/lib/components/MediaTab.svelte new file mode 100644 index 0000000..e781c52 --- /dev/null +++ b/v2/src/lib/components/MediaTab.svelte @@ -0,0 +1,326 @@ + + +
+
+

Playback

+ +
+

+ What this device can actually decode and output, read from the device itself — + its codec list, display modes, and audio passthrough settings. +

+ + {#if err} +

{err}

+ {:else if !caps} +

{loading ? "Reading playback capabilities…" : "No data."}

+ {:else} + {#if caps.verdicts.length} +

Verdict

+
    + {#each caps.verdicts as v (v.title)} +
  • +
    {v.title}
    +
    {v.detail}
    + {#if v.note} +
    + This device + {v.note} +
    + {/if} +
  • + {/each} +
+ {/if} + +

Video decoding

+ + + + + + {#each caps.video as v (v.mime)} + {@const support = formatSupport(v)} + + + + + + {/each} + +
FormatDecodingMIME
{v.label}{support.label}{v.mime}
+ +

HDR formats

+

+ What the current display chain accepts — this tracks the TV or receiver + that is connected right now, not a fixed property of the device. +

+

+ {#if caps.hdr_types.length} + {#each caps.hdr_types as h (h)}{h}{/each} + {:else} + SDR only + {/if} +

+ +

Display modes

+

+ Match Content Frame Rate: + + {caps.match_content_frame_rate + ? (matchContentLabel[caps.match_content_frame_rate] ?? caps.match_content_frame_rate) + : "Never (unset)"} + + — change it on the Tweaks tab. +

+ {#if caps.modes.length} + + + + + + {#each caps.modes as m (`${m.width}x${m.height}@${m.fps}`)} + + + + + + {/each} + +
ResolutionRefreshNotes
{m.width}×{m.height}{m.fps.toFixed(3)} Hz + {#if m.active}Active{/if} + {#if isFilmRate(m.fps)}24p film{/if} +
+ {:else} +

The device reported no display modes.

+ {/if} + +

Audio passthrough

+
+
+ Mode: {surroundLabel[caps.audio.mode] ?? caps.audio.mode} +
+ {#if caps.audio.enabled_formats.length} +
+ {#each caps.audio.enabled_formats as f (f)}{f}{/each} +
+ {/if} +
+ global.encoded_surround_output_enabled_formats = {caps.audio.raw_formats ?? "(unset)"} +
+
+ {/if} +
+ + diff --git a/v2/src/lib/components/ShellTab.svelte b/v2/src/lib/components/ShellTab.svelte new file mode 100644 index 0000000..aeb45f6 --- /dev/null +++ b/v2/src/lib/components/ShellTab.svelte @@ -0,0 +1,285 @@ + + +
+
+

Shell

+ +
+

+ Runs on the device via adb shell. Commands that plainly disable or + remove a package on the do-not-disable list are refused before anything is sent — + enough to catch an accidental paste, not a substitute for knowing what a command + does. +

+ + + +
+ {#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} +

+ 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..571a215 100644 --- a/v2/src/lib/components/TweaksTab.svelte +++ b/v2/src/lib/components/TweaksTab.svelte @@ -135,6 +135,61 @@ function matchContentLabel(v: string | null): string { return v === "0" ? "Never" : v === "1" ? "Seamless only" : v === "2" ? "Always" : "Unset (default)"; } + function surroundLabel(v: string | null): string { + return v === "0" + ? "Auto" + : v === "1" + ? "Never" + : v === "2" + ? "Always" + : v === "3" + ? "Manual" + : "Unset (Auto)"; + } + + /// `AudioFormat.ENCODING_*` values that can appear in a passthrough + /// allow-list, in the order a receiver owner thinks about them: lossy + /// first, then the lossless formats that are the reason to touch this. + const SURROUND_FORMATS: { code: string; label: string }[] = [ + { code: "5", label: "Dolby Digital" }, + { code: "6", label: "Dolby Digital Plus" }, + { code: "18", label: "Atmos over DD+" }, + { code: "14", label: "Dolby TrueHD" }, + { code: "19", label: "Dolby MAT" }, + { code: "7", label: "DTS" }, + { code: "8", label: "DTS-HD" }, + { code: "26", label: "DTS:X" }, + ]; + + function surroundFormatOn(raw: string | null, code: string): boolean { + if (!raw) return false; + return raw.split(",").map((c) => c.trim()).includes(code); + } + + /// Toggling a format rewrites the whole comma-separated list. Order is + /// normalised to SURROUND_FORMATS so the value stays stable regardless of + /// which checkbox the user clicked first. + async function toggleSurroundFormat(raw: string | null, code: string) { + const current = new Set( + (raw ?? "") + .split(",") + .map((c) => c.trim()) + .filter(Boolean), + ); + if (current.has(code)) current.delete(code); + else current.add(code); + const ordered = SURROUND_FORMATS.filter((f) => current.has(f.code)).map((f) => f.code); + // Codes the picker does not model must survive a toggle rather than being + // silently dropped from the device's list. + const unknown = [...current].filter((c) => !SURROUND_FORMATS.some((f) => f.code === c)); + await writeTweak( + "global", + "encoded_surround_output_enabled_formats", + [...ordered, ...unknown].join(","), + "encoded_surround_output_enabled_formats", + ); + } + function bgLimitLabel(v: string | null): string { if (!v) return "Standard"; return v === "0" ? "None" : `At most ${v}`; @@ -418,6 +473,66 @@ +

Audio Passthrough

+

+ Whether encoded soundtracks are sent to the receiver untouched or decoded to + PCM on the device first. Auto negotiates over HDMI/eARC and is + right for almost everyone; switch to Manual only when a + soundbar or receiver under-reports what it can decode and lossless tracks + (TrueHD, DTS-HD) are arriving downmixed. +

+
+
+
Current: {surroundLabel(tweaks.encoded_surround_output)}
+
+ global.encoded_surround_output = {tweaks.encoded_surround_output ?? "(unset)"} +
+
+
+ {#each [ + { v: "0", label: "Auto" }, + { v: "1", label: "Never" }, + { v: "2", label: "Always" }, + { v: "3", label: "Manual" }, + ] as opt (opt.v)} + + {/each} + +
+
+ {#if tweaks.encoded_surround_output === "3"} +
+

+ Formats allowed through in Manual mode. Anything unchecked is decoded on + the device. +

+
+ {#each SURROUND_FORMATS as f (f.code)} + + {/each} +
+
+ global.encoded_surround_output_enabled_formats = + {tweaks.encoded_surround_output_enabled_formats ?? "(empty)"} +
+
+ {/if} +

Background Process Limit

Caps how many apps stay alive in the background — frees RAM and can make the @@ -664,6 +779,14 @@ padding: 0.5rem 0; border-bottom: 1px solid var(--bg-button); } + .surround-formats { + background: var(--bg-inset); + border: 1px solid var(--border); + border-radius: 4px; + padding: 0.5rem 0.7rem; + margin: 0.4rem 0 0.8rem; + line-height: 1.5; + } .current-scaling { background: var(--bg-inset); border: 1px solid var(--border); diff --git a/v2/src/lib/demo-mock.ts b/v2/src/lib/demo-mock.ts index 9a65f3c..bf87782 100644 --- a/v2/src/lib/demo-mock.ts +++ b/v2/src/lib/demo-mock.ts @@ -18,6 +18,7 @@ import type { Device, HealthReport, LauncherStatus, + MediaCapabilities, OptimizePlan, OptimizePlanItem, SnapshotFile, @@ -75,6 +76,68 @@ const health: HealthReport = { ], }; +const media: MediaCapabilities = { + video: [ + { label: "H.264 / AVC", mime: "video/avc", hardware: true, software: true }, + { label: "HEVC / H.265", mime: "video/hevc", hardware: true, software: true }, + { label: "VP9", mime: "video/x-vnd.on2.vp9", hardware: true, software: true }, + { label: "AV1", mime: "video/av01", hardware: false, software: true }, + { label: "Dolby Vision", mime: "video/dolby-vision", hardware: true, software: false }, + { label: "MPEG-2", mime: "video/mpeg2", hardware: true, software: false }, + ], + hdr_types: ["Dolby Vision", "HDR10", "HLG"], + modes: [ + { width: 3840, height: 2160, fps: 59.94, active: true }, + { width: 3840, height: 2160, fps: 29.97, active: false }, + { width: 3840, height: 2160, fps: 23.976, active: false }, + { width: 1920, height: 1080, fps: 60.0, active: false }, + { width: 1920, height: 1080, fps: 23.976, active: false }, + ], + audio: { + mode: "manual", + enabled_formats: [ + "Dolby Digital (AC-3)", + "Dolby Digital Plus (E-AC-3)", + "Dolby Atmos over DD+ (E-AC-3 JOC)", + "Dolby TrueHD", + "DTS", + "DTS-HD", + ], + raw_formats: "5,6,18,14,7,8", + }, + match_content_frame_rate: "2", + verdicts: [ + { + level: "good", + title: "24p handled (23.976 Hz mode available)", + detail: + "Match Content Frame Rate is set to Always, so film switches to its native cadence instead of being pulled to the panel rate.", + note: null, + }, + { + level: "info", + title: "Surround passthrough is on a manual allow-list", + detail: + "Only these pass through: Dolby Digital (AC-3), Dolby Digital Plus (E-AC-3), Dolby Atmos over DD+ (E-AC-3 JOC), Dolby TrueHD, DTS, DTS-HD. Anything else is decoded on the device.", + note: null, + }, + { + level: "good", + title: "Dolby Vision available", + detail: + "The device advertises a Dolby Vision decoder and the display chain accepts Dolby Vision.", + note: "Profiles 5 and 8 play natively. Profile 7 — the dual-layer format UHD Blu-ray remuxes use — plays the base layer only: the enhancement layer is discarded, so FEL titles render from a base grade that was never meant to be shown alone. Converting Profile 7 to 8.1 before playback avoids that.", + }, + { + level: "warn", + title: "AV1 in software only", + detail: + "No hardware AV1 decoder is advertised. AV1 falls back to CPU decoding, which stutters above 1080p on TV-class silicon.", + note: "The Shield's Tegra X1/X1+ has no AV1 decode block, and no firmware update can add one. AV1 streams fall back to software decoding — fine at 1080p, unreliable above it.", + }, + ], +}; + const launchers: LauncherStatus[] = [ { entry: { name: "Android TV Launcher (Stock)", package: "com.google.android.tvlauncher" }, @@ -100,6 +163,8 @@ const tweaks: TweaksState = { transition_animation_scale: "0.5", animator_duration_scale: "0.5", background_process_limit: "2", + encoded_surround_output: "3", + encoded_surround_output_enabled_formats: "5,6,18,14,7,8", }; const snapshots: SnapshotFile[] = [ @@ -201,6 +266,33 @@ function demoFiles(path: string) { ]; } +function demoShellOutput(command: string): string { + if (command.includes("packages -d")) { + return [...DISABLED].map((p) => `package:${p}`).join("\n"); + } + if (command.includes("packages -3")) { + return [ + "package:com.plexapp.android", + "package:com.spocky.projengmenu", + "package:com.liskovsoft.smarttubetv.beta", + "package:org.jellyfin.androidtv", + ].join("\n"); + } + if (command.includes("getprop")) { + return [ + "[ro.product.brand]: [NVIDIA]", + "[ro.product.device]: [mdarcy]", + "[ro.product.manufacturer]: [NVIDIA]", + "[ro.product.model]: [SHIELD Android TV]", + "[ro.product.name]: [darcy]", + ].join("\n"); + } + if (command.includes("uptime")) { + return " 21:14:07 up 6 days, 3:22, 0 users, load average: 0.84, 0.61, 0.55"; + } + return "ok"; +} + // Map of command name → handler. Unlisted commands fall through to a benign // success so a stray click during capture never throws. function handle(cmd: string, args: Record): unknown { @@ -258,6 +350,38 @@ function handle(cmd: string, args: Record): unknown { return { package: "com.spocky.projengmenu", activity: "com.spocky.projengmenu/.MainActivity" }; case "channel_provider_disabled": return false; + case "media_report": + return media; + case "resource_sample": + return { + cpu_percent: 18.4, + rx_bytes_per_s: 11_534_336, + tx_bytes_per_s: 204_800, + interval_ms: 1000, + }; + case "run_shell": { + const command = String(args.command ?? ""); + // Mirror the real safety gate so the demo/screenshot layer cannot show + // a refusal-free shell that the shipping app would never allow. + if (/\b(disable|disable-user|uninstall|hide|suspend)\b/.test(command) && + /\b(android|com\.android\.systemui|com\.android\.shell|com\.google\.android\.gms)\b/.test(command)) { + return { + stdout: "", + stderr: "", + exit_code: null, + blocked: true, + blocked_reason: + "Refused: this command would disable or remove com.android.systemui, which is on the do-not-disable list. System UI — the launcher's host process. Disabling makes the device unusable.", + }; + } + return { + stdout: demoShellOutput(command), + stderr: "", + exit_code: 0, + blocked: false, + blocked_reason: null, + }; + } case "get_tweaks": return tweaks; case "list_dir": diff --git a/v2/src/lib/prefs.ts b/v2/src/lib/prefs.ts index 26c66dc..1936637 100644 --- a/v2/src/lib/prefs.ts +++ b/v2/src/lib/prefs.ts @@ -28,3 +28,36 @@ export function setRemoteForceShell(enabled: boolean): void { localStorage.setItem(REMOTE_COMPAT_KEY, String(enabled)); } } + +const SHELL_BOOKMARKS_KEY = "shieldopt.shellBookmarks"; + +export interface ShellBookmark { + label: string; + command: string; +} + +/// Bookmarks are local-only and shared across devices on purpose: the useful +/// ones ("list disabled packages", "dump the launcher") are about Android, not +/// about one TV, so scoping them per-serial would just make the user retype +/// them for every device they connect. +export function getShellBookmarks(): ShellBookmark[] { + if (typeof localStorage === "undefined") return []; + try { + const raw = JSON.parse(localStorage.getItem(SHELL_BOOKMARKS_KEY) ?? "[]"); + if (!Array.isArray(raw)) return []; + // Hand-edited or older localStorage payloads reach this unchecked, so each + // row is validated rather than trusted into the UI. + return raw.filter( + (b): b is ShellBookmark => + !!b && typeof b.label === "string" && typeof b.command === "string", + ); + } catch { + return []; + } +} + +export function setShellBookmarks(bookmarks: ShellBookmark[]): void { + if (typeof localStorage !== "undefined") { + localStorage.setItem(SHELL_BOOKMARKS_KEY, JSON.stringify(bookmarks)); + } +} diff --git a/v2/src/lib/types.ts b/v2/src/lib/types.ts index 4cf57c9..f2f2be5 100644 --- a/v2/src/lib/types.ts +++ b/v2/src/lib/types.ts @@ -89,6 +89,66 @@ export interface DisplayMode { hdr_types: string[]; } +/// Counterparts of crates/core/src/engine/media.rs. +export type SurroundMode = "auto" | "never" | "always" | "manual" | "unset"; +export type VerdictLevel = "good" | "warn" | "info"; + +export interface VideoFormat { + label: string; + mime: string; + /// A vendor (silicon-backed) decoder advertises it. + hardware: boolean; + /// A platform software decoder advertises it. + software: boolean; +} + +export interface DisplayModeEntry { + width: number; + height: number; + fps: number; + active: boolean; +} + +export interface AudioPassthrough { + mode: SurroundMode; + enabled_formats: string[]; + raw_formats: string | null; +} + +export interface Verdict { + level: VerdictLevel; + title: string; + detail: string; + /// Curated per-device knowledge, rendered apart from the derived detail. + note: string | null; +} + +export interface MediaCapabilities { + video: VideoFormat[]; + hdr_types: string[]; + modes: DisplayModeEntry[]; + audio: AudioPassthrough; + match_content_frame_rate: string | null; + verdicts: Verdict[]; +} + +/// CPU + network rates over one device-side sampling window. +export interface ResourceSample { + cpu_percent: number | null; + rx_bytes_per_s: number | null; + tx_bytes_per_s: number | null; + interval_ms: number; +} + +export interface ShellRunResult { + stdout: string; + stderr: string; + exit_code: number | null; + /// The safety gate refused it; nothing was sent to the device. + blocked: boolean; + blocked_reason: string | null; +} + export interface MemoryEntry { package: string; mb: number; @@ -314,6 +374,10 @@ export interface TweaksState { animator_duration_scale: string | null; /// Background process limit: null = Standard, "0" = none, "1"–"4" = at most N. 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 type SettingNamespace = "global" | "secure" | "system"; diff --git a/v2/src/routes/devices/[serial]/+page.svelte b/v2/src/routes/devices/[serial]/+page.svelte index 8f813e2..6e47df1 100644 --- a/v2/src/routes/devices/[serial]/+page.svelte +++ b/v2/src/routes/devices/[serial]/+page.svelte @@ -19,6 +19,7 @@ RebootMode, OtherPackage, ScreenshotResult, + ResourceSample, Safety, } from "$lib/types"; import { deviceTypeLabel } from "$lib/types"; @@ -31,10 +32,12 @@ import SideloadTab from "$lib/components/SideloadTab.svelte"; import RemoteTab from "$lib/components/RemoteTab.svelte"; import OptimizeTab from "$lib/components/OptimizeTab.svelte"; + import MediaTab from "$lib/components/MediaTab.svelte"; + import ShellTab from "$lib/components/ShellTab.svelte"; let serial = $derived(decodeURIComponent($page.params.serial ?? "")); - type Tab = "overview" | "health" | "launcher" | "apps" | "optimize" | "tweaks" | "remote" | "files" | "snapshot" | "sideload"; + type Tab = "overview" | "health" | "media" | "launcher" | "apps" | "optimize" | "tweaks" | "remote" | "files" | "snapshot" | "sideload" | "shell"; let activeTab = $state("overview"); let device = $state(null); @@ -181,9 +184,37 @@ } } + // CPU + network rates, fetched separately from the health report: the + // sample needs a one-second device-side window, and making every refresh + // wait for it would make the whole tab feel slow. + let resource = $state(null); + let resourceLoading = $state(false); + + async function loadResourceSample() { + resourceLoading = true; + try { + resource = await api.resourceSample(serial); + } catch { + // A failed sample must not blank the health report it sits next to. + resource = null; + } finally { + resourceLoading = false; + } + } + + /// Bytes/s → the largest unit that keeps the number readable. + function formatRate(bytesPerSecond: number | null): string { + if (bytesPerSecond == null) return "—"; + if (bytesPerSecond < 1024) return `${bytesPerSecond} B/s`; + if (bytesPerSecond < 1024 * 1024) return `${(bytesPerSecond / 1024).toFixed(1)} KB/s`; + return `${(bytesPerSecond / (1024 * 1024)).toFixed(2)} MB/s`; + } + async function loadHealth() { reportLoading = true; reportErr = null; + // Kick the sample off in parallel and let it land on its own. + void loadResourceSample(); try { report = await api.healthReport(serial); reportLastRefreshed = new Date(); @@ -1171,6 +1202,7 @@ {#each [ { id: "overview", label: "Overview" }, { id: "health", label: "Health" }, + { id: "media", label: "Playback" }, { id: "launcher", label: "Launcher" }, { id: "apps", label: "App List" }, { id: "optimize", label: "Optimize" }, @@ -1179,6 +1211,7 @@ { id: "files", label: "Files" }, { id: "sideload", label: "Install APK" }, { id: "snapshot", label: "Snapshot" }, + { id: "shell", label: "Shell" }, ] as t (t.id)} + +

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}