//! Network transport classification for the WiFi-only download gate. //! //! This answers "what kind of connection are we on?", which is orthogonal to //! the `ConnectivityMonitor`'s "is the server reachable?". The download queue //! pump consults this before starting pending rows when the user has enabled //! WiFi-only downloads. //! //! On Android the real transport is read from `NetworkCapabilities` in //! `NetworkTypeMonitor.kt` and pushed in from the frontend. On desktop there is //! no metered-connection concept worth enforcing, so we report `Ethernet`, //! which is always acceptable — gating desktop downloads would be a regression. use serde::{Deserialize, Serialize}; use std::sync::Arc; use tokio::sync::RwLock; /// Kind of network transport currently active. /// /// Mirrors the string constants in `NetworkTypeMonitor.kt`; the two must stay /// in sync (the serde rename below is what the frontend sends). /// /// TRACES: UR-053 | DR-074 #[derive(specta::Type, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum NetworkType { /// No active network. None, /// WiFi (may still be metered — check `unmetered`). Wifi, /// Wired ethernet, typical on Android TV and desktop. Ethernet, /// Mobile data — never acceptable when wifi-only is enabled. Cellular, /// Some other transport (VPN over unknown carrier, Bluetooth tethering, …). Other, /// Could not determine the transport. Unknown, } /// Current network transport plus whether it is metered. /// /// TRACES: UR-053 | DR-074 #[derive(specta::Type, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct NetworkState { pub network_type: NetworkType, /// Whether the active network is unmetered (Android `NET_CAPABILITY_NOT_METERED`). pub unmetered: bool, } impl Default for NetworkState { fn default() -> Self { // Desktop default: wired and unmetered, so the gate never blocks there. // Android overwrites this as soon as the frontend reports the real state. Self { network_type: NetworkType::Ethernet, unmetered: true, } } } impl NetworkState { /// Whether downloads may run right now given the wifi-only preference. /// /// Ethernet counts as acceptable — it is unmetered in practice and is what /// Android TV devices use. Cellular never does. `None`/`Unknown` fail /// closed: if we cannot tell what we are on, we do not spend the user's /// mobile data to find out. /// /// TRACES: UR-053 | DR-074 pub fn allows_download(&self, wifi_only: bool) -> bool { if !wifi_only { return true; } match self.network_type { NetworkType::Cellular | NetworkType::None | NetworkType::Unknown => false, // Require unmetered so metered WiFi hotspots (backed by the very // cellular data this setting protects) are excluded too. NetworkType::Wifi | NetworkType::Ethernet | NetworkType::Other => self.unmetered, } } } /// Shared, mutable view of the current network transport. /// /// Cheap to clone; the frontend updates it via `set_network_state` whenever /// Android reports a network change. #[derive(Clone, Default)] pub struct NetworkStateHandle { state: Arc>, } impl NetworkStateHandle { pub fn new() -> Self { Self { state: Arc::new(RwLock::new(NetworkState::default())), } } pub async fn get(&self) -> NetworkState { *self.state.read().await } pub async fn set(&self, new_state: NetworkState) { *self.state.write().await = new_state; } /// Whether downloads may run right now given the wifi-only preference. pub async fn allows_download(&self, wifi_only: bool) -> bool { self.state.read().await.allows_download(wifi_only) } } #[cfg(test)] mod tests { use super::*; fn state(network_type: NetworkType, unmetered: bool) -> NetworkState { NetworkState { network_type, unmetered, } } #[test] fn wifi_only_off_allows_every_transport() { for t in [ NetworkType::None, NetworkType::Wifi, NetworkType::Ethernet, NetworkType::Cellular, NetworkType::Other, NetworkType::Unknown, ] { assert!( state(t, false).allows_download(false), "{t:?} should be allowed when wifi_only is off" ); } } #[test] fn cellular_is_blocked_when_wifi_only() { // Even if somehow flagged unmetered, cellular is never acceptable. assert!(!state(NetworkType::Cellular, true).allows_download(true)); assert!(!state(NetworkType::Cellular, false).allows_download(true)); } #[test] fn unmetered_wifi_and_ethernet_are_allowed() { assert!(state(NetworkType::Wifi, true).allows_download(true)); assert!(state(NetworkType::Ethernet, true).allows_download(true)); } #[test] fn metered_wifi_is_blocked() { // A phone hotspot reports as WiFi but is metered — blocking it is the // whole point of checking NOT_METERED rather than the transport alone. assert!(!state(NetworkType::Wifi, false).allows_download(true)); } #[test] fn unknown_and_none_fail_closed() { assert!(!state(NetworkType::Unknown, true).allows_download(true)); assert!(!state(NetworkType::None, true).allows_download(true)); } #[test] fn desktop_default_is_never_gated() { assert!(NetworkState::default().allows_download(true)); } #[tokio::test] async fn handle_roundtrips_state() { let handle = NetworkStateHandle::new(); assert!(handle.allows_download(true).await); handle.set(state(NetworkType::Cellular, false)).await; assert!(!handle.allows_download(true).await); assert!(handle.allows_download(false).await); assert_eq!(handle.get().await.network_type, NetworkType::Cellular); } #[test] fn network_type_serializes_lowercase() { // Must match the string constants in NetworkTypeMonitor.kt. assert_eq!( serde_json::to_string(&NetworkType::Wifi).unwrap(), "\"wifi\"" ); assert_eq!( serde_json::to_string(&NetworkType::Cellular).unwrap(), "\"cellular\"" ); } }