📥 System Design Medium 2025–26

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.

Understanding the Problem

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.

📌Pattern: Resumable Chunked Download

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)
The Set Up
Which download mechanism?
OptionApproachProsCons
AAndroid DownloadManagerZero boilerplate, system-managed, survives process deathNo chunk progress callbacks, can’t pause/resume programmatically
BOkHttp + Foreground ServiceFull control over progress, headers, retryMust manage Foreground Service lifecycle manually; ANR risk
C ✓OkHttp + WorkManagerWorkManager handles process death, constraints, retry backoff. OkHttp gives byte-level progress. Room persists state.Overhead for very short downloads — acceptable
Download state machine
QUEUED RUNNING PAUSED RUNNING COMPLETED
RUNNING FAILED→ (retry) RUNNING  |  RUNNING CANCELLED

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.

High-Level Design
Architecture Overview — File Download Manager
UI LAYER DownloadListFragment DownloadViewModel NotifManager ProgressBroadcast FileOpener DOMAIN LAYER DownloadRepository QueueManager ChecksumVerifier ConstraintChecker INFRASTRUCTURE LAYER DownloadWorker OkHttpClient DownloadDao (Room) ScopedStorage Prefs ANDROID PLATFORM WorkManager ConnectivityManager MediaStore / SAF NotificationMgr BroadcastRx
📋
DownloadRepository
Single source of truth. Exposes Flow<List<DownloadTask>> from Room. Enqueues WorkManager jobs. Writes state transitions atomically.
⚙️
DownloadWorker
CoroutineWorker that opens an OkHttp Range request, streams bytes to disk in 8 KB chunks, throttle-updates progress to Room every 250 ms.
🗄️
Room (DownloadTask)
Persists every download: URL, filePath, bytesDownloaded, totalBytes, status, etag, checksum. Survives process death — worker reads this on restart.
🔒
QueueManager
Enforces max concurrency (default 3). Holds a Semaphore(3) that workers acquire before streaming bytes. QUEUED items wait without consuming CPU.
📁
ScopedStorage Helper
Android 10+: creates a pending MediaStore entry, writes to the ParcelFileDescriptor. Pre-10: writes to getExternalFilesDir().
🔔
NotificationManager
Persistent progress notification with Pause / Cancel actions. Updates via setProgress() from setForeground(). Completion shows “Open” action.
Low-Level Design
Room entity & DAO
@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)
}
DownloadWorker — the core streaming loop
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
        }
    }
}
QueueManager & WorkManager enqueue
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)
}
LLD Whiteboard — Enqueue & Download flow
Flow 1 — Enqueue & Start Download
UI Layer ViewModel / Repository DownloadWorker Network / OkHttp Room / Storage
1.User taps Download on a file cardViewModel calls repository.enqueue(url, name)
2.Repository checks findByUrl() — not a duplicate; inserts DownloadTask(QUEUED)Room INSERT
RecyclerView shows QUEUED row via Flow observer3.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 urlstatus → RUNNING
6.Streams 8 KB chunks; every 250 ms calls updateProgress()Server streams bodyupdateProgress(bytes)
Progress bar animates as Flow emits new bytesDownloaded
Flow 2 — Pause, Kill, & Resume
UI Layer ViewModel / Repository DownloadWorker System / WorkManager Room
1.User taps PauseCalls dao.updateStatus(PAUSED) then cancelUniqueWork(taskId)status → PAUSED
2.isStopped → true; worker breaks loop, writes final bytesDownloaded, releases semaphoreWM stops the coroutineupdateProgress(written, PAUSED)
Row shows PAUSED chip, resume button
[App killed]Room persists PAUSED + bytesDownloadedData intact on disk
User taps Resume after relaunch3.Re-enqueues enqueueUniqueWork(REPLACE, taskId)
4.New worker reads bytesDownloaded=4194304; sends Range: bytes=4194304-
Server responds 206 Partial Content; worker appends to existing fileProgress resumes from offset
Flow 3 — Completion & File Open
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
Deep Dives
Scoped Storage — Android 10+ correctness
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)
        }
    }
}
OkHttp + WorkManager vs Android DownloadManager
FeatureAndroid DownloadManagerOkHttp + 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)
Real-Time Deep Dive: Background Progress Sync

The UI needs live progress whether the app is foregrounded or backgrounded. There are three propagation paths and all three must work simultaneously.

Progress Propagation — Foreground vs Background
DownloadWorker every 250 ms Room DB updateProgress() Notification setProgress(pct) ViewModel StateFlow collector Fragment / UI repeatOnLifecycle DB write setForeground() Flow emit submitList() ↑ APP FOREGROUNDED ↓ APP BACKGROUNDED

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.

ScenarioWhich path firesLatency
App foregrounded, download activeRoom Flow → ViewModel → UI<250 ms
App backgrounded, download activesetForeground notification<250 ms
App killed, download activeNotification only (no UI process)<250 ms
App relaunched after download completesRoom Flow on STARTED resume~0 ms (from DB)
Real-Time Deep Dive: Network Switch Mid-Download

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.

Network Switch — Two Paths
60% on Wi-Fi Network drop Path A: wifiOnly = true 1. WM constraint violated → stops worker 2. isStopped=true → writes PAUSED + offset 3. Wi-Fi reconnects → WM auto-re-enqueues 4. New worker reads offset → Range resume Path B: wifiOnly = false 1. OkHttp socket exception on next read() 2. Worker catches IOException → Result.retry() 3. WM exponential backoff (10s, 20s, 40s...) 4. On reconnect: retry fires, Range resumes
// 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.

Real-Time Deep Dive: Parallel Chunk Download with Merged Progress

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.

Parallel Chunk Download — Fan-Out & Merge
DownloadWorker async { awaitAll(...) } Chunk 0 bytes=0–4999999 Chunk 1 bytes=5M–9999999 Chunk 2 bytes=10M–14999999 Chunk 3 bytes=15M–end download_chunks (Room) (taskId, chunkIdx, startByte, endByte, bytesWritten) Total progress = SUM(bytesWritten) / totalFileSize Merged every 250ms — emitted via Flow to ViewModel
// 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.

ApproachSpeed gain (fast Wi-Fi)ComplexityWhen to use
Single streamBaselineLowDefault for all downloads
4 parallel chunks3–5× fasterMediumFiles >50 MB, user-initiated downloads
8+ parallel chunksDiminishing returnsHighRarely worth it; server rate-limiting kicks in
Expected at Each Level
🔵 Mid-Level
  • 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
🟢 Senior
  • Implements Range header resumable downloads from Room offset
  • Uses IS_PENDING=1 pattern 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
🟣 Staff+
  • 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)
Interview Questions
Q1 Why use WorkManager instead of a Foreground Service for downloads?
Easy

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.

Q2 How does Range-header resumable download work?
Easy

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.

Q3 What is Scoped Storage and how does it affect file writing?
Medium

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+.

Q4 How do you prevent duplicate downloads for the same URL?
Easy

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.

Q5 How do you limit concurrent downloads to a maximum of N?
Medium

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.

Q6 Why throttle progress updates to every 250 ms?
Easy

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.

Q7 What is IS_PENDING in MediaStore and why does it matter?
Medium

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.

Q8 How does checksum verification work after a download?
Medium

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.

Q9 What does ExistingWorkPolicy.KEEP vs REPLACE do?
Easy

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.

Q10 How does a user-tapped Cancel differ from a Pause?
Medium

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.

Q11 How does the UI stay in sync with background worker progress?
Medium

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.

Q12 How do you open a downloaded file from the app?
Easy

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.

Q13 How do you handle a network switch mid-download (Wi-Fi → cellular)?
Medium

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.

Q14 What is an ETag and how do you use it in downloads?
Hard

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.

Q15 How would you implement parallel chunked downloading to speed up large files?
Hard

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.

Q16 How do you notify the user when all downloads are complete?
Easy

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.

Q17 How do you handle a 4 GB file without running out of memory?
Medium

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 bytesDownloadedInt overflows at ~2 GB.

Q18 How would you add download priority so users can bump a download to the front?
Hard

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.

Q19 How do you test the DownloadWorker in isolation?
Medium

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.

Q20 How would you implement automatic storage cleanup when disk is low?
Hard

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.

Q21 How does WorkManager propagate download progress to the UI when the app is backgrounded?
Medium

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.

Q22 What happens to an active download when the device switches from Wi-Fi to cellular mid-stream?
Hard

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.

Q23 How would you merge real-time progress from N concurrent chunk download streams into a single progress bar?
Hard

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.

Q24 How do you recover a download after process death without re-downloading bytes already written?
Medium

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.

Q25 How do you handle an ETag change between pause and resume — the server file changed?
Hard

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.