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
+186
View File
@@ -8,6 +8,7 @@ use std::sync::{Arc, Mutex};
use tauri::{Manager, State};
use super::{DatabaseWrapper, SmartCacheWrapper};
use crate::download::network::{NetworkState, NetworkStateHandle, NetworkType};
use crate::download::{DownloadInfo, DownloadManager};
use crate::storage::db_service::{DatabaseService, Query, QueryParam};
@@ -21,6 +22,80 @@ pub use smart_cache::*;
/// Wrapper for DownloadManager to be used as Tauri state
pub struct DownloadManagerWrapper(pub Mutex<DownloadManager>);
/// Wrapper for the current network transport, used by the WiFi-only gate.
///
/// TRACES: UR-053 | DR-074
pub struct NetworkStateWrapper(pub NetworkStateHandle);
/// Report the device's current network transport (Android → Rust).
///
/// The frontend calls this on startup and whenever the native network callback
/// fires. Updating to an acceptable network re-pumps the download queue, so a
/// queue parked on "waiting for WiFi" drains itself without user action.
///
/// TRACES: UR-053 | DR-074
#[tauri::command]
#[specta::specta]
pub async fn set_network_state(
app: tauri::AppHandle,
network: NetworkStateWrapperArg,
db: State<'_, DatabaseWrapper>,
download_manager: State<'_, DownloadManagerWrapper>,
) -> Result<(), String> {
let new_state = NetworkState {
network_type: network.network_type,
unmetered: network.unmetered,
};
let handle = app.state::<NetworkStateWrapper>().0.clone();
let previous = handle.get().await;
handle.set(new_state).await;
if previous != new_state {
info!(
"[network] Transport changed: {:?} (unmetered={}) -> {:?} (unmetered={})",
previous.network_type, previous.unmetered, new_state.network_type, new_state.unmetered
);
}
// If the new network unblocks the gate, drain whatever was waiting.
if downloads_allowed_on_current_network(&app).await {
let db_service = {
let database = db.0.lock().map_err(|e| e.to_string())?;
Arc::new(database.service())
};
let active = {
let manager = download_manager.0.lock().map_err(|e| e.to_string())?;
manager.get_active_downloads()
};
pump_download_queue(app.clone(), db_service, active).await;
}
Ok(())
}
/// Argument struct for [`set_network_state`].
///
/// TRACES: UR-053 | DR-074
#[derive(specta::Type, Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct NetworkStateWrapperArg {
pub network_type: NetworkType,
pub unmetered: bool,
}
/// Whether downloads are currently permitted by the WiFi-only gate.
///
/// The downloads UI uses this to render "Waiting for WiFi" on pending rows
/// rather than leaving them looking silently stuck.
///
/// TRACES: UR-053 | DR-074
#[tauri::command]
#[specta::specta]
pub async fn get_downloads_allowed(app: tauri::AppHandle) -> Result<bool, String> {
Ok(downloads_allowed_on_current_network(&app).await)
}
/// Download statistics computed server-side
#[allow(dead_code)]
#[derive(specta::Type, Debug, Clone, serde::Serialize, serde::Deserialize)]
@@ -1213,6 +1288,37 @@ pub async fn enqueue_video_downloads(
Ok(())
}
/// Whether the current network permits downloads, given the user's WiFi-only
/// preference.
///
/// Reads `wifi_only` from the SmartCache config (the single home of the
/// setting) and checks it against the transport reported by the platform. On
/// desktop the transport defaults to unmetered ethernet, so this is always
/// true there.
///
/// TRACES: UR-053 | DR-074
pub(crate) async fn downloads_allowed_on_current_network(app: &tauri::AppHandle) -> bool {
let wifi_only = {
let smart_cache = app.state::<SmartCacheWrapper>();
let cache = match smart_cache.0.lock() {
Ok(c) => c,
Err(e) => {
error!("[pump] Failed to lock smart cache: {}", e);
// Fail open: a lock problem must not silently wedge downloads.
return true;
}
};
cache.get_config().map(|c| c.wifi_only).unwrap_or(false)
};
if !wifi_only {
return true;
}
let network = app.state::<NetworkStateWrapper>();
network.0.allows_download(true).await
}
/// Start as many pending downloads as there are free concurrency slots.
///
/// Picks the highest-priority `pending` rows that have a persisted `stream_url`
@@ -1227,6 +1333,16 @@ pub(crate) async fn pump_download_queue(
use crate::download::events::DownloadEvent;
use tauri::Emitter;
// WiFi-only gate (UR-053): when the user has restricted downloads to
// unmetered networks and we're on cellular (or can't tell), leave every
// pending row exactly as it is. They stay 'pending' and the Android
// network callback re-pumps us as soon as an acceptable network appears.
if !downloads_allowed_on_current_network(&app).await {
info!("[pump] Downloads paused: waiting for an unmetered network (WiFi-only enabled)");
let _ = app.emit("download-event", DownloadEvent::WaitingForNetwork);
return;
}
let max_concurrent = {
let manager = app.state::<DownloadManagerWrapper>();
let manager = match manager.0.lock() {
@@ -1799,6 +1915,76 @@ pub async fn delete_album_downloads(
Ok(deleted_count as i64)
}
/// Remove every completed download at or under a container item.
///
/// Works at any level of the Downloaded browse: a leaf (removes just that
/// download), an album/season/series (removes all downloaded descendants linked
/// via album_id/season_id/series_id/parent_id). Deletes the DB rows and the
/// on-disk files. Returns the number of downloads removed. Idempotent.
///
/// TRACES: UR-055 | DR-083
#[tauri::command]
#[specta::specta]
pub async fn delete_downloads_under(
db: State<'_, DatabaseWrapper>,
item_id: String,
user_id: String,
) -> Result<i64, String> {
let db_service = {
let database = db.0.lock().map_err(|e| e.to_string())?;
Arc::new(database.service())
};
// The item itself, or any child linked to it by container id.
const SCOPE: &str = "d.user_id = ? AND d.status = 'completed'
AND (
d.item_id = ?
OR d.item_id IN (
SELECT c.id FROM items c
WHERE c.album_id = ? OR c.season_id = ? OR c.series_id = ? OR c.parent_id = ?
)
)";
let file_query = Query::with_params(
&format!("SELECT d.file_path FROM downloads d WHERE {SCOPE}"),
vec![
QueryParam::String(user_id.clone()),
QueryParam::String(item_id.clone()),
QueryParam::String(item_id.clone()),
QueryParam::String(item_id.clone()),
QueryParam::String(item_id.clone()),
QueryParam::String(item_id.clone()),
],
);
let file_paths: Vec<String> = db_service
.query_many(file_query, |row| row.get(0))
.await
.map_err(|e| e.to_string())?;
let delete_query = Query::with_params(
&format!("DELETE FROM downloads WHERE id IN (SELECT d.id FROM downloads d WHERE {SCOPE})"),
vec![
QueryParam::String(user_id),
QueryParam::String(item_id.clone()),
QueryParam::String(item_id.clone()),
QueryParam::String(item_id.clone()),
QueryParam::String(item_id.clone()),
QueryParam::String(item_id),
],
);
let deleted_count = db_service
.execute(delete_query)
.await
.map_err(|e| e.to_string())?;
for path in file_paths {
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_file(format!("{}.part", path));
}
Ok(deleted_count as i64)
}
/// Download manager statistics
#[derive(specta::Type, Debug, Clone, serde::Serialize)]
pub struct DownloadManagerStats {
+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\""
);
}
}
+20
View File
@@ -52,6 +52,7 @@ use commands::{
delete_album_downloads,
delete_all_downloads,
delete_download,
delete_downloads_under,
// Device commands
device_get_id,
device_set_id,
@@ -71,6 +72,7 @@ use commands::{
get_download_manager_stats,
get_download_storage_stats,
get_downloads,
get_downloads_allowed,
get_smart_cache_config,
get_smart_cache_stats,
image_get_url,
@@ -179,6 +181,9 @@ use commands::{
repository_get_audio_only_stream_url_for_video,
repository_get_audio_stream_url,
repository_get_channels,
repository_get_download_disk_usage,
repository_get_downloaded_items,
repository_get_downloaded_libraries,
repository_get_genres,
repository_get_image_url,
repository_get_item,
@@ -212,6 +217,7 @@ use commands::{
// Session polling commands
sessions_set_polling_hint,
set_max_concurrent_downloads,
set_network_state,
set_show_server_catalog,
start_download,
// Storage commands
@@ -770,6 +776,7 @@ fn specta_builder() -> Builder<tauri::Wry> {
delete_download,
delete_all_downloads,
delete_album_downloads,
delete_downloads_under,
clear_stale_downloads,
get_download_storage_stats,
mark_download_completed,
@@ -786,6 +793,9 @@ fn specta_builder() -> Builder<tauri::Wry> {
get_smart_cache_stats,
update_smart_cache_config,
get_smart_cache_config,
// WiFi-only download gate (UR-053)
set_network_state,
get_downloads_allowed,
get_album_recommendations,
get_album_affinity_status,
// Pinning commands
@@ -835,6 +845,9 @@ fn specta_builder() -> Builder<tauri::Wry> {
repository_get_libraries,
repository_get_items,
repository_get_item,
repository_get_downloaded_libraries,
repository_get_downloaded_items,
repository_get_download_disk_usage,
repository_jray_actors_at,
repository_get_latest_items,
repository_get_resume_items,
@@ -1196,6 +1209,13 @@ pub fn run() {
let download_manager_wrapper = DownloadManagerWrapper(Mutex::new(download_manager));
app.manage(download_manager_wrapper);
// Current network transport, for the WiFi-only download gate (UR-053).
// Defaults to unmetered ethernet so desktop is never gated; Android
// overwrites it via set_network_state as soon as the UI starts.
app.manage(commands::download::NetworkStateWrapper(
download::network::NetworkStateHandle::new(),
));
// Initialize connectivity monitor
info!("[INIT] Initializing connectivity monitor...");
let http_config = HttpConfig::default();