This is a classic mobile system design problem. It touches networking, concurrency, disk I/O, background processing, and state management all in one question.
The library needs to download files from a URL to local storage, support pause and resume, track progress, and handle multiple concurrent downloads with a queue. Users should be able to enqueue a download, get a handle back to control it (pause, resume, cancel), and observe its progress. The downloads must survive app backgrounding — if the user switches apps, the download keeps going.
For a first version, skip multi-segment parallel downloads (splitting one file across multiple connections), download speed throttling, and authentication. Also skip chunked transfer encoding handling and redirect chains. These are real concerns but they add complexity without changing the core architecture. Focus on single-connection downloads with pause/resume, a task queue, and progress reporting.
Three core components. DownloadManager is the public-facing entry point — it accepts requests, returns download IDs, and exposes pause/resume/cancel/observe APIs. TaskQueue manages ordering and concurrency — it holds pending tasks in a priority queue and limits how many run at once. StorageManager handles disk space checks, file allocation, and buffered writes.
Supporting these: a Room database persists download state so everything survives process death, a NetworkMonitor watches connectivity changes, and a NotificationManager shows progress to the user. The DownloadManager coordinates all of them.
Keep it simple for common use. A builder pattern for requests, a download ID for control, and a Flow for observation.
val downloadId = FileDownloader.enqueue(
DownloadRequest.Builder("https://example.com/file.zip")
.setDestination("/storage/downloads/file.zip")
.setTitle("App Update")
.setPriority(Priority.HIGH)
.build()
)
FileDownloader.pause(downloadId)
FileDownloader.resume(downloadId)
FileDownloader.cancel(downloadId)
FileDownloader.observe(downloadId).collect { status ->
when (status) {
is Status.Downloading -> updateProgress(status.progress)
is Status.Completed -> openFile(status.filePath)
is Status.Failed -> showRetry(status.error)
}
}
Return a downloadId on enqueue so the caller can control and observe the download later. Use a sealed class for status so the compiler forces the caller to handle every state.
Each download is represented as a DownloadTask entity persisted in Room. It holds everything needed to resume a download from scratch after process death.
@Entity(tableName = "downloads")
data class DownloadTask(
@PrimaryKey val id: String,
val url: String,
val destination: String,
val totalBytes: Long = 0,
val downloadedBytes: Long = 0,
val status: String = "QUEUED",
val priority: Int = 0,
val retryCount: Int = 0,
val createdAt: Long = System.currentTimeMillis(),
val etag: String? = null
)
The etag field stores the server’s ETag from the initial response. When resuming, you send If-Range: <etag> alongside the Range header. If the file changed on the server since you started, the server returns the full file instead of a partial response, and you restart.
When the user pauses, you save the byte count already written to disk. To resume, send a Range: bytes=<downloaded>- header. The server responds with 206 (Partial Content) and sends only the remaining bytes. You open the file in append mode and keep writing from where you left off.
Not all servers support this. Check the Accept-Ranges: bytes header on the initial response. If the server returns 200 instead of 206 on a ranged request, it doesn’t support partial content and you restart from scratch.
A download moves through five states: Queued, Downloading, Paused, Completed, and Failed.
Every state transition gets persisted to Room immediately. On app restart, query for tasks in Queued or Downloading state and re-enqueue them.
Use a foreground service to keep the process alive during downloads. The notification shows file name, progress bar, download speed, and pause/cancel actions.
class DownloadService : Service() {
private fun buildProgressNotification(
title: String, progress: Int, speed: String
): Notification {
return NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle(title)
.setContentText("$speed - $progress%")
.setSmallIcon(R.drawable.ic_download)
.setProgress(100, progress, false)
.setOngoing(true)
.addAction(R.drawable.ic_pause, "Pause", pauseIntent)
.addAction(R.drawable.ic_cancel, "Cancel", cancelIntent)
.build()
}
}
Update the notification at most once per second. More frequent updates cause flicker and waste battery. When the download completes, replace the ongoing notification with a non-ongoing one that opens the file on tap. Group multiple download notifications to avoid spamming the shade.
On pause, cancel the coroutine doing the download. The coroutine’s finally block flushes any buffered bytes and updates the database with the exact byte count. On resume, read the persisted byte count, send a Range request, and append to the existing file.
suspend fun resumeDownload(task: DownloadTask) {
val request = Request.Builder()
.url(task.url)
.header("Range", "bytes=${task.downloadedBytes}-")
.build()
val response = httpClient.newCall(request).await()
if (response.code == 206) {
val output = FileOutputStream(File(task.destination), true)
streamToFile(response.body!!.byteStream(), output, task.downloadedBytes)
} else if (response.code == 200) {
val output = FileOutputStream(File(task.destination))
streamToFile(response.body!!.byteStream(), output, 0L)
}
}
If you stored an ETag, include If-Range: <etag> in the request. This tells the server to only honor the Range if the file hasn’t changed. If it has changed, the server sends the whole file with a 200, and you overwrite.
Use a coroutine Semaphore to cap parallelism. Each download acquires a permit before starting and releases it when done, paused, or failed. Pending downloads suspend on semaphore.acquire() until a slot opens.
class DownloadExecutor(
private val maxConcurrent: Int = 3,
private val scope: CoroutineScope
) {
private val semaphore = Semaphore(maxConcurrent)
private val jobs = ConcurrentHashMap<String, Job>()
fun execute(task: DownloadTask) {
val job = scope.launch {
semaphore.acquire()
try {
performDownload(task)
} finally {
semaphore.release()
}
}
jobs[task.id] = job
}
fun pause(id: String) { jobs[id]?.cancel() }
}
Three to four concurrent downloads is a good default. More than that and you start thrashing the disk and splitting bandwidth too thin. The semaphore approach is cleaner than managing a thread pool manually because coroutines handle the suspension transparently.
Emit progress on every chunk write, but throttle what reaches the UI. A StateFlow with a time gate works well — emit at most every 200ms.
class ProgressTracker(private val taskId: String) {
private val _progress = MutableStateFlow(Progress(0, 0))
val progress: StateFlow<Progress> = _progress
private var lastEmitTime = 0L
fun onBytesWritten(downloaded: Long, total: Long) {
val now = SystemClock.elapsedRealtime()
if (now - lastEmitTime > 200) {
_progress.value = Progress(downloaded, total)
lastEmitTime = now
}
}
}
200ms gives smooth progress bar animation without wasting CPU. For notifications, throttle even more — once per second is enough. Calculate speed by dividing bytes written in the last interval by the interval duration.
Use a foreground service for active downloads the user triggered. The system won’t kill a foreground service, so the download runs uninterrupted. Android 12+ requires FOREGROUND_SERVICE permission and Android 14+ requires FOREGROUND_SERVICE_DATA_SYNC type.
For retrying failed downloads or deferred sync, use WorkManager. It survives process death, respects Doze mode, and lets you set constraints like NetworkType.UNMETERED (Wi-Fi only). The right pattern is a foreground service for active downloads with WorkManager as the fallback for recovery and background syncing.
Stream the HTTP response body and write in 8 KB chunks. Never load the whole file into memory. For large files, pre-allocate disk space before downloading so you fail early if there isn’t enough room.
suspend fun streamToFile(
input: InputStream, output: OutputStream, startBytes: Long
) {
val buffer = ByteArray(8192)
var downloaded = startBytes
input.use { src ->
output.buffered().use { dst ->
var bytesRead: Int
while (src.read(buffer).also { bytesRead = it } != -1) {
dst.write(buffer, 0, bytesRead)
downloaded += bytesRead
progressTracker.onBytesWritten(downloaded, totalBytes)
}
}
}
}
Wrapping the output in BufferedOutputStream reduces the number of system calls. The default 8 KB buffer means you’re doing one system write per 8 KB instead of potentially many smaller ones. Flush periodically (every few hundred KB) so that data isn’t lost if the process is killed, but don’t flush on every chunk — that kills throughput.
Compute a checksum of the downloaded file and compare it to what the server provides. The server might include a hash in a response header, a separate endpoint, or alongside the download link.
suspend fun verifyChecksum(
file: File, expectedHash: String, algorithm: String = "SHA-256"
): Boolean = withContext(Dispatchers.IO) {
val digest = MessageDigest.getInstance(algorithm)
val buffer = ByteArray(8192)
file.inputStream().use { input ->
var bytesRead: Int
while (input.read(buffer).also { bytesRead = it } != -1) {
digest.update(buffer, 0, bytesRead)
}
}
val hash = digest.digest().joinToString("") { "%02x".format(it) }
hash.equals(expectedHash, ignoreCase = true)
}
If verification fails, delete the file and re-download. For APK downloads and OTA updates, checksum verification is mandatory for security. SHA-256 is the standard choice — MD5 is fast but has known collision vulnerabilities.
Use exponential backoff with jitter. Wait 1 second after the first failure, then 2, 4, 8, capped at a few minutes. Jitter prevents all failed downloads from retrying at the same instant.
class RetryPolicy(
private val maxRetries: Int = 5,
private val baseDelayMs: Long = 1000
) {
private var attempts = 0
fun shouldRetry(error: Throwable): Boolean {
if (attempts >= maxRetries) return false
return error is IOException || error is SocketTimeoutException
}
suspend fun backoff() {
val delay = baseDelayMs * (1L shl attempts.coerceAtMost(5))
val jitter = Random.nextLong(0, delay / 4)
delay(delay + jitter)
attempts++
}
}
Only retry on transient errors — network timeouts, connection resets, 503 responses. Don’t retry on 404 or 401. If the download was partially done and the server supports Range, resume from the last persisted byte offset instead of restarting.
Use a PriorityBlockingQueue ordered by priority descending. When a slot opens, the highest-priority pending task gets picked up. If a user-initiated (HIGH) download arrives and all slots are full, you can optionally pause the lowest-priority active download to make room.
enum class Priority { LOW, NORMAL, HIGH, IMMEDIATE }
class TaskQueue {
private val pending = PriorityBlockingQueue<DownloadTask>(
11, compareByDescending { it.priority }
)
fun add(task: DownloadTask) {
pending.offer(task)
drainQueue()
}
fun next(): DownloadTask? = pending.poll()
}
IMMEDIATE priority should bypass the queue entirely and start right away, even if it means exceeding the concurrency limit briefly. This is for critical downloads like security patches.
Unit test the components in isolation. Mock the HTTP client to return controlled responses — partial content (206), full content (200), errors (503), and missing Range support. Use a fake file system or temp directory for disk operations. Test the state machine transitions: enqueue goes to Queued, start goes to Downloading, network loss goes to Failed or Paused depending on policy.
Integration test the full flow: enqueue a download against a local test server, verify the file lands on disk, pause and resume it, verify the Range header is sent and the file is complete. Test process death by persisting state, killing the test, restarting, and checking that downloads resume from the right offset. For concurrency, enqueue more downloads than the max concurrent limit and verify that excess tasks wait in the queue.