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:
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -309,6 +309,35 @@ describe("downloads store", () => {
|
||||
expect(state.stats.queuedCount).toBe(1); // 1 pending
|
||||
});
|
||||
|
||||
// The Transfers view shows only in-flight rows; a completed transfer must
|
||||
// NOT appear there (it lives in Downloaded). Mirrors the /downloads page's
|
||||
// `transfers` derivation: active + pending + failed.
|
||||
// TRACES: UR-055 | DR-084 | UT-052
|
||||
it("transfers set excludes completed downloads", async () => {
|
||||
const { downloads, activeDownloads, pendingDownloads, failedDownloads } = await import(
|
||||
"./downloads"
|
||||
);
|
||||
|
||||
mockInvoke.mockResolvedValueOnce({
|
||||
downloads: [
|
||||
{ id: 1, itemId: "a", userId: "u", filePath: "/a", status: "downloading", progress: 0.5, bytesDownloaded: 5, queuedAt: "t", retryCount: 0, priority: 0, mediaType: "audio", downloadSource: "user" },
|
||||
{ id: 2, itemId: "b", userId: "u", filePath: "/b", status: "pending", progress: 0, bytesDownloaded: 0, queuedAt: "t", retryCount: 0, priority: 0, mediaType: "audio", downloadSource: "user" },
|
||||
{ id: 3, itemId: "c", userId: "u", filePath: "/c", status: "completed", progress: 1, bytesDownloaded: 9, queuedAt: "t", retryCount: 0, priority: 0, mediaType: "audio", downloadSource: "user" },
|
||||
{ id: 4, itemId: "d", userId: "u", filePath: "/d", status: "failed", progress: 0, bytesDownloaded: 0, queuedAt: "t", retryCount: 1, priority: 0, mediaType: "audio", downloadSource: "user" },
|
||||
],
|
||||
stats: { total: 4, activeCount: 1, queuedCount: 1, completedCount: 1, failedCount: 1, pausedCount: 0 },
|
||||
});
|
||||
|
||||
await downloads.refresh("u");
|
||||
|
||||
const transfers = get(activeDownloads)
|
||||
.concat(get(pendingDownloads))
|
||||
.concat(get(failedDownloads));
|
||||
const ids = transfers.map((d) => d.id).sort();
|
||||
expect(ids).toEqual([1, 2, 4]);
|
||||
expect(transfers.some((d) => d.status === "completed")).toBe(false);
|
||||
});
|
||||
|
||||
it("should support status filter", async () => {
|
||||
const { downloads } = await import("./downloads");
|
||||
|
||||
|
||||
@@ -40,7 +40,16 @@ export interface DownloadInfo {
|
||||
}
|
||||
|
||||
export interface DownloadEvent {
|
||||
type: 'queued' | 'started' | 'progress' | 'completed' | 'failed' | 'paused' | 'cancelled';
|
||||
type:
|
||||
| 'queued'
|
||||
| 'started'
|
||||
| 'progress'
|
||||
| 'completed'
|
||||
| 'failed'
|
||||
| 'paused'
|
||||
| 'cancelled'
|
||||
| 'waitingForNetwork';
|
||||
/** Absent on 'waitingForNetwork', which is queue-wide rather than per-download. */
|
||||
downloadId: number;
|
||||
itemId: string;
|
||||
bytesDownloaded?: number;
|
||||
@@ -64,6 +73,15 @@ interface DownloadsState {
|
||||
stats: DownloadStats;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the download queue is held because "WiFi Only" is enabled and the
|
||||
* device is on a metered/cellular network. Pending rows stay pending; the queue
|
||||
* resumes automatically when an acceptable network appears.
|
||||
*
|
||||
* TRACES: UR-053 | DR-074
|
||||
*/
|
||||
export const waitingForNetwork = writable(false);
|
||||
|
||||
function createDownloadsStore() {
|
||||
const { subscribe, update, set } = writable<DownloadsState>({
|
||||
downloads: {},
|
||||
@@ -607,6 +625,17 @@ function handleDownloadEvent(payload: DownloadEvent): void {
|
||||
case 'cancelled':
|
||||
removeDownloadFromStore(payload.downloadId);
|
||||
break;
|
||||
|
||||
case 'waitingForNetwork':
|
||||
// Queue-wide, not tied to one download: the pump refused to start
|
||||
// anything because WiFi-only is on and we're on a metered network.
|
||||
waitingForNetwork.set(true);
|
||||
break;
|
||||
}
|
||||
|
||||
// Any per-download progress proves the gate isn't holding us any more.
|
||||
if (payload.type === 'started' || payload.type === 'progress') {
|
||||
waitingForNetwork.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user