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
+29
View File
@@ -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");
+30 -1
View File
@@ -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);
}
}