Skip to main content

#
jellytau_lib/download/
network.rs

    1//! Network transport classification for the WiFi-only download gate.
    2//!
    3//! This answers "what kind of connection are we on?", which is orthogonal to
    4//! the `ConnectivityMonitor`'s "is the server reachable?". The download queue
    5//! pump consults this before starting pending rows when the user has enabled
    6//! WiFi-only downloads.
    7//!
    8//! On Android the real transport is read from `NetworkCapabilities` in
    9//! `NetworkTypeMonitor.kt` and pushed in from the frontend. On desktop there is
    10//! no metered-connection concept worth enforcing, so we report `Ethernet`,
    11//! which is always acceptable — gating desktop downloads would be a regression.
    12
    13use serde::{Deserialize, Serialize};
    14use std::sync::Arc;
    15use tokio::sync::RwLock;
    16
    17/// Kind of network transport currently active.
    18///
    19/// Mirrors the string constants in `NetworkTypeMonitor.kt`; the two must stay
    20/// in sync (the serde rename below is what the frontend sends).
    21///
    22/// TRACES: UR-053 | DR-074
    23#[derive(specta::Type, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
    24#[serde(rename_all = "lowercase")]
    25pub enum NetworkType {
    26    /// No active network.
    27    None,
    28    /// WiFi (may still be metered — check `unmetered`).
    29    Wifi,
    30    /// Wired ethernet, typical on Android TV and desktop.
    31    Ethernet,
    32    /// Mobile data — never acceptable when wifi-only is enabled.
    33    Cellular,
    34    /// Some other transport (VPN over unknown carrier, Bluetooth tethering, …).
    35    Other,
    36    /// Could not determine the transport.
    37    Unknown,
    38}
    39
    40/// Current network transport plus whether it is metered.
    41///
    42/// TRACES: UR-053 | DR-074
    43#[derive(specta::Type, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
    44#[serde(rename_all = "camelCase")]
    45pub struct NetworkState {
    46    pub network_type: NetworkType,
    47    /// Whether the active network is unmetered (Android `NET_CAPABILITY_NOT_METERED`).
    48    pub unmetered: bool,
    49}
    50
    51impl Default for NetworkState {
    52    fn default() -> Self {
    53        // Desktop default: wired and unmetered, so the gate never blocks there.
    54        // Android overwrites this as soon as the frontend reports the real state.
    55        Self {
    56            network_type: NetworkType::Ethernet,
    57            unmetered: true,
    58        }
    59    }
    60}
    61
    62impl NetworkState {
    63    /// Whether downloads may run right now given the wifi-only preference.
    64    ///
    65    /// Ethernet counts as acceptable — it is unmetered in practice and is what
    66    /// Android TV devices use. Cellular never does. `None`/`Unknown` fail
    67    /// closed: if we cannot tell what we are on, we do not spend the user's
    68    /// mobile data to find out.
    69    ///
    70    /// TRACES: UR-053 | DR-074
    71    pub fn allows_download(&self, wifi_only: bool) -> bool {
    72        if !wifi_only {
    73            return true;
    74        }
    75        match self.network_type {
    76            NetworkType::Cellular | NetworkType::None | NetworkType::Unknown => false,
    77            // Require unmetered so metered WiFi hotspots (backed by the very
    78            // cellular data this setting protects) are excluded too.
    79            NetworkType::Wifi | NetworkType::Ethernet | NetworkType::Other => self.unmetered,
    80        }
    81    }
    82}
    83
    84/// Shared, mutable view of the current network transport.
    85///
    86/// Cheap to clone; the frontend updates it via `set_network_state` whenever
    87/// Android reports a network change.
    88#[derive(Clone, Default)]
    89pub struct NetworkStateHandle {
    90    state: Arc<RwLock<NetworkState>>,
    91}
    92
    93impl NetworkStateHandle {
    94    pub fn new() -> Self {
    95        Self {
    96            state: Arc::new(RwLock::new(NetworkState::default())),
    97        }
    98    }
    99
    100    pub async fn get(&self) -> NetworkState {
    101        *self.state.read().await
    102    }
    103
    104    pub async fn set(&self, new_state: NetworkState) {
    105        *self.state.write().await = new_state;
    106    }
    107
    108    /// Whether downloads may run right now given the wifi-only preference.
    109    pub async fn allows_download(&self, wifi_only: bool) -> bool {
    110        self.state.read().await.allows_download(wifi_only)
    111    }
    112}
    113
    114#[cfg(test)]
    115mod tests {
    116    use super::*;
    117
    118    fn state(network_type: NetworkType, unmetered: bool) -> NetworkState {
    119        NetworkState {
    120            network_type,
    121            unmetered,
    122        }
    123    }
    124
    125    #[test]
    126    fn wifi_only_off_allows_every_transport() {
    127        for t in [
    128            NetworkType::None,
    129            NetworkType::Wifi,
    130            NetworkType::Ethernet,
    131            NetworkType::Cellular,
    132            NetworkType::Other,
    133            NetworkType::Unknown,
    134        ] {
    135            assert!(
    136                state(t, false).allows_download(false),
    137                "{t:?} should be allowed when wifi_only is off"
    138            );
    139        }
    140    }
    141
    142    #[test]
    143    fn cellular_is_blocked_when_wifi_only() {
    144        // Even if somehow flagged unmetered, cellular is never acceptable.
    145        assert!(!state(NetworkType::Cellular, true).allows_download(true));
    146        assert!(!state(NetworkType::Cellular, false).allows_download(true));
    147    }
    148
    149    #[test]
    150    fn unmetered_wifi_and_ethernet_are_allowed() {
    151        assert!(state(NetworkType::Wifi, true).allows_download(true));
    152        assert!(state(NetworkType::Ethernet, true).allows_download(true));
    153    }
    154
    155    #[test]
    156    fn metered_wifi_is_blocked() {
    157        // A phone hotspot reports as WiFi but is metered — blocking it is the
    158        // whole point of checking NOT_METERED rather than the transport alone.
    159        assert!(!state(NetworkType::Wifi, false).allows_download(true));
    160    }
    161
    162    #[test]
    163    fn unknown_and_none_fail_closed() {
    164        assert!(!state(NetworkType::Unknown, true).allows_download(true));
    165        assert!(!state(NetworkType::None, true).allows_download(true));
    166    }
    167
    168    #[test]
    169    fn desktop_default_is_never_gated() {
    170        assert!(NetworkState::default().allows_download(true));
    171    }
    172
    173    #[tokio::test]
    174    async fn handle_roundtrips_state() {
    175        let handle = NetworkStateHandle::new();
    176        assert!(handle.allows_download(true).await);
    177
    178        handle.set(state(NetworkType::Cellular, false)).await;
    179        assert!(!handle.allows_download(true).await);
    180        assert!(handle.allows_download(false).await);
    181        assert_eq!(handle.get().await.network_type, NetworkType::Cellular);
    182    }
    183
    184    #[test]
    185    fn network_type_serializes_lowercase() {
    186        // Must match the string constants in NetworkTypeMonitor.kt.
    187        assert_eq!(
    188            serde_json::to_string(&NetworkType::Wifi).unwrap(),
    189            "\"wifi\""
    190        );
    191        assert_eq!(
    192            serde_json::to_string(&NetworkType::Cellular).unwrap(),
    193            "\"cellular\""
    194        );
    195    }
    196}