Design a File Download Manager

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

Learn This Pattern →

✅ 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)

2. The Set Up

Approach comparison: which download mechanism?

Option A
Android DownloadManager

✅ Zero boilerplate, system-managed, survives process death, handles Scoped Storage automatically

❌ No chunk-level progress callbacks, can't pause/resume programmatically, limited retry logic, hard to test

Option B
OkHttp + Foreground Service

✅ Full control over progress, headers, retry; can implement chunked parallel downloads

❌ Must manage Foreground Service lifecycle manually; battery & ANR risk; complex process-death handling

Option C — Chosen ✓
OkHttp + WorkManager

✅ WorkManager handles process death, constraints (Wi-Fi, battery), retry backoff. OkHttp gives byte-level progress. Room persists state. Best of all worlds.

⚠ WorkManager has overhead for very short downloads — acceptable trade-off

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. This means 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.

3. 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(). Returns a stable content:// URI.
🔔
NotificationManager
Persistent progress notification with Pause / Cancel actions. Updates via setProgress() from setForeground() inside the worker. Completion shows "Open" action.

4. 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
    val expectedChecksum: String? = null,
    val errorMessage: String? = null,
    val createdAt: Long = System.currentTimeMillis(),
    val completedAt: Long? = 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 id = :id")
    suspend fun getById(id: String): 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()

        // Honour pause/cancel set before worker started
        if (task.status == DownloadStatus.CANCELLED) return Result.success()
        if (task.status == DownloadStatus.PAUSED)    return Result.success()

        // Acquire concurrency slot — suspends if 3 already running
        queueMgr.acquire()
        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 {
                // Resume: server must respond 206 Partial Content
                if (resumeFrom > 0) header("Range", "bytes=$resumeFrom-")
                // ETag guard: if server file changed, restart
                task.etag?.let { header("If-Range", it) }
            }
            .build()

        okHttp.newCall(request).await().use { response ->
            if (!response.isSuccessful) {
                dao.updateStatus(task.id, DownloadStatus.FAILED, "HTTP ${response.code}")
                return
            }
            // 200 instead of 206 means server doesn't support Range — restart
            val startOffset = if (response.code == 206) resumeFrom else 0L
            val totalBytes  = response.header("Content-Length")?.toLongOrNull()
                ?.plus(startOffset) ?: -1L
            val newEtag = response.header("ETag")

            val outputStream = storageHelper.openOutputStream(task, append = startOffset > 0)
            val source = response.body!!.source()
            val sink   = outputStream.sink().buffer()
            val buf    = ByteArray(8192)
            var written = startOffset
            var lastProgressUpdate = 0L

            while (!isStopped) {             // isStopped = WorkManager cancelled
                val read = source.read(buf)
                if (read == -1) break
                sink.write(buf, 0, read)
                written += read

                // Throttle DB + notification writes to every 250 ms
                val now = System.currentTimeMillis()
                if (now - lastProgressUpdate >= 250) {
                    sink.flush()
                    dao.updateProgress(task.id, written, DownloadStatus.RUNNING)
                    val pct = if (totalBytes > 0) (written * 100 / totalBytes).toInt() else 0
                    setForeground(buildForegroundInfo(task, pct))
                    lastProgressUpdate = now
                }
            }
            sink.close()

            if (isStopped) {
                // WorkManager cancelled us — stay PAUSED so we can resume
                dao.updateProgress(task.id, written, DownloadStatus.PAUSED)
                return
            }

            // Verify checksum
            task.expectedChecksum?.let {
                val actual = storageHelper.sha256(task)
                if (actual != it) {
                    dao.updateStatus(task.id, DownloadStatus.FAILED, "Checksum mismatch")
                    storageHelper.delete(task)
                    return
                }
            }
            dao.updateStatus(task.id, DownloadStatus.COMPLETED)
            storageHelper.markComplete(task)       // clears MediaStore IS_PENDING
        }
    }
}

Concurrency: QueueManager with Semaphore

class QueueManager(private val maxConcurrent: Int = 3) {
    private val semaphore = Semaphore(maxConcurrent)

    suspend fun acquire() = semaphore.acquire()
    fun release() = semaphore.release()
}

// Enqueuing a download — one-liner, idempotent via unique work
fun enqueue(task: DownloadTask) {
    val constraints = Constraints.Builder()
        .setRequiredNetworkType(
            if (task.wifiOnly) NetworkType.UNMETERED else NetworkType.CONNECTED
        )
        .build()

    val workRequest = OneTimeWorkRequestBuilder<DownloadWorker>()
        .setInputData(workDataOf("TASK_ID" to task.id))
        .setConstraints(constraints)
        .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 10, TimeUnit.SECONDS)
        .addTag(task.id)         // tag = download ID for easy cancel
        .build()

    // KEEP = if already queued/running, don't start a second one (dedup)
    workManager.enqueueUniqueWork(task.id, ExistingWorkPolicy.KEEP, workRequest)
}

LLD Whiteboard — component wiring

LLD — Download Request Flow
UI / Fragment User taps Download DownloadViewModel enqueue(url, name) DownloadRepository insert + enqueue WM WorkManager enqueueUnique(KEEP) DownloadWorker acquire semaphore OkHttp Range Req bytes=offset- Room DownloadDao updateProgress(250ms) ScopedStorage MediaStore / FileDesc Notification setProgress(pct) Flow<List<DownloadTask>> observed by ViewModel
Flow 1 — Enqueue & Start Download
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() — returns null (not a duplicate), inserts DownloadTask(status=QUEUED) Room INSERT DownloadTask
RecyclerView shows new row with QUEUED chip via Flow observer 3.Repository calls workManager.enqueueUniqueWork(KEEP)
4.WorkManager schedules worker; worker calls queueManager.acquire() (blocks if 3 already running)
5.Worker calls setForeground() → persistent notification appears. Updates Room to RUNNING. OkHttp opens connection, sends GET url (no Range header — fresh start) updateStatus → RUNNING
6.Streams 8 KB chunks; every 250 ms flushes and calls dao.updateProgress() Server streams response body updateProgress(bytes, RUNNING)
Progress bar animates as Flow emits new bytesDownloaded
Flow 2 — Pause, Kill, & Resume
UI Layer ViewModel / Repository DownloadWorker System / WorkManager Room
1.User taps Pause (or notification Pause action) Repository calls dao.updateStatus(PAUSED) then workManager.cancelUniqueWork(taskId) status → PAUSED
2.isStopped becomes true inside the chunk loop; worker breaks out, writes final bytesDownloaded, releases semaphore WorkManager stops the worker coroutine updateProgress(written, PAUSED)
Row shows PAUSED chip, resume button
3.[App killed — process death] Room persists PAUSED + bytesDownloaded = safe Data intact on disk
User taps Resume after relaunch 4.Repository re-enqueues workManager.enqueueUniqueWork(REPLACE, taskId)
5.New worker starts; reads task.bytesDownloaded = 4,194,304 from Room; sends Range: bytes=4194304-
Server responds 206 Partial Content; worker appends to existing file updateProgress resumes from offset
Flow 3 — Completion & File Open
UI Layer DownloadWorker ChecksumVerifier ScopedStorage Room / Notification
1.Chunk loop exits (read == -1). All bytes written and flushed to disk.
2.If expectedChecksum != null: calls checksumVerifier.sha256(filePath) Opens file, streams through MessageDigest(SHA-256), returns hex string
Mismatch → updateStatus(FAILED, "Checksum"), deletes partial file. Match → continues. status → FAILED or continues
3.Calls storageHelper.markComplete(task) Clears IS_PENDING=0 on MediaStore row — file now visible in Files app
4.Calls dao.updateStatus(COMPLETED); notification updated to "Download complete — tap to open" status → COMPLETED, completedAt = now
Row chip → COMPLETED, progress = 100%, "Open" button appears
5.User taps Open → ViewModel calls fileOpener.open(task) Resolves content:// URI via FileProvider, fires ACTION_VIEW Intent with correct MIME

5. Deep Dives

Scoped Storage — Android 10+ correctness

class ScopedStorageHelper(private val context: Context) {

    /**
     * Creates a pending MediaStore entry for Android 10+.
     * IS_PENDING=1 means the file won't appear in Files app until markComplete().
     * Pre-10 fallback: getExternalFilesDir() — no permission needed.
     */
    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) }
                ?: createMediaStoreEntry(task, resolver)

            val mode = if (append) "wa" else "w"   // 'w' = write, 'a' = append
            resolver.openOutputStream(uri, mode)!!
        } else {
            val dir = context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS)!!
            File(dir, task.displayName).outputStream()   // no permission needed
        }
    }

    private fun createMediaStoreEntry(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) {
            val uri = Uri.parse(task.filePath)
            context.contentResolver.update(uri,
                ContentValues().apply { put(MediaStore.Downloads.IS_PENDING, 0) },
                null, null
            )
        }
    }
}

Notification with Pause / Cancel actions

private fun DownloadWorker.buildForegroundInfo(task: DownloadTask, pct: Int): ForegroundInfo {
    val pauseIntent = WorkManager.getInstance(applicationContext)
        .createCancelPendingIntent(id)   // triggers isStopped in worker

    val notification = NotificationCompat.Builder(applicationContext, CHANNEL_DOWNLOADS)
        .setSmallIcon(R.drawable.ic_download)
        .setContentTitle(task.displayName)
        .setProgress(100, pct, pct == 0)           // indeterminate if pct=0
        .setContentText("$pct%")
        .setOngoing(true)
        .setSilent(true)
        .addAction(R.drawable.ic_pause, "Pause", pauseIntent)
        .build()

    return ForegroundInfo(NOTIF_ID_BASE + task.id.hashCode(), notification)
}

Deep dive: Why OkHttp + WorkManager beats DownloadManager

Feature Android DownloadManager OkHttp + WorkManager ✓
Byte-level progress callbacks ✗ Only via polling ✓ 8 KB granularity
Pause / Resume ✗ No programmatic pause ✓ WorkManager cancel + Range header resume
Custom retry logic ✗ Fixed system behavior ✓ BackoffPolicy.EXPONENTIAL
Network constraint (Wi-Fi only) ✓ 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)

6. 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
🟣 Staff+
  • Designs parallel chunk download (split file into N ranges, merge)
  • Priority queue: users can bump a download to the front
  • Per-download bandwidth throttling via token bucket
  • Analytics: download 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
  • Discusses delta-sync for frequently updated files (partial updates)

7. Interview Q&A (20 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" in ContentResolver) 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. Instead: 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 of protection. First, in the Repository, before inserting call dao.findByUrl(url) — if a row already exists and is not CANCELLED/COMPLETED, return early (or surface a "already downloading" message). Second, 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(). WorkManager will happily schedule more than N workers simultaneously — your Semaphore is the gate that serializes them to the concurrency limit. Workers above the limit stay parked in the acquire()` 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. Track lastProgressUpdate = System.currentTimeMillis() and skip updates if now - last < 250.

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, values { IS_PENDING = 0 }, null, null) to publish it. If your app crashes without clearing IS_PENDING, the system automatically clears it after a timeout. Always write pending entries so partially written files never appear as valid content.

Q8. How does checksum verification work after a download?
Medium

The server provides the expected SHA-256 (or MD5) hash of the file, usually in the API response or a sidecar file. After writing all bytes, open the downloaded file as an InputStream, stream it through MessageDigest.getInstance("SHA-256") in 8 KB chunks, then convert the resulting byte array to a 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 entirely. 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 coroutine from the current DB state, replacing any stale worker that might have been interrupted. APPEND chains work in sequence — not useful for downloads. 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. On resume, a Range request picks up from there. Cancel sets status to CANCELLED, cancels the WorkManager 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. The DB row stays as CANCELLED for history but the file is gone.

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 in Room every 250 ms. The ViewModel collects this Flow and exposes it as StateFlow<List<DownloadTask>>. The Fragment collects the StateFlow in repeatOnLifecycle(STARTED) and calls adapter.submitList(). DiffUtil only rebinds the specific item whose progress changed — no full list refresh. No polling, no BroadcastReceiver needed — Room's reactive queries handle it end-to-end.

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(Intent.FLAG_GRANT_READ_URI_PERMISSION) }. For files in app-private storage (getExternalFilesDir): wrap with FileProvider to generate a content:// URI — never pass a raw file:// URI on Android 7+, as it throws FileUriExposedException. Declare the FileProvider in AndroidManifest with android:grantUriPermissions="true".

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. The task stays PAUSED. When Wi-Fi reconnects, WorkManager re-queues the work automatically because the constraint is re-satisfied. For wifiOnly=false, the worker uses any CONNECTED network; a switch causes a brief OkHttp socket exception, which triggers WorkManager's exponential backoff retry — and the next run issues a Range resume request.

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 (usually a hash of the file content). On the first request, save the response ETag header to the Room task. On a Range-resume request, send If-Range: <etag>. If the server file hasn't changed, the server sends 206 Partial Content — safe to append. If the file changed, the server sends 200 OK with the full new file — you must discard the partial file and restart from zero. 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 the Content-Length. Launch N parallel OkHttp requests, each with a different Range header (e.g., bytes=0-4999999, bytes=5000000-9999999). Write each chunk to a separate temp file. When all chunks complete, merge them sequentially into the final file. Track chunk progress independently in Room — a DownloadChunk table with (taskId, chunkIndex, startByte, endByte, bytesDownloaded). Resume works per-chunk. Concurrency: use async + awaitAll inside the CoroutineWorker. Typical speedup on fast connections: 3–5× for large files.

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

After dao.updateStatus(COMPLETED), update the existing notification with setContentText("Download complete"), clear setOngoing(false) (so it's dismissible), and add a tap PendingIntent that fires ACTION_VIEW on the file URI. Set setAutoCancel(true) so it dismisses on tap. Use a distinct notification ID per download (NOTIF_BASE + taskId.hashCode()) so each download has its own notification that can be individually dismissed. For batch completion, you can post a summary notification using the notification grouping API (setGroup + setGroupSummary).

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, write each chunk to the output stream, then loop again. The JVM heap only ever holds 8 KB at a time regardless of file size. Similarly for checksum verification: stream the file through MessageDigest in 8 KB chunks — never File.readBytes() a 4 GB file. Long (not Int) for bytesDownloaded and totalBytesInt 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 (higher = more urgent). The QueueManager, instead of a plain Semaphore, uses a PriorityQueue of waiting coroutines. When a slot frees up, release it to the highest-priority waiter. In practice: wrap the Semaphore with 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 artifact. Inject a fake OkHttpClient that returns a pre-built MockResponse via OkHttp's MockWebServer. Inject an in-memory Room database for the DAO. Use a real QueueManager(1). Call worker.doWork() in runTest and assert the Room row transitions through RUNNING → COMPLETED. For resume testing: pre-seed the Room row with bytesDownloaded = 500, assert the OkHttp request contained 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(Environment.getExternalStorageDirectory()).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. Present a UI warning before deletion. Never delete RUNNING or PAUSED tasks — only completed history. Respect a "keep offline" flag on items the user explicitly wants retained.