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
@@ -14,6 +14,8 @@
-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<!-- Required to read NetworkCapabilities for the WiFi-only download gate (UR-053) -->
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
@@ -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?) {
@@ -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
}
}
+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();
+156
View File
@@ -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<Record<string, unknown>> = {}) {
const bridge = {
currentType: vi.fn(() => 'wifi'),
isUnmetered: vi.fn(() => true),
isAcceptable: vi.fn(() => true),
isSupported: vi.fn(() => true),
...overrides
};
(window as unknown as Record<string, unknown>).AndroidNetworkType = bridge;
return bridge;
}
function removeBridge() {
delete (window as unknown as Record<string, unknown>).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);
});
});
});
+109
View File
@@ -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<void> {
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<boolean> {
try {
return await commands.getDownloadsAllowed();
} catch (error) {
console.warn('[NetworkType] Failed to query download gate:', error);
return true;
}
}
+29
View File
@@ -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");
+30 -1
View File
@@ -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<DownloadsState>({
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);
}
}