Skip to content

5. Android: Keeping Downloads Alive

Docs 2 to 4 treated “the native queue” as a black box that downloads files and reports back. On Android that box is WorkManager plus two pieces of the app’s own design:

  • a holding queue that feeds it eight files at a time
  • a foreground service that persuades the operating system to let the app keep working

This doc explains why both exist. It also covers the Android rules that made downloads crawl before the September 2026 fixes: 10 to 12 hours for 40% of a film.

A two-hour film is 1.5 to 3 GB. On a patchy mobile connection that can take hours, far longer than Android lets an ordinary app work in the background. What the OS allows depends on the app’s process state:

MOST PRIVILEGED
visible activity (the user is in the app) runs freely
foreground service (a pinned notification) runs freely
─────────────────────────────────────────────── line the app must stay above
background, no service frozen within seconds (Android 14
"cached app freezer"); first to be
killed when memory is low
LEAST PRIVILEGED

When the process is frozen or dead, WorkManager work can only run as JobScheduler jobs, and those are rationed by the app’s standby bucket (how recently and how often the user opened it):

Bucket Regular job runtime allowed Network
Active 20 min per rolling 60 min (Android 16+; unlimited before) yes
Working set 10 min per rolling 4 h yes
Frequent 10 min per rolling 12 h yes
Rare 10 min per rolling 24 h disabled
Restricted once per day, 10 min disabled

Source: Android’s “Power management restrictions” documentation. Before Android 16, an app with a running foreground service was exempt from these quotas. From Android 16, jobs running alongside a foreground service count against them too, and Google points user-initiated transfers at user-initiated data transfer jobs instead.

Ten minutes every few hours is a trickle. That trickle is what users saw whenever the app lost its privileged state. The rest of this doc is about staying above the line and recovering cleanly when that fails.

Each task becomes a WorkManager OneTimeWorkRequest:

OneTimeWorkRequest<DownloadTaskWorker>
constraints: network connected
tags: "BackgroundDownloader", "taskId=content_42_video_17", "group=content_42"
input: the task as JSON
│
▼
DownloadTaskRunner.run()
├─ report "running" to Dart
├─ HttpURLConnection GET connect timeout 60 s (Config.requestTimeout)
│ no read timeout: a dead socket waits
├─ stream to a temp file in the app's files directory (Config.useCacheDir: never)
├─ move the temp file to <Documents>/<slug>/…
└─ report "complete" / "failed" / "notFound" (HTTP 404) / "canceled"

Four package behaviours shape this app’s design:

  1. 9-minute worker limit. A single file that takes longer is paused and re-enqueued one second later, resuming with a Range request if the server supports it (tasks have allowPause: true).
  2. Retries happen in Dart. On failure the package deletes the WorkManager job natively and asks Dart to schedule the retry. No running Dart, no retry (doc 4, layer 1).
  3. Updates Dart cannot receive are stored. If a status update cannot be posted to Dart (the Flutter engine is gone), the package appends it to a JSON map in SharedPreferences. FileDownloader().start() replays that map on the next launch.
  4. Every WorkManager-enqueued task lives in one SharedPreferences string. The package keeps a JSON map of all of them. On every enqueue, and every final status, it decodes the whole map, changes one entry, re-encodes it and writes it back, under one global lock. With a film’s thousands of tasks in WorkManager at once, that work grows with the square of the task count: minutes of CPU just to enqueue a title on a low-end phone, and every worker queueing behind that lock.

lib/main.dart configures, for Android only:

androidConfig: [
(Config.useCacheDir, Config.never),
(Config.holdingQueue, (8, null, null)), // max 8 concurrent; no per-host/per-group cap
],

The holding queue is a waiting line inside the app process, in front of WorkManager:

enqueueAll(1,804 tasks)
│
▼
┌──────────────── HoldingQueue (in-process, native) ────────────────┐
│ [#9][#10][#11] … [#1804] each add also reports "enqueued" │
└───────────────┬───────────────────────────────▲───────────────────┘
│ release while concurrent < 8 │ taskFinished → release next
▼ │ (plus a 10 s safety timer)
┌──────────────── WorkManager ──────────────────┴───────────────────┐
│ [#1][#2][#3][#4][#5][#6][#7][#8] persisted, running │
└────────────────────────────────────────────────────────────────────┘

What it buys, for users on unstable networks who download to watch later:

Hand everything to WorkManager Holding queue of 8
Starting a 2-hour film on a cheap phone minutes of bookkeeping first seconds
Package bookkeeping per title grows with (tasks)² grows with tasks × 8
Parallel connections uncapped 8
A brief signal drop interrupts every file in flight at most 8 files
CPU, battery and heat high low

What it costs. Held tasks live in memory. If the app process dies, only the eight already inside WorkManager survive (and trickle under the quotas above). The rest wait until the app is opened, when cold start re-queues whatever is missing on disk (doc 4, layer 6) and nothing already downloaded is fetched again. The trade-off was chosen deliberately in September 2026. It replaced an earlier “enqueue everything” design whose supposed advantage, work that survives a kill, turned out to be a quota trickle. On OEMs that force-stop apps it was nothing at all.

Other properties worth knowing:

  • The queue does not deduplicate by task ID. The app’s own active-task filter does that (doc 4).
  • FileDownloader().allTasks and cancelAll see held tasks as well as running ones.
  • When the queue is created it counts the unfinished WorkManager jobs that already exist. A download started by an older app version drains first, then the cap applies.
  • iOS does not use it (doc 6).

android/app/src/main/kotlin/com/prothomalo/DownloadForegroundService.kt is a dataSync foreground service. It does no downloading itself. Its job is to keep the process above the line, and its notification is the single progress notification the user sees.

Why the app runs its own service instead of the package’s:

  • Counters that jump around. The package’s group notification counts only tasks the native side has registered so far. With thousands of tasks its “n of N” jumped around and looked finished before it was.
  • One notification. The product wants a single notification for all downloads, driven by the app’s accurate numbers.
  • What it keeps alive. While it runs, the process keeps WorkManager’s in-process scheduler going, keeps Dart alive for retries and settlement, and holds off OEM battery managers.

Dart controls the service through MethodChannel("com.prothomalo/download_fg"), handled in MainActivity.configureFlutterEngine:

Method Arguments Native effect Returns
update title, body, progress, total, channelName, channelDescription, timeoutTitle, timeoutBody startForegroundService(ACTION_UPDATE) false if Android refused the start
stop none startService(ACTION_STOP), falling back to stopService true
moveTaskToBack none Activity.moveTaskToBack(true) whether it moved
getManufacturer none Build.MANUFACTURER string
openAutostartSettings none deep link to the OEM autostart screen whether a screen opened

DownloadProgressNotifier is the Dart side. Its DownloadManagerImpl._syncAggregateNotification caller decides what to show.

tap Download / Resume / Retry ── force update ──► service starts, indeterminate bar
("preparing": parse + enqueue)
enqueue finished ─────────────── force update ──► determinate bar, "NN%"
each file completes ──────────── update only if the whole % changed
app returns to foreground ────── force update ──► restarts it if it had been refused
no queued/downloading rows left ─ stop ──────────► service stops, notification removed

The notification is ongoing, silent (channel chorki_download_progress, low importance), and opens the app when tapped. onStartCommand returns START_NOT_STICKY: if the system kills the process, the service is not recreated natively. The next cold start or foreground event restarts it from Dart.

These look fussy but each one fixed a real crash or stuck notification. Keep them:

  • Stop is a start command. It is delivered with startService, so it queues behind pending updates instead of racing them.
  • startForeground in every branch, including the stop branch. Otherwise a pending startForegroundService obligation crashes the app with RemoteServiceException.
  • Stop with stopSelf(startId), never stopSelf(). A cancel followed by an immediate re-download would otherwise destroy the service while the new update is queued, which is the same crash.
  • Dart always sends stop, and an epoch counter stops a slow update reply from overwriting “stopped”.
  • startAsForeground catches exceptions and stops quietly instead of crashing.

Rule 1: no foreground service starts from the background (Android 12+)

Section titled “Rule 1: no foreground service starts from the background (Android 12+)”

Android 12 throws ForegroundServiceStartNotAllowedException when an app starts a foreground service while it is in the background. MainActivity catches it and returns false. The notifier remembers the service is not running, and _onAppResume tries again.

This is why the service is started at the tap (doc 2): parsing and queueing a film can outlast the few seconds the OS allows after the user leaves. It is also why the first download asks, once, for an exemption from battery optimization (REQUEST_IGNORE_BATTERY_OPTIMIZATIONS, remembered in downloads.battery_opt_prompted). An exempt app may start the service from the background.

Rule 2: six hours of dataSync per day (Android 15+)

Section titled “Rule 2: six hours of dataSync per day (Android 15+)”

Apps targeting Android 15 or later (this one targets 36) get 6 hours of dataSync foreground service time per 24 hours. The budget resets when the user brings the app to the foreground.

When it runs out, Android calls Service.onTimeout(startId, fgsType). The service must stop within seconds or the whole app is killed with RemoteServiceException: A foreground service of type dataSync did not stop within its timeout. Before September 2026 the app did not implement onTimeout, so an overnight download ended in exactly that crash.

Now:

onTimeout
├─ post "Download paused" / "Open Chorki to continue downloading"
│ (localized text sent with every update; notification id 0x44C0DF)
├─ stopForeground(REMOVE)
└─ stopSelf() (no startId: the service must go, and a new
dataSync start is refused anyway)

Until the user opens the app, the process is no longer protected and the quotas above apply. Opening the app resets the budget. _onAppResume restarts the service, the next update clears the notice, and settlement catches up on anything missing.

Several manufacturers kill or freeze background apps far more aggressively than stock Android, even when an app has a foreground service or a battery-optimization exemption. DownloadProgressNotifier treats these as aggressive:

  • Xiaomi, Redmi, POCO
  • OPPO, realme, OnePlus
  • vivo, iQOO
  • Huawei, Honor
  • Meizu
  • Tecno, Infinix, itel

On the first download on such a phone, maybeShowDownloadProtectionTip shows a one-time dialog, remembered in downloads.oem_protection_tip_shown. Its Open settings button deep-links to the MIUI, ColorOS, OPPO, vivo or Huawei autostart screen, falling back to the app’s details page.

On many of these phones, swiping the app away from Recents is effectively a force stop. That cancels its queued jobs until the app is opened again. No app-side design can prevent it; cold start recovers the download on the next launch.

MainScaffold uses the common “press Back twice to exit” pattern. Exiting used to call SystemNavigator.pop(), which finishes the FlutterActivity and destroys the Flutter engine. Once the engine is gone, nothing retries failed files, the watchdog and settlement stop, and the progress notification freezes at its last percentage.

Now, on Android, the second Back checks DownloadManager.list() for any queued or downloading record. If one exists, it calls DownloadProgressNotifier.moveTaskToBack(): the app goes to the background exactly as if the user had pressed Home, with the engine alive. Otherwise it exits as before.

Besides the ongoing progress notification, DownloadProgressNotifier posts ordinary dismissible notifications through flutter_local_notifications:

Notice When Dismissed by
Download complete _safeFinalize succeeded user
Download failed settlement gave up, finalize threw retry, cancel
Download paused pause resume, cancel

They use the channel chorki_download_events (default importance) and one notification ID per title (derived from the content ID). Tapping one opens the Downloads screen.

Declaration Why
FOREGROUND_SERVICE, FOREGROUND_SERVICE_DATA_SYNC Run DownloadForegroundService as a dataSync service (required on Android 14+).
POST_NOTIFICATIONS Show the progress and terminal notifications on Android 13+. The manager requests it at startup if not granted.
REQUEST_IGNORE_BATTERY_OPTIMIZATIONS The one-time exemption prompt.
RECEIVE_BOOT_COMPLETED Lets WorkManager reschedule its persisted jobs after a reboot.
<service .DownloadForegroundService foregroundServiceType="dataSync" exported="false"> The app’s service.
<service androidx.work.impl.foreground.SystemForegroundService foregroundServiceType="dataSync"> Required if WorkManager workers are ever promoted to foreground by the package. Unused today because the package’s notifications are not configured on Android.
Event Process Service Dart side Downloading continues? Recovery
Home button, screen locked alive running alive yes, full speed none needed
Back (download active) alive running alive yes, full speed none needed
Swipe from Recents (stock Android) alive running gone yes: the 8 in WorkManager and the holding queue drain; failed files are not retried; notification % freezes next app open
Swipe from Recents (aggressive OEM) killed / force-stopped gone gone no next app open
Low-memory kill killed gone gone only the ≤ 8 persisted jobs, under quotas next app open
6-hour dataSync limit reached alive, unprotected stopped with a notice alive until frozen until frozen or killed open the app
Reboot new gone gone only the ≤ 8 persisted jobs, under quotas next app open
  • android/app/src/main/kotlin/com/prothomalo/DownloadForegroundService.kt: the service, onTimeout, notification building.
  • android/app/src/main/kotlin/com/prothomalo/MainActivity.kt: the download_fg channel and OEM autostart deep links.
  • android/app/src/main/AndroidManifest.xml: permissions and service declarations.
  • lib/data/services/download_progress_notifier.dart: the Dart side of the channel and the terminal notifications.
  • lib/data/services/download_manager_impl.dart: _syncAggregateNotification, _maybePromptBatteryOptimization, _onAppResume.
  • lib/presentation/widgets/download/download_protection_tip.dart: the OEM dialog.
  • lib/presentation/pages/home_pages/main_scaffold.dart: Back handling (_hasActiveDownload).
  • lib/main.dart: _initBackgroundDownloader (holding queue, timeouts).
  • Package: background_downloader-9.5.5/android/…/BDPlugin.kt, TaskRunner.kt, DownloadTaskRunner.kt, HoldingQueue.kt.

On Android the downloader is only as fast as the privilege the OS grants the app. The design keeps the app in the foreground-service tier from the moment of the tap, keeps Dart alive through the Back button, and stops cleanly at the 6-hour limit instead of crashing. It feeds WorkManager in small batches so the work stays cheap and a signal drop stays small. When Android takes that privilege away anyway, the design makes sure the next launch picks up exactly where the disk left off.