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
197 lines
6.4 KiB
Rust
197 lines
6.4 KiB
Rust
//! 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\""
|
|
);
|
|
}
|
|
}
|