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:
@@ -14,6 +14,8 @@
|
|||||||
-->
|
-->
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<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.WAKE_LOCK" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
<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(
|
override fun onPictureInPictureModeChanged(
|
||||||
isInPictureInPictureMode: Boolean,
|
isInPictureInPictureMode: Boolean,
|
||||||
newConfig: android.content.res.Configuration
|
newConfig: android.content.res.Configuration
|
||||||
@@ -214,6 +219,35 @@ class MainActivity : TauriActivity() {
|
|||||||
}, "AndroidBackgroundAudio")
|
}, "AndroidBackgroundAudio")
|
||||||
android.util.Log.d("MainActivity", "JavaScript interface 'AndroidBackgroundAudio' added")
|
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
|
// Set WebChromeClient to handle video playback and audio focus
|
||||||
webView.webChromeClient = object : WebChromeClient() {
|
webView.webChromeClient = object : WebChromeClient() {
|
||||||
override fun onShowCustomView(view: View?, callback: CustomViewCallback?) {
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ use std::sync::{Arc, Mutex};
|
|||||||
use tauri::{Manager, State};
|
use tauri::{Manager, State};
|
||||||
|
|
||||||
use super::{DatabaseWrapper, SmartCacheWrapper};
|
use super::{DatabaseWrapper, SmartCacheWrapper};
|
||||||
|
use crate::download::network::{NetworkState, NetworkStateHandle, NetworkType};
|
||||||
use crate::download::{DownloadInfo, DownloadManager};
|
use crate::download::{DownloadInfo, DownloadManager};
|
||||||
use crate::storage::db_service::{DatabaseService, Query, QueryParam};
|
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
|
/// Wrapper for DownloadManager to be used as Tauri state
|
||||||
pub struct DownloadManagerWrapper(pub Mutex<DownloadManager>);
|
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
|
/// Download statistics computed server-side
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
#[derive(specta::Type, Debug, Clone, serde::Serialize, serde::Deserialize)]
|
#[derive(specta::Type, Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||||
@@ -1213,6 +1288,37 @@ pub async fn enqueue_video_downloads(
|
|||||||
Ok(())
|
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.
|
/// Start as many pending downloads as there are free concurrency slots.
|
||||||
///
|
///
|
||||||
/// Picks the highest-priority `pending` rows that have a persisted `stream_url`
|
/// 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 crate::download::events::DownloadEvent;
|
||||||
use tauri::Emitter;
|
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 max_concurrent = {
|
||||||
let manager = app.state::<DownloadManagerWrapper>();
|
let manager = app.state::<DownloadManagerWrapper>();
|
||||||
let manager = match manager.0.lock() {
|
let manager = match manager.0.lock() {
|
||||||
@@ -1799,6 +1915,76 @@ pub async fn delete_album_downloads(
|
|||||||
Ok(deleted_count as i64)
|
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
|
/// Download manager statistics
|
||||||
#[derive(specta::Type, Debug, Clone, serde::Serialize)]
|
#[derive(specta::Type, Debug, Clone, serde::Serialize)]
|
||||||
pub struct DownloadManagerStats {
|
pub struct DownloadManagerStats {
|
||||||
|
|||||||
@@ -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 {
|
pub fn should_precache_queue(&self) -> bool {
|
||||||
self.config
|
self.config
|
||||||
.lock()
|
.lock()
|
||||||
.map(|cfg| cfg.queue_precache_enabled && !cfg.wifi_only)
|
.map(|cfg| cfg.queue_precache_enabled)
|
||||||
.unwrap_or(false)
|
.unwrap_or(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -282,6 +291,19 @@ mod tests {
|
|||||||
assert!(cache.should_precache_queue());
|
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]
|
#[tokio::test]
|
||||||
async fn test_storage_limit_check() {
|
async fn test_storage_limit_check() {
|
||||||
use crate::storage::db_service::RusqliteService;
|
use crate::storage::db_service::RusqliteService;
|
||||||
|
|||||||
@@ -41,6 +41,11 @@ pub enum DownloadEvent {
|
|||||||
/// Download cancelled
|
/// Download cancelled
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
Cancelled { download_id: i64, item_id: String },
|
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)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
|
|
||||||
pub mod cache;
|
pub mod cache;
|
||||||
pub mod events;
|
pub mod events;
|
||||||
|
pub mod network;
|
||||||
pub mod worker;
|
pub mod worker;
|
||||||
|
|
||||||
use crate::utils::lock::MutexSafe;
|
use crate::utils::lock::MutexSafe;
|
||||||
|
|||||||
@@ -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\""
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -52,6 +52,7 @@ use commands::{
|
|||||||
delete_album_downloads,
|
delete_album_downloads,
|
||||||
delete_all_downloads,
|
delete_all_downloads,
|
||||||
delete_download,
|
delete_download,
|
||||||
|
delete_downloads_under,
|
||||||
// Device commands
|
// Device commands
|
||||||
device_get_id,
|
device_get_id,
|
||||||
device_set_id,
|
device_set_id,
|
||||||
@@ -71,6 +72,7 @@ use commands::{
|
|||||||
get_download_manager_stats,
|
get_download_manager_stats,
|
||||||
get_download_storage_stats,
|
get_download_storage_stats,
|
||||||
get_downloads,
|
get_downloads,
|
||||||
|
get_downloads_allowed,
|
||||||
get_smart_cache_config,
|
get_smart_cache_config,
|
||||||
get_smart_cache_stats,
|
get_smart_cache_stats,
|
||||||
image_get_url,
|
image_get_url,
|
||||||
@@ -179,6 +181,9 @@ use commands::{
|
|||||||
repository_get_audio_only_stream_url_for_video,
|
repository_get_audio_only_stream_url_for_video,
|
||||||
repository_get_audio_stream_url,
|
repository_get_audio_stream_url,
|
||||||
repository_get_channels,
|
repository_get_channels,
|
||||||
|
repository_get_download_disk_usage,
|
||||||
|
repository_get_downloaded_items,
|
||||||
|
repository_get_downloaded_libraries,
|
||||||
repository_get_genres,
|
repository_get_genres,
|
||||||
repository_get_image_url,
|
repository_get_image_url,
|
||||||
repository_get_item,
|
repository_get_item,
|
||||||
@@ -212,6 +217,7 @@ use commands::{
|
|||||||
// Session polling commands
|
// Session polling commands
|
||||||
sessions_set_polling_hint,
|
sessions_set_polling_hint,
|
||||||
set_max_concurrent_downloads,
|
set_max_concurrent_downloads,
|
||||||
|
set_network_state,
|
||||||
set_show_server_catalog,
|
set_show_server_catalog,
|
||||||
start_download,
|
start_download,
|
||||||
// Storage commands
|
// Storage commands
|
||||||
@@ -770,6 +776,7 @@ fn specta_builder() -> Builder<tauri::Wry> {
|
|||||||
delete_download,
|
delete_download,
|
||||||
delete_all_downloads,
|
delete_all_downloads,
|
||||||
delete_album_downloads,
|
delete_album_downloads,
|
||||||
|
delete_downloads_under,
|
||||||
clear_stale_downloads,
|
clear_stale_downloads,
|
||||||
get_download_storage_stats,
|
get_download_storage_stats,
|
||||||
mark_download_completed,
|
mark_download_completed,
|
||||||
@@ -786,6 +793,9 @@ fn specta_builder() -> Builder<tauri::Wry> {
|
|||||||
get_smart_cache_stats,
|
get_smart_cache_stats,
|
||||||
update_smart_cache_config,
|
update_smart_cache_config,
|
||||||
get_smart_cache_config,
|
get_smart_cache_config,
|
||||||
|
// WiFi-only download gate (UR-053)
|
||||||
|
set_network_state,
|
||||||
|
get_downloads_allowed,
|
||||||
get_album_recommendations,
|
get_album_recommendations,
|
||||||
get_album_affinity_status,
|
get_album_affinity_status,
|
||||||
// Pinning commands
|
// Pinning commands
|
||||||
@@ -835,6 +845,9 @@ fn specta_builder() -> Builder<tauri::Wry> {
|
|||||||
repository_get_libraries,
|
repository_get_libraries,
|
||||||
repository_get_items,
|
repository_get_items,
|
||||||
repository_get_item,
|
repository_get_item,
|
||||||
|
repository_get_downloaded_libraries,
|
||||||
|
repository_get_downloaded_items,
|
||||||
|
repository_get_download_disk_usage,
|
||||||
repository_jray_actors_at,
|
repository_jray_actors_at,
|
||||||
repository_get_latest_items,
|
repository_get_latest_items,
|
||||||
repository_get_resume_items,
|
repository_get_resume_items,
|
||||||
@@ -1196,6 +1209,13 @@ pub fn run() {
|
|||||||
let download_manager_wrapper = DownloadManagerWrapper(Mutex::new(download_manager));
|
let download_manager_wrapper = DownloadManagerWrapper(Mutex::new(download_manager));
|
||||||
app.manage(download_manager_wrapper);
|
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
|
// Initialize connectivity monitor
|
||||||
info!("[INIT] Initializing connectivity monitor...");
|
info!("[INIT] Initializing connectivity monitor...");
|
||||||
let http_config = HttpConfig::default();
|
let http_config = HttpConfig::default();
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -309,6 +309,35 @@ describe("downloads store", () => {
|
|||||||
expect(state.stats.queuedCount).toBe(1); // 1 pending
|
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 () => {
|
it("should support status filter", async () => {
|
||||||
const { downloads } = await import("./downloads");
|
const { downloads } = await import("./downloads");
|
||||||
|
|
||||||
|
|||||||
@@ -40,7 +40,16 @@ export interface DownloadInfo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface DownloadEvent {
|
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;
|
downloadId: number;
|
||||||
itemId: string;
|
itemId: string;
|
||||||
bytesDownloaded?: number;
|
bytesDownloaded?: number;
|
||||||
@@ -64,6 +73,15 @@ interface DownloadsState {
|
|||||||
stats: DownloadStats;
|
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() {
|
function createDownloadsStore() {
|
||||||
const { subscribe, update, set } = writable<DownloadsState>({
|
const { subscribe, update, set } = writable<DownloadsState>({
|
||||||
downloads: {},
|
downloads: {},
|
||||||
@@ -607,6 +625,17 @@ function handleDownloadEvent(payload: DownloadEvent): void {
|
|||||||
case 'cancelled':
|
case 'cancelled':
|
||||||
removeDownloadFromStore(payload.downloadId);
|
removeDownloadFromStore(payload.downloadId);
|
||||||
break;
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user