feat(downloads): WiFi-only network-type-aware download gating

Add a metered/cellular network detector so downloads honour a "WiFi
only" preference. Android reports network type via NetworkTypeMonitor;
Rust exposes it through download/network.rs and holds the queue pump when
on a metered connection, emitting a queue-wide waitingForNetwork event.
The frontend surfaces this via the networkType service and a
waitingForNetwork store flag.

TRACES: UR-053 | DR-074
This commit is contained in:
2026-07-23 20:02:07 +02:00
parent 8f8433eebe
commit e083b53ee8
13 changed files with 944 additions and 3 deletions
+24 -2
View File
@@ -64,11 +64,20 @@ impl SmartCache {
}
}
/// Check if should pre-cache queue items
/// Check if should pre-cache queue items.
///
/// Note this deliberately does NOT consult `wifi_only`. It used to return
/// `queue_precache_enabled && !wifi_only`, which disabled precaching
/// outright whenever the user enabled WiFi-only — regardless of the network
/// actually in use. The network check now lives in the download queue pump
/// (`downloads_allowed_on_current_network`), which is the single gate for
/// all download traffic, so this only answers "is precaching enabled?".
///
/// TRACES: UR-053 | DR-074
pub fn should_precache_queue(&self) -> bool {
self.config
.lock()
.map(|cfg| cfg.queue_precache_enabled && !cfg.wifi_only)
.map(|cfg| cfg.queue_precache_enabled)
.unwrap_or(false)
}
@@ -282,6 +291,19 @@ mod tests {
assert!(cache.should_precache_queue());
}
#[test]
fn test_wifi_only_does_not_disable_precaching() {
// wifi_only must not short-circuit precaching: the network gate lives in
// the download pump, which checks the *actual* transport. Enabling
// WiFi-only while on WiFi should still precache.
let mut config = CacheConfig::default();
config.queue_precache_enabled = true;
config.wifi_only = true;
let cache = SmartCache::new(config);
assert!(cache.should_precache_queue());
}
#[tokio::test]
async fn test_storage_limit_check() {
use crate::storage::db_service::RusqliteService;
+5
View File
@@ -41,6 +41,11 @@ pub enum DownloadEvent {
/// Download cancelled
#[serde(rename_all = "camelCase")]
Cancelled { download_id: i64, item_id: String },
/// The queue is holding: WiFi-only is enabled and the current network is
/// metered/cellular. Pending rows stay pending and resume on network change.
///
/// TRACES: UR-053 | DR-074
WaitingForNetwork,
}
#[cfg(test)]
+1
View File
@@ -8,6 +8,7 @@
pub mod cache;
pub mod events;
pub mod network;
pub mod worker;
use crate::utils::lock::MutexSafe;
+196
View File
@@ -0,0 +1,196 @@
//! Network transport classification for the WiFi-only download gate.
//!
//! This answers "what kind of connection are we on?", which is orthogonal to
//! the `ConnectivityMonitor`'s "is the server reachable?". The download queue
//! pump consults this before starting pending rows when the user has enabled
//! WiFi-only downloads.
//!
//! On Android the real transport is read from `NetworkCapabilities` in
//! `NetworkTypeMonitor.kt` and pushed in from the frontend. On desktop there is
//! no metered-connection concept worth enforcing, so we report `Ethernet`,
//! which is always acceptable — gating desktop downloads would be a regression.
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use tokio::sync::RwLock;
/// Kind of network transport currently active.
///
/// Mirrors the string constants in `NetworkTypeMonitor.kt`; the two must stay
/// in sync (the serde rename below is what the frontend sends).
///
/// TRACES: UR-053 | DR-074
#[derive(specta::Type, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum NetworkType {
/// No active network.
None,
/// WiFi (may still be metered — check `unmetered`).
Wifi,
/// Wired ethernet, typical on Android TV and desktop.
Ethernet,
/// Mobile data — never acceptable when wifi-only is enabled.
Cellular,
/// Some other transport (VPN over unknown carrier, Bluetooth tethering, …).
Other,
/// Could not determine the transport.
Unknown,
}
/// Current network transport plus whether it is metered.
///
/// TRACES: UR-053 | DR-074
#[derive(specta::Type, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct NetworkState {
pub network_type: NetworkType,
/// Whether the active network is unmetered (Android `NET_CAPABILITY_NOT_METERED`).
pub unmetered: bool,
}
impl Default for NetworkState {
fn default() -> Self {
// Desktop default: wired and unmetered, so the gate never blocks there.
// Android overwrites this as soon as the frontend reports the real state.
Self {
network_type: NetworkType::Ethernet,
unmetered: true,
}
}
}
impl NetworkState {
/// Whether downloads may run right now given the wifi-only preference.
///
/// Ethernet counts as acceptable — it is unmetered in practice and is what
/// Android TV devices use. Cellular never does. `None`/`Unknown` fail
/// closed: if we cannot tell what we are on, we do not spend the user's
/// mobile data to find out.
///
/// TRACES: UR-053 | DR-074
pub fn allows_download(&self, wifi_only: bool) -> bool {
if !wifi_only {
return true;
}
match self.network_type {
NetworkType::Cellular | NetworkType::None | NetworkType::Unknown => false,
// Require unmetered so metered WiFi hotspots (backed by the very
// cellular data this setting protects) are excluded too.
NetworkType::Wifi | NetworkType::Ethernet | NetworkType::Other => self.unmetered,
}
}
}
/// Shared, mutable view of the current network transport.
///
/// Cheap to clone; the frontend updates it via `set_network_state` whenever
/// Android reports a network change.
#[derive(Clone, Default)]
pub struct NetworkStateHandle {
state: Arc<RwLock<NetworkState>>,
}
impl NetworkStateHandle {
pub fn new() -> Self {
Self {
state: Arc::new(RwLock::new(NetworkState::default())),
}
}
pub async fn get(&self) -> NetworkState {
*self.state.read().await
}
pub async fn set(&self, new_state: NetworkState) {
*self.state.write().await = new_state;
}
/// Whether downloads may run right now given the wifi-only preference.
pub async fn allows_download(&self, wifi_only: bool) -> bool {
self.state.read().await.allows_download(wifi_only)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn state(network_type: NetworkType, unmetered: bool) -> NetworkState {
NetworkState {
network_type,
unmetered,
}
}
#[test]
fn wifi_only_off_allows_every_transport() {
for t in [
NetworkType::None,
NetworkType::Wifi,
NetworkType::Ethernet,
NetworkType::Cellular,
NetworkType::Other,
NetworkType::Unknown,
] {
assert!(
state(t, false).allows_download(false),
"{t:?} should be allowed when wifi_only is off"
);
}
}
#[test]
fn cellular_is_blocked_when_wifi_only() {
// Even if somehow flagged unmetered, cellular is never acceptable.
assert!(!state(NetworkType::Cellular, true).allows_download(true));
assert!(!state(NetworkType::Cellular, false).allows_download(true));
}
#[test]
fn unmetered_wifi_and_ethernet_are_allowed() {
assert!(state(NetworkType::Wifi, true).allows_download(true));
assert!(state(NetworkType::Ethernet, true).allows_download(true));
}
#[test]
fn metered_wifi_is_blocked() {
// A phone hotspot reports as WiFi but is metered — blocking it is the
// whole point of checking NOT_METERED rather than the transport alone.
assert!(!state(NetworkType::Wifi, false).allows_download(true));
}
#[test]
fn unknown_and_none_fail_closed() {
assert!(!state(NetworkType::Unknown, true).allows_download(true));
assert!(!state(NetworkType::None, true).allows_download(true));
}
#[test]
fn desktop_default_is_never_gated() {
assert!(NetworkState::default().allows_download(true));
}
#[tokio::test]
async fn handle_roundtrips_state() {
let handle = NetworkStateHandle::new();
assert!(handle.allows_download(true).await);
handle.set(state(NetworkType::Cellular, false)).await;
assert!(!handle.allows_download(true).await);
assert!(handle.allows_download(false).await);
assert_eq!(handle.get().await.network_type, NetworkType::Cellular);
}
#[test]
fn network_type_serializes_lowercase() {
// Must match the string constants in NetworkTypeMonitor.kt.
assert_eq!(
serde_json::to_string(&NetworkType::Wifi).unwrap(),
"\"wifi\""
);
assert_eq!(
serde_json::to_string(&NetworkType::Cellular).unwrap(),
"\"cellular\""
);
}
}