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}