Skip to content

Downloads: fespalier_download ​

fespalier_download (since 0.15.0) is the vocabulary of a file download that outlives a screen: a request (an id, a URL, a file and what the file must be), a status that says where the download stands, and the ports a transfer engine is built from. It is pure Dart over fespalier_transport's HttpTransport (no package:http, no Dio; since 0.21.0, over an http.Client until 0.20.0) with no platform plugin, so it resolves on Flutter 3.35, fespalier's floor, and an app that lists it links nothing native.

fespalier itself has no download feature: no file kind, no fespalier: key, no fsp command, and app.g.dart is the same bytes. The package is a companion, installed like fespalier_flags.

What is in this release, and what is not. This release is the model, the telemetry names, the fakes, the engine, Downloads, which starts, pauses, resumes, retries, cancels and removes a download over a backend you give it and settles the registry after a restart, a foreground backend, HttpDownloadBackend, that downloads through an HttpTransport while the app runs, a durable registry, FileDownloadStore, and the providers a widget watches (downloads, downloadStatus). A download that must go on while the app is closed is the job of the background backend, in the separate package fespalier_download_background (since 0.15.0, Flutter 3.47 or newer, over background_downloader; see The background backend), which also uploads a file (Uploads). Notification taps open typed routes through an adapter (since 0.15.0), and this page grows with it.

Contents: Install, Requests and files, Status, Starting a download, After a restart, Sign-out, The foreground backend, The background backend, Android and iOS setup, Credentials, In a widget, Notification taps, Uploads, Testing.

Install ​

Add the package next to fespalier with the same url and the same ref: pub resolves the two to one package only then. The tag must be a release that contains the package (0.15.0 or later). The install block, with the version release-please keeps current, is in the package's README.

The package depends on fespalier_transport (since 0.21.0; for HttpTransport and HttpCredentials), a pure Dart package with no dependencies, and on fespalier. It depends on neither http nor Dio (it depended on http and fespalier_http until 0.20.0): you hand the backend a transport, so list fespalier_transport yourself, with the same url and ref, and fespalier_http or fespalier_dio for the one that sends (HttpClientTransport(http.Client()), DioTransport(dio); HTTP transport). It resolves on Flutter 3.35 with the lowest versions it allows (CI's floor job runs flutter pub downgrade, flutter analyze and flutter test on the package).

Requests and files ​

A download is a DownloadRequest:

dart
final request = DownloadRequest(
  id: 'manual-42',
  url: Uri.parse('https://files.example.com/manual-42.pdf'),
  file: const DownloadLocation(DownloadBase.support, 'manuals/manual-42.pdf'),
  bytes: 1048576, // optional: a different size ends Failed(sizeMismatch)
  sha256: '…64 hex digits…', // optional: a different digest ends Failed(hashMismatch)
);
  • A file is a base and a relative path, never an absolute path. An iOS app's container path changes between launches, so a stored absolute path goes stale. DownloadBase is support (the application support folder, the default), cache (the platform may empty it) or documents. A backend resolves a location to a path for the moment of use and the path is never stored.
  • DownloadLocation.isValid refuses an empty path, a leading /, an empty segment (a//b, a trailing /), a .. segment, a \ and a NUL character. Check it for a path that came from outside the app (a server's file name).
  • DownloadRequest.isValid is false for an empty id, a URL that is not an absolute http or https URL with a host and no credentials in it, an invalid file, a negative bytes, a sha256 that is not 64 hex digits, or an empty header name.
  • network is DownloadNetwork.any or unmetered (Wi-Fi only), and priority is DownloadPriority.background (the default) or userInitiated (the person asked just now and watches it).
  • toString() of a request prints no field: only DownloadRequest. The URL can be a capability, the headers a credential and the display name a person's own words, and a request ends up in a log line or an error report. A DownloadLocation prints its base only.
  • Headers may be kept on disk in plaintext by a backend while a download is queued. Never put a refresh token or a long-lived bearer in one. The documented way to authorise a transfer is a short-lived capability: before starting, the app makes its normal signed request, the server answers with a short-lived single-file URL, and the request carries that.

Status ​

A download is in one DownloadStatus at a time, a sealed family you can switch over exhaustively:

StatusMeaning
AbsentNo download is known with that id.
QueuedHanded to the backend, not running yet.
Waiting(WaitReason)Not running: network, unmetered, retry (an attempt failed) or slot (it is next).
Running(received, total?)Transferring; total is null when the server did not say.
Paused(received, total?)Stopped on purpose, the bytes kept.
VerifyingReceived; the size and the digest are being checked.
Complete(file, bytes)The file is in place and checked.
Failed(DownloadFailure)Ended without a file.
CancelledStopped for good by the app; nothing kept.

DownloadFailure is a value, never an error's text: unsupported (the web), invalidRequest, network, rejected (an HTTP error that asking again will not fix), unauthorized (401 or 403), sizeMismatch, hashMismatch, storage, notificationsRequired, killed and other. Every status prints its state and numbers, never a path. network is also what a download ends with when the app could not reach its server for the credentials (since 0.16.0): a grantor or an HttpCredentials that throws a CredentialsUnavailable (which is what fespalier_auth's AuthUnavailable is when a token cannot refresh offline; it was an http.ClientException until 0.20.0) or a TransportFailure. Nothing was refused, so it is not unauthorized, and retry asks again.

The ports are the seams the engine is built on, and an app does not call them: DownloadBackend (the transfer machinery, with DownloadCapabilities saying what it can do and DownloadEvents as the way back), DownloadStore (the durable list of what the app asked for: it is a registry, not a cache, so nothing is evicted) and DownloadFiles.

Telemetry (the names are the contract, add never rename): fespalier.download.transfer for one transfer with result (complete, failed, cancelled), failure, resumed, background, network and priority; fespalier.download.reconciled; and fespalier.download.open with routed. Their values are constants, booleans and enum names: never a URL, an id, a path, a display name, a header or an error's text. FespalierDownloadConventions has them, and test/telemetry_test.dart pins each one.

Starting a download ​

Downloads is the engine. It imports no Riverpod, so it works in a test or a background isolate as it is; a later release adds the providers on top.

dart
final downloads = Downloads(backend: backend, store: store, files: files);
await downloads.open(); // before anything else
downloads.observe((id, status) => debugPrint('$id is $status')); // one owner

await downloads.start(request);
downloads.statusOf('manual-42'); // Queued, then what the backend reports
await downloads.pause('manual-42'); // true when the backend took the request
await downloads.resume('manual-42');
await downloads.retry('manual-42'); // from Failed only
await downloads.cancel('manual-42'); // stops it for good: Cancelled, nothing kept
await downloads.remove('manual-42'); // also deletes the file: Absent
final path = await downloads.pathOf('manual-42'); // null unless Complete
  • start never throws for a bad request. An invalid request, or a file that is not a safe relative path, ends Failed(invalidRequest) and is not registered, so there is nothing to retry: start it again once it is right. A userInitiated request on a backend whose capabilities have no notifications ends Failed(notificationsRequired). Starting an id that is already queued, running, paused or complete does nothing: it is the same download. Calling start before open is a StateError.
  • The backend reports, the engine does not guess. pause and resume ask the backend and return what it answered; the status moves when the backend says so (Paused, Running), except that a resumed download shows Queued at once. A backend that cannot pause (its capabilities.pause is false) makes pause return false.
  • retry starts a Failed download again from the request the registry holds. After sizeMismatch or hashMismatch it deletes the file first; after a network failure it keeps the bytes so the backend can go on from them.
  • statuses and statusOf are the whole state: statusOf answers Absent for an id it does not know. A 401 or 403 that the backend reports with a failed status ends Failed(unauthorized).
  • observe is one owner slot. A second call replaces the first, observe(null) clears it, and close() clears it too, so an engine that outlives a screen holds nothing of it. An observer that throws costs only its own update.
  • Generations drop what is stale. Each id has a generation that a cancel, a remove, a restart and a sign-out move on. An event for an id the engine no longer knows, or for a download that has ended, is dropped; so is the rest of a start whose download was cancelled or cleared while it waited on the backend. A backend must not report on a transfer it was told to cancel: the engine cannot tell a late report of an old attempt from a new one once the same id has been started again. The same holds for cancel and remove themselves: if the id is started again while the backend is still stopping the old download, they stop there and leave the new download's registry entry and files alone.
  • Telemetry. Each transfer is one fespalier.download.transfer span from its start to the state it ends in (result, and failure when it failed). Its attributes are only the constants of FespalierDownloadConventions: never a URL, an id, a path, a display name, a header or an error's text. With no sink installed the cost is a null check.

After a restart ​

open() loads the registry from the DownloadStore, opens the backend and settles what ended while the app was not running. Every entry starts as Queued; the backend then replays what it knows through DownloadEvents while it opens.

  • A download the backend reports finished or failed ends Complete or Failed, and is reported once as fespalier.download.reconciled (the same result and failure attributes as a transfer).
  • A download the backend reports as running goes on: it gets a transfer span with resumed true.
  • A download the backend does not mention at all ends Complete when DownloadFiles finds its file (with the size the request names, when it names one) and Failed(killed) otherwise. Without a DownloadFiles it is Failed(killed), so give the engine one when the backend does not replay finished downloads.
  • A report about an id that is not in the registry is dropped: the backend's tasks that are not ours are not ours.

The registry is FileDownloadStore (see In a widget): a JSON file, so the list survives a restart whatever the backend. A completed download keeps its registry entry, so statusOf and pathOf still answer after the next restart. Only remove, cancel and sign-out drop an entry. Calling open twice does the work once; close() unregisters from the backend and clears the observer, and the transfers the platform owns go on.

Sign-out ​

clearAccount() ends everything the signed-in person had: it cancels every download, deletes the files and the registry, moves every generation on and reports Absent for each id to the observer. Nothing an earlier attempt was still doing, and no event that arrives afterwards, reaches the next account. Call it where the app signs out, before the next person can sign in, as crateStackAccount.clear is in fespalier_cratestack.

The foreground backend ​

HttpDownloadBackend (since 0.15.0) is a DownloadBackend that runs each download as a resumable HTTP transfer in the app's own process, through the HttpTransport you give it (HttpDownloadBackend(transport:); it was client:, an http.Client, until 0.20.0), so the timeouts, proxy and certificates of the client under it apply. It is pure Dart: the base folders are yours to name, because the package depends on no plugin. A transfer that cannot reach the server, or whose certificate was refused (TransportUntrusted), ends Failed(network).

dart
Future<String> bases(DownloadBase base) async => switch (base) {
  DownloadBase.support => (await getApplicationSupportDirectory()).path,
  DownloadBase.cache => (await getApplicationCacheDirectory()).path,
  DownloadBase.documents => (await getApplicationDocumentsDirectory()).path,
};

final downloads = Downloads(
  backend: HttpDownloadBackend(transport: HttpClientTransport(client), bases: bases),
  store: store,
  files: TransferDownloadFiles(bases: bases),
);
  • What it can do, honestly. Its capabilities are pause and nothing else: no background, no notifications, no user-initiated priority, no unmetered, and a transfer does not survive the app being closed. A request with DownloadNetwork.unmetered is refused (start ends Failed(other)): the foreground cannot tell a metered network from another, and spending mobile data the app said not to is worse than not starting. It runs every start at once.
  • Where the bytes go. They arrive in <file>.part, with the server's ETag (a strong one) or Last-Modified beside it in <file>.part.etag. A file at its destination is always whole and checked: the part is moved there, atomically, only when it has the bytes and the sha256 the request names (the digest is computed in another isolate, so a large file does not take frames).
  • Who uses it. The foreground transfer is the one fespalier_maps' file packs run on (since 0.15.0): FilePacks builds an HttpTransfer per attempt and maps its result to a PackStatus (a 401 or 403 stays rejected there), so the two packages resume, verify and move a file the same way. See File packs.
  • Resuming. A pause, a network failure and an app restart leave the part. The next attempt asks for Range: bytes=<size of the part>- with If-Range: <validator>, and appends. A server that ignores Range (it answers 200) makes the transfer start again from the first byte; a 206 whose validator is not the part's, or at another offset, and a 416 to a part the server no longer has the end of, each restart once. A part with no validator and no sha256 starts again from the first byte, since nothing says it belongs to the file the server has now. After a restart the engine finds the download ended (Failed(killed)), and retry goes on from the part.
  • Cancel. cancel and remove abort the request in flight (the transport must honour abortTrigger, as HttpClientTransport and DioTransport do), so a connection that sends nothing cannot hold them up. A transfer that was running or paused loses its part; one that already ended keeps it, so retry can continue. Give the engine a TransferDownloadFiles so remove, cancel and sign-out also delete the finished file, the part and the validator.
  • Failures are values: network (no connection, a body that ended short), rejected (any other HTTP error, with the status code), unauthorized (401 or 403), sizeMismatch, hashMismatch, storage (a write or a move the device refused) and unsupported. A mismatch deletes the part.
  • The web. Where there is no dart:io, the file store answers UnsupportedError and every start ends Failed(DownloadFailure.unsupported) before any request is made; the package compiles for the web all the same. Hand the browser the URL instead.
  • Telemetry is the engine's fespalier.download.transfer span, with background false. The transfer itself reports nothing, and a URL, an id, a path or a header never leave it.

Underneath, HttpTransfer is the transfer of one file, with no state beyond the attempt (run, pause, stop), and TransferFiles the path-level file port it drives (dart:io by default). They are exported for a backend of your own; most apps never touch them.

The background backend ​

fespalier_download_background (since 0.15.0) is a second package with a DownloadBackend over background_downloader: the operating system runs the transfer (WorkManager, and user-initiated data transfer jobs on Android 14 and newer; a background URLSession on iOS), so it goes on while the app is in the background and after the app was closed. It is a package of its own, and not part of fespalier_download, because background_downloader 9.6 needs Dart 3.13 and Flutter 3.47: an app on an older Flutter keeps the foreground backend and resolves as before, and the package is left out of CI's floor job as fespalier_keypair is. The install block is in the package's README. It was written against background_downloader 9.6.4, whose sources and documentation were read, not recalled.

dart
final engine = Downloads(
  backend: BackgroundDownloaderBackend(
    notifications: const DownloadNotifications(
      running: 'Downloading',
      complete: 'Download finished',
      failed: 'Download failed',
    ),
  ),
  store: FileDownloadStore(bases: bases),
  files: TransferDownloadFiles(bases: bases),
);
await engine.open(); // before anything of your own calls FileDownloader().start()
await engine.start(
  DownloadRequest(
    id: 'manual-42',
    url: grantUrl, // short-lived, from a signed request made in the foreground
    file: const DownloadLocation(DownloadBase.support, 'manuals/manual-42.pdf'),
    bytes: 1048576,
    sha256: digest,
    priority: DownloadPriority.userInitiated,
    displayName: 'Manual 42',
  ),
);
  • What it can do, honestly. On Android and iOS: pause, resume across a restart, background, userInitiated and unmetered. notifications is true only when you gave it a running text. On the desktop it can pause and nothing else (the plugin has no operating-system background mode there, no notification and no network constraint), and a request with DownloadNetwork.unmetered is refused. On the web (and Fuchsia) every start ends Failed(DownloadFailure.unsupported) and the plugin is never touched.
  • What a request becomes. The id is the task id; the file is the plugin's base folder (support is the application support folder, cache the temporary one, documents the documents folder), a sub-folder and a file name, never an absolute path. userInitiated is priority 0, which the plugin runs as a user-initiated data transfer job on Android 14 and newer (when a notification is configured and the job service is declared, see Android and iOS setup) and as a plain high-priority task elsewhere; background is the plugin's default priority 5. Every task allows pause, which is also how the plugin continues a long download across WorkManager's nine-minute cycles (without it, or without priority 0, an Android task stops at the limit). DownloadNetwork.unmetered is requiresWiFi: on Android 9 and newer a true Wi-Fi requirement, and on iOS "no cellular", which a metered hotspot satisfies. A foreground service is never asked for: the plugin's runInForeground configuration, which would make it a dataSync foreground service, is global to the app, and this package never sets a global.
  • Only its own group. The tasks are in the plugin group fespalier.download, and callbacks are registered, tracked (trackTasksInGroup) and configured (notifications) for that group alone. The app's own FileDownloader().updates stream, a single-subscription stream that belongs to the app, is never listened to, and the plugin's global start, reset, configure and rescheduleKilledTasks are never called (the last has no group parameter and would enqueue the app's own tracked tasks again): test/no_timers_test.dart greps for each. Callbacks are registered beforeresumeFromBackground, so what finished while the app was away reaches the engine. Open the engine before the app calls FileDownloader().start() or resumeFromBackground() itself: the plugin delivers what it kept once, and an update for a group with no callbacks goes to the app's updates stream, not to the engine.
  • The plugin's database is not the registry. It prunes itself (ten days, 500 records) whenever the app asks it to, and it is the plugin's. The registry is the engine's FileDownloadStore. At open() the plugin's records for the group are replayed to the engine as hints. A task the plugin still lists as running or enqueued that the operating system no longer has is not enqueued again from the stored task (the grant it carries may have expired): the engine ends it Failed(killed) unless its file is whole, and retry starts it again, with a fresh grant.
  • The file is checked. The size and the SHA-256 of the request travel in the task (its metaData, which holds no URL, header or name), and when the plugin says a download finished, the backend reports Verifying, compares them and reports Complete or Failed(sizeMismatch) or Failed(hashMismatch). That includes a download that finished while the app was away: open() waits for those checks before the engine settles the registry.
  • Failures are values. An HTTP 401 or 403 is unauthorized (and the engine reads the code); any other HTTP error is rejected; a plugin notFound is rejected with 404; a connection error is network; a file system error is storage; a resume that could not be done is killed. Only the exception's type and code are read, never its text.
  • Notifications are off unless you configure them, per backend (notifications: or configureNotifications), for its own group only: the title is your text and the body is the download's displayName. A userInitiated request on a backend without a running notification ends Failed(notificationsRequired) and nothing is queued, because Android requires a visible notification for a user-initiated job. The package never asks for the notification permission, on either platform: that is the app's, with a rationale of its own (FileDownloader().permissions or any permission package); the plugin shows no notification while it is not granted.
  • Auth is what the request carries. The documented path is a short-lived capability: the app makes its normal signed request in the foreground, the server answers with a short-lived URL (or a download-scoped header), and that is what the task holds. The headers of a task are written to the operating system's task queue in plaintext until it ends, and a retry sends the same ones; set BackgroundOptions(retries: 0) where a stale grant must not be replayed. Never a refresh token, never a long-lived bearer. A proof per request (DPoP, fespalier_keypair_dpop) cannot be done: the plugin's native callbacks run with no access to the app's state or its plugins. A grant works here as it does anywhere (Credentials): the engine hands enqueue and resume the granted request (its URL) and the grant's headers, and after a 401 or 403 it cancels the attempt and enqueues again with a renewed grant. The backend drops the plugin's late canceled update for the attempt it cancelled (told apart from its successor by the task's creation time), so a renewed grant or a retry is not ended by the answer to its own cancel. Without a grantor the download ends Failed(unauthorized), and retry starts it again.
  • Telemetry is the engine's fespalier.download.transfer span with background true. The backend reports nothing of its own, and a URL, an id, a path, a header or an error's text never leave it.
  • What no test shows (UNCHECKED). There is no device in CI: PluginTransport, the one file that calls the plugin, is a thin layer that no test runs, and the backend's logic is tested through FakeBackgroundTransport. These depend on behaviour of the operating system and the plugin that nobody has run on a device, and are tracked in issue #158: when a notification tap arrives after a cold start relative to resumeFromBackground (the backend reports the tap; the engine hands it to the adapter, Notification taps); whether a resume with new headers or a new address sends them (the backend passes both, on the task it hands the plugin; whether the plugin uses them is not known); whether a failed update carries the server's headers, or only the status code the plugin documents in its exception; whether the plugin's database delete is scoped to the group (its source deletes record by record for the group, which was read, not run); whether an Android task that cycles every nine minutes reuses the headers it was enqueued with (which would replay an expired grant); whether the plugin's cancel keeps the partial file (a renewed grant or a retry is a cancel and a new task, so it may start again from the first byte); how an update for a group with no callbacks arrives when the app opened the plugin first; and whether the package builds for the web (the web path never calls the plugin, but the plugin's own web support was not compiled here).

Android and iOS setup ​

These steps are the plugin's, read from background_downloader 9.6.4's README and doc/, and nothing here is done for you. Follow the plugin's current README where it moves.

Android.

  • Kotlin 2.1.0 or newer in the app (org.jetbrains.kotlin.android version 2.1.0 or later in settings.gradle). The plugin itself is built with Kotlin 2.3.20 and compiles against Android SDK 36. Its README says the minimum API is 21; its build.gradle sets minSdkVersion 23, so treat 23 as the floor.

  • android.permission.POST_NOTIFICATIONS in the app's AndroidManifest.xml, for Android 13 (API 33) and newer, and a runtime request for it that the app makes (this package does not):

    xml
    <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
  • User-initiated jobs (Android 14 and newer). userInitiated requests run as user-initiated data transfer jobs, which have no execution time limit. The plugin's documentation asks for this in the app's manifest, and falls back to WorkManager when the permission is missing:

    xml
    <uses-permission android:name="android.permission.RUN_USER_INITIATED_JOBS" />
    
    <service
        android:name="com.bbflight.background_downloader.UIDTJobService"
        android:permission="android.permission.BIND_JOB_SERVICE"
        android:exported="true"
        android:foregroundServiceType="dataSync" />

    The foregroundServiceType is the plugin's own attribute on its job service: it is not a foreground service that this package starts, and the plugin's separate global switch for one (runInForeground) stays off. The plugin's own manifest declares the same service, so the merge supplies it even without this block; whether that alone is enough on a device is UNCHECKED, so declare it as the plugin's README says.

  • android:launchMode="singleTask" on the main activity, which the plugin asks for so that a notification tap reaches the running app.

  • Without user-initiated jobs, Android limits a standard task to nine minutes (two minutes for a priority below 5): every task here allows pause so that the plugin resumes across the cycles.

iOS.

  • iOS 14.0 or newer. A background URLSession needs no capability or manifest entry; the plugin's README says "no special setup is required". iOS requires HTTPS unless App Transport Security is relaxed. A background transfer must finish within the system resource timeout (four hours by default).

  • Notifications: set the notification centre's delegate in AppDelegate.swift, or the plugin's notifications do not show while the app is open:

    swift
    UNUserNotificationCenter.current().delegate = self as UNUserNotificationCenterDelegate

    iOS shows no progress bar and one running notification, which changes only when the state does.

  • Photo Library keys. The plugin can move files into the Photo Library, so Apple wants Info.plist usage strings even if you never do; this package never does. With Swift Package Manager, set BYPASS_PERMISSION_IOSADDTOPHOTOLIBRARY=1 and BYPASS_PERMISSION_IOSCHANGEPHOTOLIBRARY=1 in the environment of flutter build ios to compile that code out (CocoaPods: the -D BYPASS_PERMISSION_… Swift flags in the Podfile). flutter clean after changing them.

macOS needs com.apple.security.network.client in both entitlements files; Windows and Linux need nothing. The desktop has no operating-system background mode, so a download there stops with the app.

Credentials ​

A download is a request the operating system may send later, in another process, with no access to your app's session, its plugins or its signing key. That decides what credentials can be (since 0.15.0).

The documented path is a short-lived capability grant. Before the download starts, in the foreground, the app makes a normal signed request (through authTransport, so the session, its renewal and its proofs apply there), and the server answers with something good for one file for a few minutes: a single-file URL and/or headers scoped to that download. The transfer carries only that.

dart
Future<DownloadGrant?> grant(DownloadRequest request, {required bool renewal}) async {
  final answer = await signedClient.post(Uri.parse('https://api.example.com/files/${request.id}/grant'));
  return DownloadGrant(url: Uri.parse(urlOf(answer))); // expires in minutes, names one file
}

final downloads = Downloads(backend: backend, store: store, files: files, grantor: grant);
  • DownloadGrant is a url (replaces the request's own for this attempt) and headers (added for this attempt), either or both. Its toString() prints no field. The engine never stores it: the registry keeps the request as you made it, so a grant URL is not in fespalier_downloads.json. (A background backend may still keep the request it was handed in plaintext while it is queued, which is why a grant must expire.)
  • When it is asked. The grantor is called before each start, retry and resume (renewal: false), and once more after a 401 or 403 (renewal: true). Return null to send the request as it is. DownloadBackend.resume takes the grant's address as url: (since 0.16.0); a backend of your own accepts it, and uses it when it is not null.
  • After a 401 or 403 the engine asks once. It cancels the failed attempt, keeping the bytes the backend can continue from (the foreground backend keeps its .part, and the new attempt sends Range), and enqueues again with the new grant. A second 401 or 403 ends Failed(unauthorized); so does a grantor that throws or a grant whose URL is not a valid http or https URL. Nothing of the error is kept. There is no retry loop and no timer.
  • A grantor that cannot reach its server is not a refusal (since 0.16.0). A grantor that throws a TransportFailure (no network) or a CredentialsUnavailable (fespalier_auth's AuthUnavailable, a token that could not refresh offline, is one; an http.ClientException until 0.20.0) ends the download Failed(network), not Failed(unauthorized), so an app that reads unauthorized as "sign in again" does not sign the person out for being offline, and retry asks again. A refused certificate (TransportUntrusted) ends the download Failed(network) too, and not even a resume waits for the network on it: a certificate that does not verify is not being offline. On a resume the download stays Paused and resume returns false. Any other throw is still Failed(unauthorized).
  • A newer state wins. A cancel, remove, restart or clearAccount while the grant request is in flight drops its answer: nothing reaches the backend after it.
  • A resume goes to the address its own grant names (since 0.16.0). On resume the engine hands the backend the grant's url as well as its headers, and the bytes already kept stay: the attempt asks the new address for the rest. A grant with no url resumes at the request's own address. Before 0.16.0 the resume kept the address it had, so the grant's url was thrown away and an expired one earned a 403 first.
  • The renewal is once for each grant (since 0.16.0). A start, a retry and a resume each ask for a grant of their own and each begin with the one renewal unspent, so a download paused twice for longer than a signed address lives is renewed after each resume, not only after the first. Within one grant it is still one: a second 401 or 403 ends Failed(unauthorized), and nothing asks again by itself, so there is no loop.
  • Telemetry. The transfer span's end carries fespalier.download.regranted: true when a renewal was asked (absent otherwise). Never the grant, a URL or a header.

Plain headers are allowed, and they are stored in plaintext. DownloadRequest.headers may be kept by a backend, on disk, while a download is queued; FileDownloadStore keeps them in the registry. Use them for something that is public or expires in minutes, never for a credential you would mind finding in a backup.

Never a refresh token or a long-lived bearer in a request or a grant: anything a background backend persists can be read back from the device, and a download has no way to refresh a token itself.

Why not the session's own credentials in the background. A DPoP proof (fespalier_keypair_dpop) is bound to one method, one URL and a fresh nonce and timestamp, so it would have to be signed for each send, by a key the operating system's native callbacks cannot reach. Authenticating every send is therefore possible only where your Dart code makes the send.

The foreground backend can do exactly that. HttpDownloadBackend(credentials: credentials) takes any HttpCredentials from fespalier_transport (since 0.21.0; fespalier_http until 0.20.0; fespalier_auth's Authorizer is one). For each request it sends it asks credentials.authorize(request) (a TransportRequest for the GET) and adds the headers it returns (nothing for an origin the credentials do not cover), and after a 4xx answer it asks credentials.retry(attempt, response) (the body of a 401 or a 403, up to 64 KiB, is read first) and, if told to, sends again with authorize(request, previous: attempt), at most three sends, the way SessionTransport does. The credentials live in memory for the send. A credentials object that throws ends the download Failed(unauthorized), except a CredentialsUnavailable (AuthUnavailable: the token could not refresh offline), which ends it Failed(network) since 0.16.0, so retry goes on from the partial file once the network is back. When the refusal stands, the engine's grant renewal above still applies, so the two compose. Choose by where the transfer runs: credentials for a foreground download to your own API, a grant for anything a background backend sends or a CDN that does not know your session.

Replay. A download sends one GET, which is safe to send again, so the re-send above is allowed. A request that is not replay-safe (a POST or PUT without an Idempotency-Key, which is what an upload usually is) must be sent once: no retry, no re-send after a 401, and no pause that would cycle it. Uploads enforces exactly that; see Uploads.

In a widget ​

Three pieces connect the engine to the widget tree. FileDownloadStore is the durable registry: one JSON file, fespalier_downloads.json, in the folder your DownloadBases names for DownloadBase.support. Every change writes fespalier_downloads.json.tmp and renames it over the file, so a crash between the two leaves the previous file whole, and writes are queued in order. It is never a BoundedDataStorage (that evicts the oldest entries; a download the person asked for must not vanish). A missing, unreadable or corrupt file is an empty registry and never throws; an entry that is not valid is skipped and the rest kept. A write that fails (a full disk) is dropped and the list in memory stays right for the run, so a download never fails because its registry could not be saved. clear(), which sign-out calls, deletes the file. It needs no path_provider of its own: the base folder comes from the same DownloadBases function the backend uses. On the web, where there are no files, the registry is in memory only (every start ends Failed(unsupported) there anyway). A request's headers are written to this file in plaintext: never a long-lived credential in a header.

downloadsEngine is the provider of the Downloads engine, and it has no default: reading it without an override throws a StateError that names the override to add. Build the engine once and override the provider with it:

dart
// lib/app/startup.dart
Future<String> bases(DownloadBase base) async => switch (base) {
  DownloadBase.support => (await getApplicationSupportDirectory()).path,
  DownloadBase.cache => (await getTemporaryDirectory()).path,
  DownloadBase.documents => (await getApplicationDocumentsDirectory()).path,
};

Future<List<Override>> startup() async => [
  downloadsEngine.overrideWithValue(
    Downloads(
      backend: HttpDownloadBackend(transport: HttpClientTransport(http.Client()), bases: bases),
      store: FileDownloadStore(bases: bases),
      files: TransferDownloadFiles(bases: bases),
    ),
  ),
];

downloads is a NotifierProvider holding the status of every download by id, and downloadStatus(id) is an auto-dispose family that rebuilds a widget only when that one download changes (Absent when none is known). Watching downloads opens the engine (so the restart settling of After a restart happens) and fills the engine's one observe slot; when the provider is disposed the observer is cleared and the engine is closed (the transfers the platform owns go on). An engine that cannot open (a store that threw) leaves downloads empty. The providers hold no timer and no listener of their own, and the engine itself still imports no Riverpod. Because the slot is the provider's, do not call observe on an engine that downloads watches.

dart
class ManualTile extends ConsumerWidget {
  const ManualTile({super.key});

  static final request = DownloadRequest(
    id: 'manual-42',
    url: Uri.parse('https://files.example.com/manual-42.pdf'),
    file: const DownloadLocation(DownloadBase.support, 'manuals/manual-42.pdf'),
  );

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    ref.watch(downloads); // opens the engine; the page is the owner
    final status = ref.watch(downloadStatus('manual-42'));
    final engine = ref.read(downloadsEngine);
    return switch (status) {
      Running(:final received, :final total) => LinearProgressIndicator(
        value: total == null ? null : received / total,
      ),
      Complete() => const Text('Downloaded'),
      Failed() => TextButton(
        onPressed: () => engine.retry('manual-42'),
        child: const Text('Try again'),
      ),
      _ => FilledButton(
        onPressed: () => engine.start(request),
        child: const Text('Download'),
      ),
    };
  }
}

Acting goes through the engine (ref.read(downloadsEngine).start(...)); the state comes back through downloads. Call clearAccount() where the app signs out, as Sign-out says.

Notification taps ​

Since 0.15.0. A tap on a download's notification (the progress notification a background backend shows, or its completion) can open a typed route, with the same shape as fespalier_push: the package ships an adapter, and the app configures it in main().

dart
// lib/main.dart
Future<void> main() {
  FespalierDownload.configure( // before the adapters run
    backend: BackgroundDownloaderBackend(),
    store: FileDownloadStore(bases: bases),
    notifications: const DownloadNotifications(running: 'Downloading', complete: 'Done'),
    route: (tap) => switch (tap.request?.id) {
      final id? when id.startsWith('manual-') => DownloadTarget.to(ManualRoute(id: id)),
      _ => null,
    },
  );
  return AppMain.run();
}
yaml
# pubspec.yaml
fespalier:
  adapters: [fespalier_download]
  • FespalierDownload.configure builds the engine. It takes the backend and the store (and, as Downloads does, files, grantor and clock), and the adapter binds that engine to downloadsEngine: the app's startup() does not override the provider. The notifications are handed to the backend once the engine is open. The package never asks for the notification permission.
  • route maps a DownloadTap to a DownloadTarget. The tap holds the download's id, the kind (body or action), the status the engine had and the request it holds for that id (null for an id it does not know: map it to nothing). DownloadTarget.to(route) takes a typed route and DownloadOpen.go (the default) or push. null, no route: and a route that throws all mean: the tap opens the app and navigates nowhere (a throw is reported). It must be quick and synchronous.
  • A cold start is the initial location. The adapter's launch() opens the engine, so the first frame waits for the registry file and the backend's own start. The tap the backend replays while it opens becomes an InboundLaunch marked NavigationSource.notification, and the guards still run. Which comes first on a cold start, the tap or the backend's replay of its tasks, is UNCHECKED on a device (issue #158): if the plugin delivers the tap after open finished, it arrives as a warm tap, which goes from the first page. Both work; the difference is whether the router starts at the target.
  • A warm tap is a navigation. In attach(router, container) the adapter takes the engine's one tap slot (Downloads.observeTaps) and calls router.go (or push) inside navigateFrom(NavigationSource.notification, ...). The slot is the adapter's: do not call observeTaps on that engine. Taps heard between launch() and attach are replayed in order. The cold-start tap seen again as a warm tap is dropped once; the same notification tapped again later opens again.
  • Unconfigured, it says so once. A pubspec that lists fespalier_download under adapters: and a main() that never called configure reports one FlutterError and the adapter does nothing; it never throws. AppMain.root() in a widget test runs no main(), so the test calls configure with the fakes first (FakeDownloadBackend(replayTaps: [...]) plays a tap that started the app, backend.tap(id) one while it runs); FespalierDownload.debugReset() clears it.
  • Telemetry is one span, fespalier.download.open, from the tap to the navigation call, with fespalier.download.routed (a boolean) at its end. It carries no id, path, URL or name.

Uploads ​

fespalier_download_background also uploads a file (since 0.15.0), over background_downloader 9.6.4's UploadTask (multipart and binary; MultiUploadTask is not used). The operating system runs the upload, so it goes on after the app was closed. It is background only in this release: HttpDownloadBackend downloads, and a foreground upload over an HttpTransport (a streamed, abortable request body, with its own replay rules) is not built. On the web every upload ends Failed(unsupported).

dart
final uploads = Uploads(
  backend: BackgroundUploaderBackend(
    notifications: const DownloadNotifications(running: 'Uploading', failed: 'Upload failed'),
  ),
  store: FileUploadStore(bases: bases),
  grantor: grantUploadUrl, // optional: a short-lived address, see below
);
await uploads.open(); // in startup(), next to the downloads engine
await uploads.start(
  UploadRequest(
    id: 'receipt-7',
    url: Uri.parse('https://api.example.com/receipts'),
    file: const DownloadLocation(DownloadBase.documents, 'outbox/receipt-7.jpg'),
    fileField: 'receipt',
    fields: {'orderId': '7'},
    headers: {'Idempotency-Key': 'receipt-7'},
  ),
);
  • A sibling engine, not a mode of Downloads. The two disagree on what a restart means, and on what the engine may delete. A download whose file is whole is complete; an upload whose file is still there has told you nothing about the server. Downloads deletes the file on remove, cancel and sign-out and settles an unmentioned entry from the file; Uploads has no file port at all, so it can never delete the file you sent, and an entry nobody mentions is Failed(killed), never Complete. Uploads keeps the rest of the engine's guarantees: a generation per id that drops a late answer of an old attempt, a durable registry that is never evicted (FileUploadStore, the file fespalier_uploads.json beside the downloads' one, written by atomic rename, wiped by clearAccount()), one observe slot, no timer, no listener and no Riverpod import. The providers are uploadsEngine, uploads and uploadStatus.

  • UploadRequest is an id, a URL, a file as a DownloadBase and a relative path (the same validation as a download: no absolute path, no .., no \, no NUL), a method (UploadMethod.post or put), an encoding (UploadEncoding.multipart, the default, with fileField and string fields; or binary, the file as the whole body, with no fields), headers, a network and a priority. isValid checks all of it. Its toString() prints no field. The file must exist when the upload starts, and nothing here moves or deletes it.

  • Statuses are DownloadStatus. Queued, Waiting, Running(sent, total), then Complete(file, bytes), where file is the file that was sent (untouched) and bytes its size, or Failed. An upload never shows Paused or Verifying: the plugin cannot pause an upload. The server's response body is not reported.

  • Replay safety is a rule, not an option. An upload is replay safe when it carries an Idempotency-Key header, fespalier_transport's rule for a write (HttpWrites.isWrite; UploadRequest.replaySafe). A POST or PUT without one may already be on the server, so it is sent once:

    SituationReplay safe (has an Idempotency-Key)Not replay safe
    The platform retries after a failureBackgroundOptions(retries:)None: the task is made with retries: 0
    Pause and resumeThe plugin cannot pause an uploadThe same, so no pause cycle can send it twice
    401 or 403One renewed grant, then sent againFailed(unauthorized), no renewal
    The app was killed, or no answer cameFailed(killed); retry sends it againFailed(killed); retry and start refuse
    The response was lost (Failed(network))retry sends it againThe same: the server may have it (outcomeUnknown(id) is true)
    A 5xx, 408 or 499 (since 0.16.0)Failed(other); retry sends it againFailed(other): the same, the server may have run it
    Another 4xx (400, 404, 413, ...)Failed(rejected); retry sends againFailed(rejected): the server refused it, retry sends again

    For a non-replay-safe upload, Failed(network), Failed(killed) and Failed(other) mean the outcome is unknown: Uploads.outcomeUnknown(id) says so, retry returns false and start of the same id does nothing. Ask your server whether it has the upload, then remove(id) and start it again if you decide to: that decision is yours, never the engine's. A failure that is the server's refusal (a 4xx other than 408 and 499, which end rejected; unauthorized) or that happened before anything was sent (storage, invalidRequest) may be retried. An upload to a pre-signed PUT address is a write too by this rule; add an Idempotency-Key header (any value, the same on every attempt) when sending it again is harmless.

    A 5xx is not a refusal (since 0.16.0). A gateway's 502 or 504, a 503 from a proxy, or the server's own 500 after it committed, says nothing about whether the write ran. Uploads ends such an upload Failed(other) (an unknown outcome), and so a 408 (the server gave up on a request it was already handling) and a 499 (the connection closed while the server went on). Only a client error proves the server did not apply the write, and it ends Failed(rejected). Before 0.16.0 every status of 400 and above except 401 and 403 ended Failed(rejected), and retry sent a non-replay-safe write again after a 500 or 504, which could run it twice. failureOf, which also maps a download's failure, still says rejected for a 5xx: a download is a read, and asking again is harmless.

  • The registry is what remembers a write may have been sent (since 0.16.0). An upload is handed to the platform only after its entry is saved: when UploadStore.put throws (a full disk), the upload ends Failed(storage) and nothing is sent; retry tries again, and it is not an unknown outcome because nothing left the device. FileUploadStore no longer reads a file it cannot read as an empty registry: a file that exists and is not a registry this version knows (an I/O error, text that is not JSON, a version it does not know) makes load and put throw UploadStoreException, and the file is left as it is, to be read again on the next call. Uploads.open() still succeeds, but an upload without an Idempotency-Key is then not started (Failed(storage)) until the next open() reads the registry or clearAccount() deletes it, since nothing says which writes the unreadable registry held; an upload with a key is not affected. A missing file is an empty registry, and an entry that is not valid is still skipped. remove and clear are best effort: a cancel or a sign-out never fails because the registry could not be written. In debug builds Uploads prints one line for each case (fespalier-troubleshooting quotes them). Before 0.16.0 FileUploadStore swallowed a write that failed and read an unreadable file as empty, so the upload was sent with no record that it had been, and start sent it again after a restart. FileDownloadStore is unchanged: a download is a read.

  • Auth is a grant, as for a download (Credentials). Uploads(grantor:) takes a function from an UploadRequest to a DownloadGrant: a short-lived url (a pre-signed upload address) and/or headers for one attempt. The headers are written to the operating system's task queue in plaintext until the task ends: never a refresh token or a long-lived bearer. The registry stores the request, never the grant.

  • Its own group. Upload tasks are in the plugin group fespalier.upload, apart from the downloads' fespalier.download, so neither engine's callbacks see the other's updates. The same rules hold: callbacks, tracking and notifications for the group only, registered before resumeFromBackground; never the app's updates stream and never the plugin's global start, reset, configure or rescheduleKilledTasks; the plugin's database is not the registry. PluginUploadTransport is in the one file that calls the plugin.

  • Notifications and priority. Off unless configured, as for downloads. DownloadPriority.userInitiated is priority 0 (a user-initiated data transfer job on Android 14 and newer, which needs a visible notification: without a running text the upload ends Failed(notificationsRequired)). On Android a long upload that is not a user-initiated job meets WorkManager's limit (about nine minutes), and since an upload cannot pause, the plugin's source ends it as a connection failure (Failed(network), an unknown outcome for an upload without a key; read in the source, not run).

  • Telemetry is a namespace of its own, so no download name changed: fespalier.upload.transfer (start attributes background, network, priority, encoding and replay_safe; end attributes result, failure, and regranted only when true) and fespalier.upload.reconciled, as FespalierUploadConventions, pinned by test/upload_telemetry_test.dart. Constants, booleans and enum names only: never a URL, an id, a path, a field name or value, a header or an error's text.

  • What no test shows (UNCHECKED). There is no device in CI, and PluginUploadTransport is run by no test. These are not known and are not claimed: that the operating system sends an upload at all with the app closed; how your server reads the multipart body the plugin writes (field order, the Content-Type of the file part, large files) and what a binary upload sends as Content-Disposition; whether a non-2xx answer reaches the app as the status code the mapping reads; how long an upload may run on Android before WorkManager ends it, with and without a user-initiated job; whether WorkManager or the plugin sends an upload again on its own after the system stops the worker, beyond the task's retries; what is delivered when the downloads engine and the uploads engine open one after the other (the plugin's resumeFromBackground is global, so the first engine's call can deliver the second group's updates before it has callbacks; those are then settled from the registry as Failed(killed), never as a second send); and whether the platform setup has a step of its own for uploads (the plugin's README, SETUP_ANDROID.md and doc/ name none beyond the download steps above, which was read, not run).

  • Test it with FakeUploadBackend (emit, replay:), MemoryUploadStore and uploadTestOverrides, or, for the backend itself, FakeUploadTransport in package:fespalier_download_background/testing.dart. The pure uploadTaskOf shows exactly what the plugin is handed.

Testing ​

package:fespalier_download/testing.dart has the fakes, which need no network, platform or disk:

  • FakeDownloadBackend: records what is asked (enqueued, paused, resumed, resumeUrls, cancelled, authorizations), answers as accepts and capabilities say, and plays the platform through emit(id, status, httpStatus:) and tap(id) once a listener has called open. replay: {id: status} is what the platform reports at open, to test a restart, and replayTaps: [(id, kind)] the notification taps it delivers then, to test a cold start.
  • MemoryDownloadStore: a registry in memory (entries, clearCalls).
  • FakeDownloadFiles: files as a map of sizes (put, deleted).
  • downloadTestOverrides(backend:, store:, files:): the overrides that bind downloadsEngine (so downloads and downloadStatus) to an engine over the fakes, for a ProviderContainer or ProviderScope. The test keeps the backend and plays the platform with emit; disposing the container closes the engine, as in an app.
  • FakeTransferFiles: the path-level files of a transfer in memory (putBytes, bytesOf, renames, failWrites, failRename, failDelete, unsupported for the web), to test HttpDownloadBackend with a FakeTransport (package:fespalier_transport/testing.dart).

package:fespalier_download_background/testing.dart has FakeBackgroundTransport, which plays background_downloader for one group so that BackgroundDownloaderBackend runs without a plugin or a device: the test plays the operating system with emitStatus, emitProgress and tap, seeds the plugin's records, active ids and undelivered updates, and reads calls (the order: callbacks registered before resumeFromBackground), enqueued, paused, resumed, cancelled, plans and leaked (an update for a group with no callbacks, which the plugin would send to the app's updates stream). Build the backend with transport:, platform: and files: (a FakeTransferFiles). The mapping functions (downloadTaskOf, downloadStatusOf, failureOf, progressOf) are pure and public. None of this runs the plugin.

package:fespalier_download_background/testing.dart also has the upload fakes, FakeUploadBackend, MemoryUploadStore, uploadTestOverrides and FakeUploadTransport; see Uploads.

The model needs no widget: DownloadRequest, DownloadLocation and DownloadStatus are plain values, so a unit test builds them and checks isValid and equality directly. The package's own tests do exactly that, including every refusal of isValid and a check that no toString leaks a field.

Released under the MIT License.