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.
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)
2. The Set Up
Approach comparison: which download mechanism?
✅ 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
✅ Full control over progress, headers, retry; can implement chunked parallel downloads
❌ Must manage Foreground Service lifecycle manually; battery & ANR risk; complex process-death handling
✅ 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
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
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
| 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 | — | — | — | — |
| 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 |
| 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
- 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
- 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)
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" in ContentResolver) 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. 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+.
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.
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.
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.
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.
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.
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.
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.
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.
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".
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.
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.
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.
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).
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 totalBytes — Int overflows at ~2 GB.
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.
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.
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.