Design a File Download Manager
A deep-dive Android system design of a file download manager — think a documents app, podcast app, or browser downloads. Covers resumable chunked downloads with Range headers, WorkManager process-death survival, Scoped Storage, real-time progress sync, and complex scenarios like mid-download network switches and parallel chunk merging.
Design the Android client for a file download manager — think a documents app, a podcast app downloading episodes, or a browser downloading PDFs. The core challenge is downloading potentially large files reliably in the background, surviving process death, supporting pause/resume, showing real-time progress, and writing to the correct storage location without breaking Android’s scoped storage rules.
The defining technique is Range-header resumable downloading: the server signals Accept-Ranges: bytes, the client records the last byte written to disk, and on reconnect sends Range: bytes=<offset>- to fetch only the remaining bytes. Combined with a Room-tracked DownloadTask and a WorkManager background worker, downloads survive app kills, network switches, and device reboots.
✅ Functional Requirements
- Enqueue files by URL with a user-visible display name
- Download runs in the background — survives app kill
- Pause, resume, and cancel individual downloads
- Real-time progress bar in notification and UI
- Show download history: completed, failed, cancelled
- Open a downloaded file from the history list
- File integrity verification (checksum on completion)
⛔ Non-Functional Requirements
- Max N concurrent downloads (configurable, default 3)
- Retry failed downloads with backoff (network errors)
- Only download on unmetered Wi-Fi if user toggles that
- No duplicate downloads for the same URL
- Progress updates at most every 250 ms (no UI thrashing)
- Write to Scoped Storage / MediaStore correctly
- Handle files up to 4 GB (multi-gigabyte streaming)
| Option | Approach | Pros | Cons |
|---|---|---|---|
| A | Android DownloadManager | Zero boilerplate, system-managed, survives process death | No chunk progress callbacks, can’t pause/resume programmatically |
| B | OkHttp + Foreground Service | Full control over progress, headers, retry | Must manage Foreground Service lifecycle manually; ANR risk |
| C ✓ | OkHttp + WorkManager | WorkManager handles process death, constraints, retry backoff. OkHttp gives byte-level progress. Room persists state. | Overhead for very short downloads — acceptable |
Every state transition is written atomically to Room before the WorkManager job acts. If the process dies mid-download, the worker restarts and reads the last known bytesDownloaded from the DB to issue a Range header — picking up exactly where it left off.
Flow<List<DownloadTask>> from Room. Enqueues WorkManager jobs. Writes state transitions atomically.Semaphore(3) that workers acquire before streaming bytes. QUEUED items wait without consuming CPU.getExternalFilesDir().setProgress() from setForeground(). Completion shows “Open” action.@Entity(tableName = "download_tasks") data class DownloadTask( @PrimaryKey val id: String = UUID.randomUUID().toString(), val url: String, val displayName: String, val mimeType: String, val filePath: String?, // null until first byte written val bytesDownloaded: Long = 0L, val totalBytes: Long = -1L, // -1 = unknown (no Content-Length) val status: DownloadStatus = DownloadStatus.QUEUED, val etag: String? = null, // detect server file changes on resume val expectedChecksum: String? = null, val priority: Int = 0, // higher = dequeued first val wifiOnly: Boolean = false ) enum class DownloadStatus { QUEUED, RUNNING, PAUSED, COMPLETED, FAILED, CANCELLED } @Dao interface DownloadDao { @Query("SELECT * FROM download_tasks ORDER BY priority DESC, createdAt ASC") fun observeAll(): Flow<List<DownloadTask>> @Query("SELECT * FROM download_tasks WHERE url = :url LIMIT 1") suspend fun findByUrl(url: String): DownloadTask? // dedup check @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insert(task: DownloadTask) @Query("UPDATE download_tasks SET bytesDownloaded=:bytes, status=:status WHERE id=:id") suspend fun updateProgress(id: String, bytes: Long, status: DownloadStatus) @Query("UPDATE download_tasks SET status=:status, errorMessage=:err WHERE id=:id") suspend fun updateStatus(id: String, status: DownloadStatus, err: String? = null) }
class DownloadWorker(context: Context, params: WorkerParameters, private val dao: DownloadDao, private val okHttp: OkHttpClient, private val storageHelper: ScopedStorageHelper, private val queueMgr: QueueManager ) : CoroutineWorker(context, params) { override suspend fun doWork(): Result { val taskId = inputData.getString("TASK_ID") ?: return Result.failure() val task = dao.getById(taskId) ?: return Result.failure() if (task.status == DownloadStatus.CANCELLED) return Result.success() queueMgr.acquire() // suspends if 3 already running try { setForeground(buildForegroundInfo(task, 0)) dao.updateStatus(taskId, DownloadStatus.RUNNING) downloadFile(task) } finally { queueMgr.release() } return Result.success() } private suspend fun downloadFile(task: DownloadTask) { val resumeFrom = task.bytesDownloaded val request = Request.Builder().url(task.url).apply { if (resumeFrom > 0) header("Range", "bytes=$resumeFrom-") task.etag?.let { header("If-Range", it) } // restart if server file changed }.build() okHttp.newCall(request).await().use { response -> val startOffset = if (response.code == 206) resumeFrom else 0L val source = response.body.source() val sink = storageHelper.openOutputStream(task, append = startOffset > 0).sink().buffer() var written = startOffset; var lastUpdate = 0L while (!isStopped) { val read = source.read(ByteArray(8192)) if (read == -1) break sink.write(buf, 0, read); written += read val now = System.currentTimeMillis() if (now - lastUpdate >= 250) { // throttle: 4 updates/sec max sink.flush() dao.updateProgress(task.id, written, DownloadStatus.RUNNING) setForeground(buildForegroundInfo(task, (written * 100 / totalBytes).toInt())) lastUpdate = now } } if (isStopped) { dao.updateProgress(task.id, written, DownloadStatus.PAUSED); return } // Checksum verify, then mark complete task.expectedChecksum?.let { if (storageHelper.sha256(task) != it) { dao.updateStatus(task.id, DownloadStatus.FAILED, "Checksum mismatch"); return } } dao.updateStatus(task.id, DownloadStatus.COMPLETED) storageHelper.markComplete(task) // clears MediaStore IS_PENDING } } }
class QueueManager(private val maxConcurrent: Int = 3) { private val semaphore = Semaphore(maxConcurrent) suspend fun acquire() = semaphore.acquire() fun release() = semaphore.release() } fun enqueue(task: DownloadTask) { val constraints = Constraints.Builder() .setRequiredNetworkType(if (task.wifiOnly) NetworkType.UNMETERED else NetworkType.CONNECTED) .build() val req = OneTimeWorkRequestBuilder<DownloadWorker>() .setInputData(workDataOf("TASK_ID" to task.id)) .setConstraints(constraints) .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 10, TimeUnit.SECONDS) .addTag(task.id).build() // KEEP = don’t start a second worker for the same download (dedup) workManager.enqueueUniqueWork(task.id, ExistingWorkPolicy.KEEP, req) }
| UI Layer | ViewModel / Repository | DownloadWorker | Network / OkHttp | Room / Storage |
|---|---|---|---|---|
| 1.User taps Download on a file card | ViewModel calls repository.enqueue(url, name) | — | — | — |
| — | 2.Repository checks findByUrl() — not a duplicate; inserts DownloadTask(QUEUED) | — | — | Room INSERT |
| RecyclerView shows QUEUED row via Flow observer | 3.Repository calls workManager.enqueueUniqueWork(KEEP) | — | — | — |
| — | — | 4.Worker calls queueManager.acquire() — blocks if 3 running | — | — |
| — | — | 5.setForeground() → notification. Updates Room to RUNNING. | OkHttp opens connection, sends GET url | status → RUNNING |
| — | — | 6.Streams 8 KB chunks; every 250 ms calls updateProgress() | Server streams body | updateProgress(bytes) |
| Progress bar animates as Flow emits new bytesDownloaded | — | — | — | — |
| UI Layer | ViewModel / Repository | DownloadWorker | System / WorkManager | Room |
|---|---|---|---|---|
| 1.User taps Pause | Calls dao.updateStatus(PAUSED) then cancelUniqueWork(taskId) | — | — | status → PAUSED |
| — | — | 2.isStopped → true; worker breaks loop, writes final bytesDownloaded, releases semaphore | WM stops the coroutine | updateProgress(written, PAUSED) |
| Row shows PAUSED chip, resume button | — | — | — | — |
| [App killed] | — | — | Room persists PAUSED + bytesDownloaded | Data intact on disk |
| User taps Resume after relaunch | 3.Re-enqueues enqueueUniqueWork(REPLACE, taskId) | — | — | — |
| — | — | 4.New worker reads bytesDownloaded=4194304; sends Range: bytes=4194304- | — | — |
| — | — | Server responds 206 Partial Content; worker appends to existing file | — | Progress resumes from offset |
| UI Layer | DownloadWorker | ChecksumVerifier | ScopedStorage | Room / Notification |
|---|---|---|---|---|
| — | 1.Chunk loop exits (read == -1). All bytes written. | — | — | — |
| — | 2.Calls checksumVerifier.sha256(filePath) | Streams file through MessageDigest, returns hex string | — | — |
| — | Mismatch → FAILED, deletes file. Match → continues. | — | — | status → FAILED or continues |
| — | 3.Calls storageHelper.markComplete(task) | — | Clears IS_PENDING=0 — file now visible in Files app | — |
| — | 4.Calls dao.updateStatus(COMPLETED) | — | — | status → COMPLETED. Notification: “Tap to open” |
| Row → COMPLETED, “Open” button appears | — | — | — | — |
5.User taps Open → ViewModel calls fileOpener.open(task) | — | — | Resolves content:// URI via FileProvider, fires ACTION_VIEW | — |
class ScopedStorageHelper(private val context: Context) { fun openOutputStream(task: DownloadTask, append: Boolean): OutputStream { return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { val resolver = context.contentResolver val uri = task.filePath?.let { Uri.parse(it) } ?: createPendingEntry(task, resolver) resolver.openOutputStream(uri, if (append) "wa" else "w")!! } else { File(context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS)!!, task.displayName).outputStream() } } private fun createPendingEntry(task: DownloadTask, resolver: ContentResolver): Uri { val values = ContentValues().apply { put(MediaStore.Downloads.DISPLAY_NAME, task.displayName) put(MediaStore.Downloads.MIME_TYPE, task.mimeType) put(MediaStore.Downloads.IS_PENDING, 1) // invisible until done } return resolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, values)!! } fun markComplete(task: DownloadTask) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { context.contentResolver.update(Uri.parse(task.filePath), ContentValues().apply { put(MediaStore.Downloads.IS_PENDING, 0) }, null, null) } } }
| Feature | Android DownloadManager | OkHttp + WorkManager ✓ |
|---|---|---|
| Byte-level progress callbacks | ✗ Polling only | ✓ 8 KB granularity |
| Pause / Resume | ✗ No programmatic pause | ✓ WM cancel + Range header resume |
| Custom retry logic | ✗ Fixed system behavior | ✓ BackoffPolicy.EXPONENTIAL |
| Wi-Fi only constraint | ✓ Built-in | ✓ NetworkType.UNMETERED |
| Checksum verification | ✗ Not supported | ✓ SHA-256 after completion |
| Testability | ✗ System service, hard to mock | ✓ Inject OkHttp, fake DAO |
| Process death recovery | ✓ System-managed | ✓ WorkManager + Room state |
| Concurrent download limit | ✗ Uncontrolled | ✓ Semaphore(N) |
The UI needs live progress whether the app is foregrounded or backgrounded. There are three propagation paths and all three must work simultaneously.
Path 1 — Room Flow (foreground): The worker calls dao.updateProgress() every 250 ms. Room’s reactive Flow emits the new list to the ViewModel’s StateFlow. The Fragment collects in repeatOnLifecycle(STARTED) — which automatically stops collecting when the app is backgrounded and resumes when it returns. No polling, no broadcast needed.
Path 2 — setForeground notification (background): The worker calls setForeground(buildForegroundInfo(task, pct)) inside the same 250 ms throttle window. This posts a persistent NotificationCompat with setProgress(100, pct, false). The OS renders this progress bar in the status bar even while the app is fully killed. This is the user’s only real-time signal when backgrounded.
Path 3 — onResume re-sync (catch-all): When the user foregrounds the app after a long pause, the Fragment’s repeatOnLifecycle(STARTED) collector resumes and immediately receives the current Room state — no explicit refresh needed. If the download finished while backgrounded, the UI shows COMPLETED status instantly on resume.
| Scenario | Which path fires | Latency |
|---|---|---|
| App foregrounded, download active | Room Flow → ViewModel → UI | <250 ms |
| App backgrounded, download active | setForeground notification | <250 ms |
| App killed, download active | Notification only (no UI process) | <250 ms |
| App relaunched after download completes | Room Flow on STARTED resume | ~0 ms (from DB) |
What happens when the device switches from Wi-Fi to cellular (or loses connectivity entirely) while a 2 GB download is at 60%? This is one of the most common failure modes and there are two distinct behaviours depending on the task’s wifiOnly flag.
// In downloadFile() — handling IOException for wifiOnly=false path try { okHttp.newCall(request).await().use { response -> streamBody(response, task) } } catch (e: IOException) { // Persist progress written so far so Range resume works on retry dao.updateProgress(task.id, written, DownloadStatus.RUNNING) throw e // WorkManager catches it, marks worker FAILED, schedules retry // setBackoffCriteria(EXPONENTIAL, 10s) applies automatically } // For wifiOnly=true: WorkManager sees constraint violated and stops the worker. // The isStopped branch handles persistence: if (isStopped) { dao.updateProgress(task.id, written, DownloadStatus.PAUSED) return // WorkManager will re-enqueue when Wi-Fi constraint is satisfied again }
ETag guard on resume: After any network interruption, the file on the server may have been updated. The worker always sends If-Range: <etag> on resume. If the server replies 200 OK (not 206), it means the file changed — the worker discards the partial file, resets bytesDownloaded=0, and starts fresh. Without this guard, you could corrupt a file by appending bytes from a new version to an old partial.
For large files on fast connections, you can split the file into N equal byte ranges and download them simultaneously, then merge. The real challenge is merging per-chunk progress into a single accurate progress bar in real time.
// Room entity for chunk-level tracking @Entity(tableName = "download_chunks") data class DownloadChunk( @PrimaryKey val id: String, // "$taskId-$chunkIndex" val taskId: String, val chunkIndex: Int, val startByte: Long, val endByte: Long, val bytesWritten: Long = 0L, val done: Boolean = false ) // Inside DownloadWorker — parallel chunk download private suspend fun downloadParallel(task: DownloadTask, totalBytes: Long) { val chunkSize = totalBytes / N_CHUNKS val chunks = (0 until N_CHUNKS).map { i -> val start = i * chunkSize val end = if (i == N_CHUNKS - 1) totalBytes - 1 else (i + 1) * chunkSize - 1 chunkDao.insert(DownloadChunk("${task.id}-$i", task.id, i, start, end)) async { downloadChunk(task, i, start, end) } } chunks.awaitAll() // all 4 streams run concurrently mergeChunkFiles(task) // sequential merge into final file, then delete temps } // Merged progress = sum of all chunk bytesWritten @Query("SELECT SUM(bytesWritten) FROM download_chunks WHERE taskId = :id") fun observeTotalWritten(id: String): Flow<Long>
Resume per-chunk: When a parallel download is interrupted, you resume only the incomplete chunks. Each chunk reads its bytesWritten from the download_chunks table and issues Range: bytes=<start+bytesWritten>-<end>. Completed chunks (done=true) are skipped. This means a 4-chunk download that was 75% done across all chunks only re-downloads the missing 25% — correctly distributed across chunks.
| Approach | Speed gain (fast Wi-Fi) | Complexity | When to use |
|---|---|---|---|
| Single stream | Baseline | Low | Default for all downloads |
| 4 parallel chunks | 3–5× faster | Medium | Files >50 MB, user-initiated downloads |
| 8+ parallel chunks | Diminishing returns | High | Rarely worth it; server rate-limiting kicks in |
- Knows Android DownloadManager exists and when to use it
- Uses WorkManager for background safety
- Can implement a basic progress notification
- Understands the need for Scoped Storage on Android 10+
- Stores download state in Room so it survives restart
- Handles CANCELLED vs FAILED status separately
- Implements Range header resumable downloads from Room offset
- Uses
IS_PENDING=1pattern to hide partial files in MediaStore - Throttles DB writes to 250 ms to prevent UI thrashing
- Designs Semaphore-based QueueManager for concurrency cap
- Implements SHA-256 checksum verification post-download
- Deduplicates via
findByUrl()before enqueuing - Uses ETag to detect server-side file changes mid-resume
- Explains three progress paths: Room Flow, notification, onResume
- Designs parallel chunk download (split into N ranges, merge)
- Per-chunk resume: only re-download incomplete chunk offsets
- Priority queue: users can bump a download to the front
- Per-download bandwidth throttling via token bucket
- Analytics: speed histogram, failure rate by network type
- Clean-up policy: evict oldest completed downloads when storage low
- Multi-process safety: if UI and worker process both write Room
- Delta-sync for frequently updated files (partial content updates)
WorkManager handles process death, OS-imposed constraints (network type, battery), and automatic retries — all for free. A Foreground Service requires you to manually handle START_STICKY, re-post the notification after process death, and implement your own retry backoff. For long-running background work like downloads, WorkManager is the Android-recommended solution since API 14. The CoroutineWorker subclass integrates with structured concurrency natively.
When a download is paused, you persist bytesDownloaded to Room. On resume, you read that value and send the HTTP header Range: bytes=<offset>-. If the server supports byte ranges (it advertises Accept-Ranges: bytes), it responds with 206 Partial Content and streams only the remaining bytes. You open the output file in append mode ("wa") so existing bytes are preserved. If the server responds 200 instead, it doesn’t support Range — restart from zero.
Since Android 10, apps can no longer freely write to arbitrary paths on external storage. For files meant to be shared (e.g., downloaded PDFs visible in Files app), use MediaStore — insert a ContentValues entry into MediaStore.Downloads, get a Uri, write via ContentResolver.openOutputStream(uri). Set IS_PENDING=1 while downloading so the file is hidden; clear it on completion. For app-private files, use getExternalFilesDir() — no special permission needed. Never use raw File paths to /sdcard/ on Android 10+.
Two layers: (1) In the Repository, before inserting call dao.findByUrl(url) — if a row already exists and is not CANCELLED/COMPLETED, return early. (2) When enqueuing WorkManager use enqueueUniqueWork(taskId, ExistingWorkPolicy.KEEP, ...) — if a work request with the same name is already queued or running, WorkManager ignores the new enqueue. These two checks together prevent both DB-level and WorkManager-level duplicates.
Use a Kotlin Semaphore(N) in a singleton QueueManager. At the top of DownloadWorker.doWork(), call semaphore.acquire() — this is a suspending call, so the coroutine blocks without consuming a thread if all N slots are taken. In the finally block, call semaphore.release(). Workers above the limit stay parked at the suspension point.
Without throttling, a fast connection can write 8 KB chunks thousands of times per second, each triggering a Room write + notification update + UI recomposition. Room writes to WAL on disk — doing thousands per second causes heavy I/O, battery drain, and UI jank. 250 ms is a sweet spot: the progress bar updates about 4 times per second which feels smooth to users, while DB writes stay well under 10/second.
IS_PENDING=1 marks a MediaStore entry as incomplete. While pending, the file is invisible to other apps and the system Files browser — users won’t see a half-downloaded, corrupt file. When the download completes (or fails and needs cleanup), you call ContentResolver.update(uri, { IS_PENDING=0 }, null, null) to publish it. If your app crashes without clearing IS_PENDING, the system automatically clears it after a timeout.
The server provides the expected SHA-256 hash. After writing all bytes, open the downloaded file as an InputStream, stream it through MessageDigest.getInstance("SHA-256") in 8 KB chunks, convert to hex string. Compare against task.expectedChecksum. On mismatch: update status to FAILED with "Checksum mismatch", delete the corrupt file via MediaStore, and optionally re-enqueue. Never expose a checksum-failed file to the user.
KEEP: if a work request with the same unique name is already queued or running, ignore the new request. Best for new downloads — prevents duplicates. REPLACE: cancel the existing work and schedule the new request. Best for resuming — you want to start a fresh worker from the current DB state, replacing any stale interrupted worker. Use KEEP on enqueue, REPLACE on explicit resume.
Pause sets status to PAUSED in Room and cancels the WorkManager job — the partial file on disk is kept because bytesDownloaded is persisted. Cancel sets status to CANCELLED, cancels the WM job, and additionally deletes the partial file from MediaStore/storage. In the worker’s isStopped check you distinguish by re-reading the DB status: if PAUSED, persist progress and return. If CANCELLED, delete the file and return.
Room’s @Query methods returning Flow automatically emit a new list whenever any row changes. The worker updates bytesDownloaded every 250 ms. The ViewModel collects this Flow and exposes it as StateFlow<List<DownloadTask>>. The Fragment collects in repeatOnLifecycle(STARTED). DiffUtil only rebinds the specific item whose progress changed. No polling, no BroadcastReceiver needed.
For files written via MediaStore: use the stored content:// URI. Fire Intent(Intent.ACTION_VIEW).apply { setDataAndType(uri, mimeType); addFlags(FLAG_GRANT_READ_URI_PERMISSION) }. For app-private files (getExternalFilesDir): wrap with FileProvider to generate a content:// URI — never pass a raw file:// URI on Android 7+ as it throws FileUriExposedException.
If the task has wifiOnly=true, the WorkManager constraint is NetworkType.UNMETERED. When the device switches to cellular, WorkManager automatically stops the worker — isStopped becomes true, the worker persists bytesDownloaded and exits PAUSED. When Wi-Fi reconnects, WorkManager re-queues automatically. For wifiOnly=false, a switch causes a brief OkHttp socket exception, which triggers WorkManager’s exponential backoff retry — and the next run issues a Range resume.
An ETag is a server-generated identifier for a specific version of a resource. On the first request, save the response ETag header to the Room task. On a Range-resume, send If-Range: <etag>. If the file hasn’t changed, the server sends 206 Partial Content — safe to append. If the file changed, it sends 200 OK with the full new file — discard the partial file and restart. Without ETag, you could corrupt a file by appending new-version bytes to old-version bytes.
Split the file into N equal byte ranges using Content-Length. Launch N parallel OkHttp requests, each with a different Range header. Write each chunk to a separate temp file. When all chunks complete via async + awaitAll, merge them sequentially. Track chunk progress in a DownloadChunk table — merged progress = SUM(bytesWritten) / totalBytes. Resume works per-chunk: only re-download incomplete ranges. Typical speedup: 3–5× on fast connections.
After dao.updateStatus(COMPLETED), update the existing notification: setContentText("Download complete"), clear setOngoing(false), add a tap PendingIntent for ACTION_VIEW on the file URI. Use a distinct notification ID per download (NOTIF_BASE + taskId.hashCode()). For batch completion, use the notification grouping API (setGroup + setGroupSummary) to collapse multiple done notifications into one summary.
Never load the response body into memory at once. OkHttp’s ResponseBody.source() returns an okio.Source — a streaming interface. Read into a fixed-size ByteArray(8192) buffer in a loop. The JVM heap only ever holds 8 KB at a time regardless of file size. Similarly for checksum: stream through MessageDigest in 8 KB chunks. Use Long (not Int) for bytesDownloaded — Int overflows at ~2 GB.
Store a priority: Int on DownloadTask. The QueueManager, instead of a plain Semaphore, uses a priority-aware dispatcher: when acquire() is called, add the coroutine’s continuation to a PriorityQueue; in release(), resume the highest-priority waiting continuation. On priority bump: update Room, then adjust position in the queue. WorkManager itself has no native priority — the priority lives entirely in the QueueManager’s in-process queue.
Use TestListenableWorkerBuilder from work-testing. Inject a fake OkHttpClient that returns a pre-built MockResponse via MockWebServer. Inject an in-memory Room database. Call worker.doWork() in runTest and assert Room rows transition RUNNING → COMPLETED. For resume: pre-seed Room with bytesDownloaded=500, assert OkHttp received Range: bytes=500-. For checksum: provide a deliberate mismatch and assert status = FAILED.
Register a PeriodicWorkRequest for a StorageCleanupWorker that runs daily. The worker checks StatFs.availableBytes — if below a threshold (e.g., 500 MB), query Room for COMPLETED downloads ordered by completedAt ASC (oldest first), delete files via MediaStore and remove their Room rows until enough space is freed. Alternatively, respond to ACTION_DEVICE_STORAGE_LOW broadcast. Never delete RUNNING or PAUSED tasks.
Two parallel paths. (1) Notification: inside the worker, setForeground(ForegroundInfo(id, notification)) posts a persistent notification with setProgress(100, pct, false) — visible even when the app is killed. The notification is the only real-time signal when the app process doesn’t exist. (2) Room Flow: the worker writes bytesDownloaded every 250 ms. When the user foregrounds the app, repeatOnLifecycle(STARTED) resumes the collector and immediately gets the latest DB state — no polling or explicit refresh needed.
Two paths depending on wifiOnly. (A) wifiOnly=true: WorkManager’s NetworkType.UNMETERED constraint is violated — WM stops the worker automatically (isStopped=true). The worker writes PAUSED + current offset to Room and exits. When Wi-Fi reconnects, WM re-enqueues and a new worker issues a Range: bytes=<offset>- resume. (B) wifiOnly=false: the OkHttp stream throws IOException when the socket drops. The worker catches it, persists current progress, and throws it — WM marks the work FAILED and schedules an exponential-backoff retry (10s, 20s, 40s…). The next retry issues a Range resume from the persisted offset.
Each chunk coroutine writes its own bytesWritten to a DownloadChunk Room table every 250 ms. The ViewModel observes a query: SELECT SUM(bytesWritten) FROM download_chunks WHERE taskId=? as a Flow<Long>. Room emits a new value whenever any chunk row changes, so the ViewModel always has the real-time total bytes written across all chunks. Progress percentage = totalWritten / totalFileSize. The ViewModel merges this with the existing DownloadTask Flow using combine() to drive a single progress bar in the UI.
WorkManager automatically re-schedules a failed/interrupted worker on process restart. The new worker’s doWork() reads the DownloadTask from Room by taskId (passed via inputData). It reads task.bytesDownloaded (persisted by the previous worker on its last 250 ms tick) and sends Range: bytes=<bytesDownloaded>- in the OkHttp request. The output file is opened in append mode ("wa"). The If-Range: <etag> header guards against server-side file changes. If the server responds 206, the resume is valid. If it responds 200, the file changed — restart from 0.
On the first download, save the response ETag header to DownloadTask.etag in Room. On every resume, send If-Range: <etag> alongside the Range header. The HTTP spec defines the semantics: if the ETag matches, the server sends 206 Partial Content — safe to append. If the ETag doesn’t match (file changed), the server sends 200 OK with the full new file — the client must reset bytesDownloaded=0, delete the partial file from MediaStore, and start writing from offset 0. Without this guard, you’d silently corrupt the file by appending bytes from the new file version onto bytes from the old version.