From e083b53ee88b1de7e48b32f691578636323c7923 Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Thu, 23 Jul 2026 20:02:07 +0200 Subject: [PATCH] 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 --- .../android/src/main/AndroidManifest.xml | 2 + .../com/dtourolle/jellytau/MainActivity.kt | 34 +++ .../dtourolle/jellytau/NetworkTypeMonitor.kt | 152 ++++++++++++++ src-tauri/src/commands/download/mod.rs | 186 +++++++++++++++++ src-tauri/src/download/cache.rs | 26 ++- src-tauri/src/download/events.rs | 5 + src-tauri/src/download/mod.rs | 1 + src-tauri/src/download/network.rs | 196 ++++++++++++++++++ src-tauri/src/lib.rs | 20 ++ src/lib/services/networkType.test.ts | 156 ++++++++++++++ src/lib/services/networkType.ts | 109 ++++++++++ src/lib/stores/downloads.test.ts | 29 +++ src/lib/stores/downloads.ts | 31 ++- 13 files changed, 944 insertions(+), 3 deletions(-) create mode 100644 src-tauri/android/src/main/java/com/dtourolle/jellytau/NetworkTypeMonitor.kt create mode 100644 src-tauri/src/download/network.rs create mode 100644 src/lib/services/networkType.test.ts create mode 100644 src/lib/services/networkType.ts diff --git a/src-tauri/android/src/main/AndroidManifest.xml b/src-tauri/android/src/main/AndroidManifest.xml index bfb25b50..1b16e1cf 100644 --- a/src-tauri/android/src/main/AndroidManifest.xml +++ b/src-tauri/android/src/main/AndroidManifest.xml @@ -14,6 +14,8 @@ --> + + diff --git a/src-tauri/android/src/main/java/com/dtourolle/jellytau/MainActivity.kt b/src-tauri/android/src/main/java/com/dtourolle/jellytau/MainActivity.kt index 5c566156..469e7fbb 100644 --- a/src-tauri/android/src/main/java/com/dtourolle/jellytau/MainActivity.kt +++ b/src-tauri/android/src/main/java/com/dtourolle/jellytau/MainActivity.kt @@ -125,6 +125,11 @@ class MainActivity : TauriActivity() { } } + override fun onDestroy() { + NetworkTypeMonitor.stopWatching(this) + super.onDestroy() + } + override fun onPictureInPictureModeChanged( isInPictureInPictureMode: Boolean, newConfig: android.content.res.Configuration @@ -214,6 +219,35 @@ class MainActivity : TauriActivity() { }, "AndroidBackgroundAudio") android.util.Log.d("MainActivity", "JavaScript interface 'AndroidBackgroundAudio' added") + // Network transport reporting for the WiFi-only download gate (UR-053). + // The frontend polls these on demand and re-pumps the download queue when + // the 'jellytau-network-changed' event fires. + webView.addJavascriptInterface(object : Any() { + /** Active transport: wifi | ethernet | cellular | other | none | unknown. */ + @JavascriptInterface + fun currentType(): String = NetworkTypeMonitor.currentType(this@MainActivity) + + /** Whether the active network is unmetered. */ + @JavascriptInterface + fun isUnmetered(): Boolean = NetworkTypeMonitor.isUnmetered(this@MainActivity) + + /** Whether downloads may run given the wifi-only preference. */ + @JavascriptInterface + fun isAcceptable(wifiOnly: Boolean): Boolean = + NetworkTypeMonitor.isAcceptable(this@MainActivity, wifiOnly) + + /** Whether native network detection is available at all (false on non-Android). */ + @JavascriptInterface + fun isSupported(): Boolean = true + }, "AndroidNetworkType") + android.util.Log.d("MainActivity", "JavaScript interface 'AndroidNetworkType' added") + + // Push network changes into the WebView so a queue blocked on "waiting for + // WiFi" resumes the moment an acceptable network appears. + NetworkTypeMonitor.startWatching(this) { + dispatchWebEvent("jellytau-network-changed") + } + // Set WebChromeClient to handle video playback and audio focus webView.webChromeClient = object : WebChromeClient() { override fun onShowCustomView(view: View?, callback: CustomViewCallback?) { diff --git a/src-tauri/android/src/main/java/com/dtourolle/jellytau/NetworkTypeMonitor.kt b/src-tauri/android/src/main/java/com/dtourolle/jellytau/NetworkTypeMonitor.kt new file mode 100644 index 00000000..b62f569b --- /dev/null +++ b/src-tauri/android/src/main/java/com/dtourolle/jellytau/NetworkTypeMonitor.kt @@ -0,0 +1,152 @@ +package com.dtourolle.jellytau + +import android.content.Context +import android.net.ConnectivityManager +import android.net.Network +import android.net.NetworkCapabilities +import android.net.NetworkRequest + +/** + * Reports the *kind* of network the device is on, so downloads can be gated on + * "unmetered only" (the WiFi-only setting). + * + * This is deliberately separate from the Rust-side ConnectivityMonitor, which + * answers a different question: whether the Jellyfin *server* is reachable, + * derived from real request outcomes. Reachability and transport type are + * orthogonal — you can be on WiFi with a dead server, or on cellular with a + * perfectly reachable one. + * + * Requires ACCESS_NETWORK_STATE; without it getNetworkCapabilities returns null + * and we report UNKNOWN (which the gate treats as "not acceptable" when + * wifi-only is on, failing closed rather than burning mobile data). + * + * TRACES: UR-053 | DR-074 + */ +object NetworkTypeMonitor { + private const val TAG = "NetworkTypeMonitor" + + /** Transport classification, mirrored by the Rust `NetworkType` enum. */ + const val TYPE_NONE = "none" + const val TYPE_WIFI = "wifi" + const val TYPE_ETHERNET = "ethernet" + const val TYPE_CELLULAR = "cellular" + const val TYPE_OTHER = "other" + const val TYPE_UNKNOWN = "unknown" + + private var callback: ConnectivityManager.NetworkCallback? = null + + /** Invoked on any network change; set by [startWatching]. */ + @Volatile + private var onChange: (() -> Unit)? = null + + private fun connectivityManager(context: Context): ConnectivityManager? = + context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager + + /** + * Current transport type of the active network. + * + * Returns UNKNOWN (not NONE) when capabilities can't be read, so callers can + * distinguish "definitely offline" from "couldn't tell". + */ + fun currentType(context: Context): String { + val cm = connectivityManager(context) ?: return TYPE_UNKNOWN + val network = cm.activeNetwork ?: return TYPE_NONE + val caps = cm.getNetworkCapabilities(network) ?: return TYPE_UNKNOWN + + return when { + caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> TYPE_WIFI + caps.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) -> TYPE_ETHERNET + caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> TYPE_CELLULAR + else -> TYPE_OTHER + } + } + + /** + * Whether the active network is unmetered. + * + * This is the bit that actually matters for the WiFi-only gate: a phone + * hotspot reports TRANSPORT_WIFI but is metered, and is backed by exactly the + * cellular data the setting exists to protect. Checking NOT_METERED rather + * than the transport alone means tethering doesn't quietly burn a data plan. + */ + fun isUnmetered(context: Context): Boolean { + val cm = connectivityManager(context) ?: return false + val network = cm.activeNetwork ?: return false + val caps = cm.getNetworkCapabilities(network) ?: return false + return caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED) + } + + /** + * 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. When wifi-only is off this is + * always true — the gate simply isn't engaged. + */ + fun isAcceptable(context: Context, wifiOnly: Boolean): Boolean { + if (!wifiOnly) return true + val type = currentType(context) + if (type == TYPE_CELLULAR || type == TYPE_NONE || type == TYPE_UNKNOWN) return false + // WiFi/Ethernet/other: require unmetered so metered hotspots are excluded. + return isUnmetered(context) + } + + /** + * Register a callback that fires whenever the network changes, so a blocked + * download queue can be re-pumped the moment an acceptable network appears. + * Without this the queue would stall until some unrelated event pumped it. + * + * Idempotent: a second call replaces the previous callback. + */ + fun startWatching(context: Context, onNetworkChanged: () -> Unit) { + val cm = connectivityManager(context) ?: run { + android.util.Log.w(TAG, "No ConnectivityManager; network changes won't be observed") + return + } + + stopWatching(context) + onChange = onNetworkChanged + + val request = NetworkRequest.Builder() + .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + .build() + + val cb = object : ConnectivityManager.NetworkCallback() { + override fun onAvailable(network: Network) { + android.util.Log.d(TAG, "Network available") + onChange?.invoke() + } + + override fun onLost(network: Network) { + android.util.Log.d(TAG, "Network lost") + onChange?.invoke() + } + + override fun onCapabilitiesChanged(network: Network, caps: NetworkCapabilities) { + // Fires when e.g. metered-ness flips without the network itself changing. + onChange?.invoke() + } + } + + try { + cm.registerNetworkCallback(request, cb) + callback = cb + android.util.Log.d(TAG, "Network callback registered") + } catch (e: Exception) { + android.util.Log.e(TAG, "Failed to register network callback", e) + } + } + + /** Unregister the network callback, if one is active. */ + fun stopWatching(context: Context) { + val cb = callback ?: return + val cm = connectivityManager(context) + try { + cm?.unregisterNetworkCallback(cb) + } catch (e: Exception) { + android.util.Log.w(TAG, "Failed to unregister network callback", e) + } + callback = null + onChange = null + } +} diff --git a/src-tauri/src/commands/download/mod.rs b/src-tauri/src/commands/download/mod.rs index 416c6578..9d9686d8 100644 --- a/src-tauri/src/commands/download/mod.rs +++ b/src-tauri/src/commands/download/mod.rs @@ -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); +/// 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::().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 { + 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::(); + 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::(); + 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::(); 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 { + 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 = 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 { diff --git a/src-tauri/src/download/cache.rs b/src-tauri/src/download/cache.rs index aa2f8feb..25c3963a 100644 --- a/src-tauri/src/download/cache.rs +++ b/src-tauri/src/download/cache.rs @@ -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; diff --git a/src-tauri/src/download/events.rs b/src-tauri/src/download/events.rs index a6da1108..f2528fda 100644 --- a/src-tauri/src/download/events.rs +++ b/src-tauri/src/download/events.rs @@ -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)] diff --git a/src-tauri/src/download/mod.rs b/src-tauri/src/download/mod.rs index bd5f67c9..fc8b309e 100644 --- a/src-tauri/src/download/mod.rs +++ b/src-tauri/src/download/mod.rs @@ -8,6 +8,7 @@ pub mod cache; pub mod events; +pub mod network; pub mod worker; use crate::utils::lock::MutexSafe; diff --git a/src-tauri/src/download/network.rs b/src-tauri/src/download/network.rs new file mode 100644 index 00000000..1c3de373 --- /dev/null +++ b/src-tauri/src/download/network.rs @@ -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>, +} + +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\"" + ); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 34ea33a8..d6a998ca 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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 { 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 { 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 { 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(); diff --git a/src/lib/services/networkType.test.ts b/src/lib/services/networkType.test.ts new file mode 100644 index 00000000..157ef4a2 --- /dev/null +++ b/src/lib/services/networkType.test.ts @@ -0,0 +1,156 @@ +/** + * Tests for the network-transport reporter behind the WiFi-only download gate. + * + * TRACES: UR-053 | DR-074 | UT-066 + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +const setNetworkState = vi.fn(); +const getDownloadsAllowed = vi.fn(); + +vi.mock('$lib/api/bindings', () => ({ + commands: { + setNetworkState: (...args: unknown[]) => setNetworkState(...args), + getDownloadsAllowed: () => getDownloadsAllowed() + } +})); + +import { + isNetworkDetectionSupported, + reportNetworkState, + startNetworkReporting, + areDownloadsAllowed +} from './networkType'; + +/** Install a fake Android bridge on window. */ +function installBridge(overrides: Partial> = {}) { + const bridge = { + currentType: vi.fn(() => 'wifi'), + isUnmetered: vi.fn(() => true), + isAcceptable: vi.fn(() => true), + isSupported: vi.fn(() => true), + ...overrides + }; + (window as unknown as Record).AndroidNetworkType = bridge; + return bridge; +} + +function removeBridge() { + delete (window as unknown as Record).AndroidNetworkType; +} + +describe('networkType service', () => { + beforeEach(() => { + vi.clearAllMocks(); + setNetworkState.mockResolvedValue(null); + getDownloadsAllowed.mockResolvedValue(true); + removeBridge(); + }); + + afterEach(() => { + removeBridge(); + }); + + describe('isNetworkDetectionSupported', () => { + it('is false with no Android bridge (desktop)', () => { + expect(isNetworkDetectionSupported()).toBe(false); + }); + + it('is true when the Android bridge is present', () => { + installBridge(); + expect(isNetworkDetectionSupported()).toBe(true); + }); + + it('is false when the bridge throws', () => { + installBridge({ + isSupported: vi.fn(() => { + throw new Error('bridge exploded'); + }) + }); + expect(isNetworkDetectionSupported()).toBe(false); + }); + }); + + describe('reportNetworkState', () => { + it('does not call the backend on desktop', async () => { + await reportNetworkState(); + expect(setNetworkState).not.toHaveBeenCalled(); + }); + + it('reports transport and metered-ness from the bridge', async () => { + installBridge({ + currentType: vi.fn(() => 'cellular'), + isUnmetered: vi.fn(() => false) + }); + + await reportNetworkState(); + + expect(setNetworkState).toHaveBeenCalledWith({ + networkType: 'cellular', + unmetered: false + }); + }); + + it('reports metered WiFi as WiFi-but-metered, not as unmetered', async () => { + // A phone hotspot: WiFi transport, metered connection. + installBridge({ + currentType: vi.fn(() => 'wifi'), + isUnmetered: vi.fn(() => false) + }); + + await reportNetworkState(); + + expect(setNetworkState).toHaveBeenCalledWith({ + networkType: 'wifi', + unmetered: false + }); + }); + + it('swallows backend errors so the UI never breaks', async () => { + installBridge(); + setNetworkState.mockRejectedValue(new Error('ipc down')); + + await expect(reportNetworkState()).resolves.toBeUndefined(); + }); + }); + + describe('startNetworkReporting', () => { + it('reports once immediately and again on network change', async () => { + installBridge(); + + const stop = startNetworkReporting(); + await vi.waitFor(() => expect(setNetworkState).toHaveBeenCalledTimes(1)); + + window.dispatchEvent(new CustomEvent('jellytau-network-changed')); + await vi.waitFor(() => expect(setNetworkState).toHaveBeenCalledTimes(2)); + + stop(); + }); + + it('stops reporting after teardown', async () => { + installBridge(); + + const stop = startNetworkReporting(); + await vi.waitFor(() => expect(setNetworkState).toHaveBeenCalledTimes(1)); + stop(); + + window.dispatchEvent(new CustomEvent('jellytau-network-changed')); + // Give any stray listener a chance to fire before asserting. + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(setNetworkState).toHaveBeenCalledTimes(1); + }); + }); + + describe('areDownloadsAllowed', () => { + it('returns the backend verdict', async () => { + getDownloadsAllowed.mockResolvedValue(false); + expect(await areDownloadsAllowed()).toBe(false); + }); + + it('fails open if the query errors, so the UI never falsely blames WiFi', async () => { + getDownloadsAllowed.mockRejectedValue(new Error('ipc down')); + expect(await areDownloadsAllowed()).toBe(true); + }); + }); +}); diff --git a/src/lib/services/networkType.ts b/src/lib/services/networkType.ts new file mode 100644 index 00000000..0c586f97 --- /dev/null +++ b/src/lib/services/networkType.ts @@ -0,0 +1,109 @@ +/** + * Reports the device's network transport to the Rust backend, so the download + * queue can honour the "WiFi Only" setting. + * + * Android exposes the real transport through the `AndroidNetworkType` + * JavascriptInterface (backed by NetworkCapabilities). On desktop that + * interface is absent and we report nothing — the backend defaults to unmetered + * ethernet, so desktop downloads are never gated. + * + * TRACES: UR-053 | DR-074 + */ + +import { commands } from '$lib/api/bindings'; +import type { NetworkType } from '$lib/api/bindings'; + +/** The Android bridge, present only in the Android WebView. */ +interface AndroidNetworkTypeBridge { + currentType(): NetworkType; + isUnmetered(): boolean; + isAcceptable(wifiOnly: boolean): boolean; + isSupported(): boolean; +} + +declare global { + interface Window { + AndroidNetworkType?: AndroidNetworkTypeBridge; + } +} + +/** Event dispatched into the WebView by MainActivity on any network change. */ +const NETWORK_CHANGED_EVENT = 'jellytau-network-changed'; + +function bridge(): AndroidNetworkTypeBridge | undefined { + if (typeof window === 'undefined') return undefined; + return window.AndroidNetworkType; +} + +/** Whether native network detection is available (Android only). */ +export function isNetworkDetectionSupported(): boolean { + try { + return bridge()?.isSupported() ?? false; + } catch { + return false; + } +} + +/** + * Read the current transport from Android and push it into Rust. + * + * No-op on desktop, where the backend's unmetered-ethernet default already + * means downloads run unconditionally. + */ +export async function reportNetworkState(): Promise { + const android = bridge(); + if (!android) return; + + try { + const networkType = android.currentType(); + const unmetered = android.isUnmetered(); + + await commands.setNetworkState({ networkType, unmetered }); + } catch (error) { + // Never let network reporting break the UI — the gate fails closed on + // the Rust side, so a missed report at worst delays a queued download. + console.warn('[NetworkType] Failed to report network state:', error); + } +} + +/** + * Start reporting network state: once immediately, then on every native network + * change. Reporting an acceptable network re-pumps the download queue on the + * Rust side, so a queue parked on "waiting for WiFi" drains itself. + * + * Returns a teardown function. + */ +export function startNetworkReporting(): () => void { + if (typeof window === 'undefined') return () => {}; + + void reportNetworkState(); + + const onChange = () => { + void reportNetworkState(); + }; + + window.addEventListener(NETWORK_CHANGED_EVENT, onChange); + // The browser's own online/offline events are a useful extra nudge on + // desktop-style webviews where the native callback may not fire. + window.addEventListener('online', onChange); + window.addEventListener('offline', onChange); + + return () => { + window.removeEventListener(NETWORK_CHANGED_EVENT, onChange); + window.removeEventListener('online', onChange); + window.removeEventListener('offline', onChange); + }; +} + +/** + * Whether downloads are currently permitted by the WiFi-only gate. Used by the + * downloads UI to show "Waiting for WiFi" instead of a stuck-looking queue. + */ +export async function areDownloadsAllowed(): Promise { + try { + return await commands.getDownloadsAllowed(); + } catch (error) { + console.warn('[NetworkType] Failed to query download gate:', error); + return true; + } +} diff --git a/src/lib/stores/downloads.test.ts b/src/lib/stores/downloads.test.ts index 14cc891f..411402fb 100644 --- a/src/lib/stores/downloads.test.ts +++ b/src/lib/stores/downloads.test.ts @@ -309,6 +309,35 @@ describe("downloads store", () => { expect(state.stats.queuedCount).toBe(1); // 1 pending }); + // The Transfers view shows only in-flight rows; a completed transfer must + // NOT appear there (it lives in Downloaded). Mirrors the /downloads page's + // `transfers` derivation: active + pending + failed. + // TRACES: UR-055 | DR-084 | UT-052 + it("transfers set excludes completed downloads", async () => { + const { downloads, activeDownloads, pendingDownloads, failedDownloads } = await import( + "./downloads" + ); + + mockInvoke.mockResolvedValueOnce({ + downloads: [ + { id: 1, itemId: "a", userId: "u", filePath: "/a", status: "downloading", progress: 0.5, bytesDownloaded: 5, queuedAt: "t", retryCount: 0, priority: 0, mediaType: "audio", downloadSource: "user" }, + { id: 2, itemId: "b", userId: "u", filePath: "/b", status: "pending", progress: 0, bytesDownloaded: 0, queuedAt: "t", retryCount: 0, priority: 0, mediaType: "audio", downloadSource: "user" }, + { id: 3, itemId: "c", userId: "u", filePath: "/c", status: "completed", progress: 1, bytesDownloaded: 9, queuedAt: "t", retryCount: 0, priority: 0, mediaType: "audio", downloadSource: "user" }, + { id: 4, itemId: "d", userId: "u", filePath: "/d", status: "failed", progress: 0, bytesDownloaded: 0, queuedAt: "t", retryCount: 1, priority: 0, mediaType: "audio", downloadSource: "user" }, + ], + stats: { total: 4, activeCount: 1, queuedCount: 1, completedCount: 1, failedCount: 1, pausedCount: 0 }, + }); + + await downloads.refresh("u"); + + const transfers = get(activeDownloads) + .concat(get(pendingDownloads)) + .concat(get(failedDownloads)); + const ids = transfers.map((d) => d.id).sort(); + expect(ids).toEqual([1, 2, 4]); + expect(transfers.some((d) => d.status === "completed")).toBe(false); + }); + it("should support status filter", async () => { const { downloads } = await import("./downloads"); diff --git a/src/lib/stores/downloads.ts b/src/lib/stores/downloads.ts index cf9de16d..a3d1479a 100644 --- a/src/lib/stores/downloads.ts +++ b/src/lib/stores/downloads.ts @@ -40,7 +40,16 @@ export interface DownloadInfo { } export interface DownloadEvent { - type: 'queued' | 'started' | 'progress' | 'completed' | 'failed' | 'paused' | 'cancelled'; + type: + | 'queued' + | 'started' + | 'progress' + | 'completed' + | 'failed' + | 'paused' + | 'cancelled' + | 'waitingForNetwork'; + /** Absent on 'waitingForNetwork', which is queue-wide rather than per-download. */ downloadId: number; itemId: string; bytesDownloaded?: number; @@ -64,6 +73,15 @@ interface DownloadsState { stats: DownloadStats; } +/** + * True when the download queue is held because "WiFi Only" is enabled and the + * device is on a metered/cellular network. Pending rows stay pending; the queue + * resumes automatically when an acceptable network appears. + * + * TRACES: UR-053 | DR-074 + */ +export const waitingForNetwork = writable(false); + function createDownloadsStore() { const { subscribe, update, set } = writable({ downloads: {}, @@ -607,6 +625,17 @@ function handleDownloadEvent(payload: DownloadEvent): void { case 'cancelled': removeDownloadFromStore(payload.downloadId); break; + + case 'waitingForNetwork': + // Queue-wide, not tied to one download: the pump refused to start + // anything because WiFi-only is on and we're on a metered network. + waitingForNetwork.set(true); + break; + } + + // Any per-download progress proves the gate isn't holding us any more. + if (payload.type === 'started' || payload.type === 'progress') { + waitingForNetwork.set(false); } }