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
+156
View File
@@ -0,0 +1,156 @@
/**
* Tests for the network-transport reporter behind the WiFi-only download gate.
*
* TRACES: UR-053 | DR-074 | UT-066
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
const setNetworkState = vi.fn();
const getDownloadsAllowed = vi.fn();
vi.mock('$lib/api/bindings', () => ({
commands: {
setNetworkState: (...args: unknown[]) => setNetworkState(...args),
getDownloadsAllowed: () => getDownloadsAllowed()
}
}));
import {
isNetworkDetectionSupported,
reportNetworkState,
startNetworkReporting,
areDownloadsAllowed
} from './networkType';
/** Install a fake Android bridge on window. */
function installBridge(overrides: Partial<Record<string, unknown>> = {}) {
const bridge = {
currentType: vi.fn(() => 'wifi'),
isUnmetered: vi.fn(() => true),
isAcceptable: vi.fn(() => true),
isSupported: vi.fn(() => true),
...overrides
};
(window as unknown as Record<string, unknown>).AndroidNetworkType = bridge;
return bridge;
}
function removeBridge() {
delete (window as unknown as Record<string, unknown>).AndroidNetworkType;
}
describe('networkType service', () => {
beforeEach(() => {
vi.clearAllMocks();
setNetworkState.mockResolvedValue(null);
getDownloadsAllowed.mockResolvedValue(true);
removeBridge();
});
afterEach(() => {
removeBridge();
});
describe('isNetworkDetectionSupported', () => {
it('is false with no Android bridge (desktop)', () => {
expect(isNetworkDetectionSupported()).toBe(false);
});
it('is true when the Android bridge is present', () => {
installBridge();
expect(isNetworkDetectionSupported()).toBe(true);
});
it('is false when the bridge throws', () => {
installBridge({
isSupported: vi.fn(() => {
throw new Error('bridge exploded');
})
});
expect(isNetworkDetectionSupported()).toBe(false);
});
});
describe('reportNetworkState', () => {
it('does not call the backend on desktop', async () => {
await reportNetworkState();
expect(setNetworkState).not.toHaveBeenCalled();
});
it('reports transport and metered-ness from the bridge', async () => {
installBridge({
currentType: vi.fn(() => 'cellular'),
isUnmetered: vi.fn(() => false)
});
await reportNetworkState();
expect(setNetworkState).toHaveBeenCalledWith({
networkType: 'cellular',
unmetered: false
});
});
it('reports metered WiFi as WiFi-but-metered, not as unmetered', async () => {
// A phone hotspot: WiFi transport, metered connection.
installBridge({
currentType: vi.fn(() => 'wifi'),
isUnmetered: vi.fn(() => false)
});
await reportNetworkState();
expect(setNetworkState).toHaveBeenCalledWith({
networkType: 'wifi',
unmetered: false
});
});
it('swallows backend errors so the UI never breaks', async () => {
installBridge();
setNetworkState.mockRejectedValue(new Error('ipc down'));
await expect(reportNetworkState()).resolves.toBeUndefined();
});
});
describe('startNetworkReporting', () => {
it('reports once immediately and again on network change', async () => {
installBridge();
const stop = startNetworkReporting();
await vi.waitFor(() => expect(setNetworkState).toHaveBeenCalledTimes(1));
window.dispatchEvent(new CustomEvent('jellytau-network-changed'));
await vi.waitFor(() => expect(setNetworkState).toHaveBeenCalledTimes(2));
stop();
});
it('stops reporting after teardown', async () => {
installBridge();
const stop = startNetworkReporting();
await vi.waitFor(() => expect(setNetworkState).toHaveBeenCalledTimes(1));
stop();
window.dispatchEvent(new CustomEvent('jellytau-network-changed'));
// Give any stray listener a chance to fire before asserting.
await new Promise((resolve) => setTimeout(resolve, 10));
expect(setNetworkState).toHaveBeenCalledTimes(1);
});
});
describe('areDownloadsAllowed', () => {
it('returns the backend verdict', async () => {
getDownloadsAllowed.mockResolvedValue(false);
expect(await areDownloadsAllowed()).toBe(false);
});
it('fails open if the query errors, so the UI never falsely blames WiFi', async () => {
getDownloadsAllowed.mockRejectedValue(new Error('ipc down'));
expect(await areDownloadsAllowed()).toBe(true);
});
});
});
+109
View File
@@ -0,0 +1,109 @@
/**
* Reports the device's network transport to the Rust backend, so the download
* queue can honour the "WiFi Only" setting.
*
* Android exposes the real transport through the `AndroidNetworkType`
* JavascriptInterface (backed by NetworkCapabilities). On desktop that
* interface is absent and we report nothing — the backend defaults to unmetered
* ethernet, so desktop downloads are never gated.
*
* TRACES: UR-053 | DR-074
*/
import { commands } from '$lib/api/bindings';
import type { NetworkType } from '$lib/api/bindings';
/** The Android bridge, present only in the Android WebView. */
interface AndroidNetworkTypeBridge {
currentType(): NetworkType;
isUnmetered(): boolean;
isAcceptable(wifiOnly: boolean): boolean;
isSupported(): boolean;
}
declare global {
interface Window {
AndroidNetworkType?: AndroidNetworkTypeBridge;
}
}
/** Event dispatched into the WebView by MainActivity on any network change. */
const NETWORK_CHANGED_EVENT = 'jellytau-network-changed';
function bridge(): AndroidNetworkTypeBridge | undefined {
if (typeof window === 'undefined') return undefined;
return window.AndroidNetworkType;
}
/** Whether native network detection is available (Android only). */
export function isNetworkDetectionSupported(): boolean {
try {
return bridge()?.isSupported() ?? false;
} catch {
return false;
}
}
/**
* Read the current transport from Android and push it into Rust.
*
* No-op on desktop, where the backend's unmetered-ethernet default already
* means downloads run unconditionally.
*/
export async function reportNetworkState(): Promise<void> {
const android = bridge();
if (!android) return;
try {
const networkType = android.currentType();
const unmetered = android.isUnmetered();
await commands.setNetworkState({ networkType, unmetered });
} catch (error) {
// Never let network reporting break the UI — the gate fails closed on
// the Rust side, so a missed report at worst delays a queued download.
console.warn('[NetworkType] Failed to report network state:', error);
}
}
/**
* Start reporting network state: once immediately, then on every native network
* change. Reporting an acceptable network re-pumps the download queue on the
* Rust side, so a queue parked on "waiting for WiFi" drains itself.
*
* Returns a teardown function.
*/
export function startNetworkReporting(): () => void {
if (typeof window === 'undefined') return () => {};
void reportNetworkState();
const onChange = () => {
void reportNetworkState();
};
window.addEventListener(NETWORK_CHANGED_EVENT, onChange);
// The browser's own online/offline events are a useful extra nudge on
// desktop-style webviews where the native callback may not fire.
window.addEventListener('online', onChange);
window.addEventListener('offline', onChange);
return () => {
window.removeEventListener(NETWORK_CHANGED_EVENT, onChange);
window.removeEventListener('online', onChange);
window.removeEventListener('offline', onChange);
};
}
/**
* Whether downloads are currently permitted by the WiFi-only gate. Used by the
* downloads UI to show "Waiting for WiFi" instead of a stuck-looking queue.
*/
export async function areDownloadsAllowed(): Promise<boolean> {
try {
return await commands.getDownloadsAllowed();
} catch (error) {
console.warn('[NetworkType] Failed to query download gate:', error);
return true;
}
}