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
}
}