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:
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.
DownloadBaseissupport(the application support folder, the default),cache(the platform may empty it) ordocuments. A backend resolves a location to a path for the moment of use and the path is never stored. DownloadLocation.isValidrefuses 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.isValidis 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 invalidfile, a negativebytes, asha256that is not 64 hex digits, or an empty header name.networkisDownloadNetwork.anyorunmetered(Wi-Fi only), andpriorityisDownloadPriority.background(the default) oruserInitiated(the person asked just now and watches it).toString()of a request prints no field: onlyDownloadRequest. 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. ADownloadLocationprints 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:
| Status | Meaning |
|---|---|
Absent | No download is known with that id. |
Queued | Handed 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. |
Verifying | Received; the size and the digest are being checked. |
Complete(file, bytes) | The file is in place and checked. |
Failed(DownloadFailure) | Ended without a file. |
Cancelled | Stopped 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.
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 Completestartnever throws for a bad request. An invalid request, or afilethat is not a safe relative path, endsFailed(invalidRequest)and is not registered, so there is nothing toretry: start it again once it is right. AuserInitiatedrequest on a backend whose capabilities have no notifications endsFailed(notificationsRequired). Starting an id that is already queued, running, paused or complete does nothing: it is the same download. Callingstartbeforeopenis aStateError.- The backend reports, the engine does not guess.
pauseandresumeask the backend and return what it answered; the status moves when the backend says so (Paused,Running), except that a resumed download showsQueuedat once. A backend that cannot pause (itscapabilities.pauseis false) makespausereturn false. retrystarts aFaileddownload again from the request the registry holds. AftersizeMismatchorhashMismatchit deletes the file first; after a network failure it keeps the bytes so the backend can go on from them.statusesandstatusOfare the whole state:statusOfanswersAbsentfor an id it does not know. A 401 or 403 that the backend reports with a failed status endsFailed(unauthorized).observeis one owner slot. A second call replaces the first,observe(null)clears it, andclose()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
startwhose 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 forcancelandremovethemselves: 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.transferspan from its start to the state it ends in (result, andfailurewhen it failed). Its attributes are only the constants ofFespalierDownloadConventions: 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
CompleteorFailed, and is reported once asfespalier.download.reconciled(the sameresultandfailureattributes as a transfer). - A download the backend reports as running goes on: it gets a transfer span with
resumedtrue. - A download the backend does not mention at all ends
CompletewhenDownloadFilesfinds its file (with the size the request names, when it names one) andFailed(killed)otherwise. Without aDownloadFilesit isFailed(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).
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
pauseand nothing else: no background, no notifications, no user-initiated priority, nounmetered, and a transfer does not survive the app being closed. A request withDownloadNetwork.unmeteredis refused (startendsFailed(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'sETag(a strong one) orLast-Modifiedbeside 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 thebytesand thesha256the 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):FilePacksbuilds anHttpTransferper attempt and maps its result to aPackStatus(a 401 or 403 staysrejectedthere), 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>-withIf-Range: <validator>, and appends. A server that ignoresRange(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 nosha256starts 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)), andretrygoes on from the part. - Cancel.
cancelandremoveabort the request in flight (the transport must honourabortTrigger, asHttpClientTransportandDioTransportdo), 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, soretrycan continue. Give the engine aTransferDownloadFilessoremove,canceland 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) andunsupported. A mismatch deletes the part. - The web. Where there is no
dart:io, the file store answersUnsupportedErrorand every start endsFailed(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.transferspan, withbackgroundfalse. 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.
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,
userInitiatedandunmetered.notificationsis true only when you gave it arunningtext. 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 withDownloadNetwork.unmeteredis refused. On the web (and Fuchsia) every start endsFailed(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 (
supportis the application support folder,cachethe temporary one,documentsthe documents folder), a sub-folder and a file name, never an absolute path.userInitiatedis 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;backgroundis 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.unmeteredisrequiresWiFi: 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'srunInForegroundconfiguration, which would make it adataSyncforeground 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 ownFileDownloader().updatesstream, a single-subscription stream that belongs to the app, is never listened to, and the plugin's globalstart,reset,configureandrescheduleKilledTasksare never called (the last has no group parameter and would enqueue the app's own tracked tasks again):test/no_timers_test.dartgreps for each. Callbacks are registered beforeresumeFromBackground, so what finished while the app was away reaches the engine. Open the engine before the app callsFileDownloader().start()orresumeFromBackground()itself: the plugin delivers what it kept once, and an update for a group with no callbacks goes to the app'supdatesstream, 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. Atopen()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 itFailed(killed)unless its file is whole, andretrystarts 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 reportsVerifying, compares them and reportsCompleteorFailed(sizeMismatch)orFailed(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 isrejected; a pluginnotFoundisrejectedwith 404; a connection error isnetwork; a file system error isstorage; a resume that could not be done iskilled. Only the exception's type and code are read, never its text. - Notifications are off unless you configure them, per backend (
notifications:orconfigureNotifications), for its own group only: the title is your text and the body is the download'sdisplayName. AuserInitiatedrequest on a backend without arunningnotification endsFailed(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().permissionsor 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 handsenqueueandresumethe 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 latecanceledupdate for the attempt it cancelled (told apart from its successor by the task's creation time), so a renewed grant or aretryis not ended by the answer to its own cancel. Without a grantor the download endsFailed(unauthorized), andretrystarts it again. - Telemetry is the engine's
fespalier.download.transferspan withbackgroundtrue. 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 throughFakeBackgroundTransport. 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 toresumeFromBackground(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 aretryis 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.androidversion2.1.0or later insettings.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; itsbuild.gradlesetsminSdkVersion 23, so treat 23 as the floor.android.permission.POST_NOTIFICATIONSin the app'sAndroidManifest.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).
userInitiatedrequests 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
foregroundServiceTypeis 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
URLSessionneeds 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:swiftUNUserNotificationCenter.current().delegate = self as UNUserNotificationCenterDelegateiOS shows no progress bar and one
runningnotification, which changes only when the state does.Photo Library keys. The plugin can move files into the Photo Library, so Apple wants
Info.plistusage strings even if you never do; this package never does. With Swift Package Manager, setBYPASS_PERMISSION_IOSADDTOPHOTOLIBRARY=1andBYPASS_PERMISSION_IOSCHANGEPHOTOLIBRARY=1in the environment offlutter build iosto compile that code out (CocoaPods: the-D BYPASS_PERMISSION_…Swift flags in thePodfile).flutter cleanafter 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.
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);DownloadGrantis aurl(replaces the request's own for this attempt) andheaders(added for this attempt), either or both. ItstoString()prints no field. The engine never stores it: the registry keeps the request as you made it, so a grant URL is not infespalier_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
grantoris called before eachstart,retryandresume(renewal: false), and once more after a 401 or 403 (renewal: true). Return null to send the request as it is.DownloadBackend.resumetakes the grant's address asurl:(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 sendsRange), and enqueues again with the new grant. A second 401 or 403 endsFailed(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 aCredentialsUnavailable(fespalier_auth'sAuthUnavailable, a token that could not refresh offline, is one; anhttp.ClientExceptionuntil 0.20.0) ends the downloadFailed(network), notFailed(unauthorized), so an app that readsunauthorizedas "sign in again" does not sign the person out for being offline, andretryasks again. A refused certificate (TransportUntrusted) ends the downloadFailed(network)too, and not even aresumewaits for the network on it: a certificate that does not verify is not being offline. On aresumethe download staysPausedandresumereturns false. Any other throw is stillFailed(unauthorized). - A newer state wins. A
cancel,remove, restart orclearAccountwhile 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
resumethe engine hands the backend the grant'surlas well as its headers, and the bytes already kept stay: the attempt asks the new address for the rest. A grant with nourlresumes at the request's own address. Before 0.16.0 the resume kept the address it had, so the grant'surlwas 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: truewhen 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:
// 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.
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().
// 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();
}# pubspec.yaml
fespalier:
adapters: [fespalier_download]FespalierDownload.configurebuilds the engine. It takes thebackendand thestore(and, asDownloadsdoes,files,grantorandclock), and the adapter binds that engine todownloadsEngine: the app'sstartup()does not override the provider. Thenotificationsare handed to the backend once the engine is open. The package never asks for the notification permission.routemaps aDownloadTapto aDownloadTarget. The tap holds the download'sid, thekind(bodyoraction), thestatusthe engine had and therequestit holds for that id (null for an id it does not know: map it to nothing).DownloadTarget.to(route)takes a typed route andDownloadOpen.go(the default) orpush.null, noroute:and aroutethat 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 anInboundLaunchmarkedNavigationSource.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 afteropenfinished, it arrives as a warm tap, whichgoes 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 callsrouter.go(orpush) insidenavigateFrom(NavigationSource.notification, ...). The slot is the adapter's: do not callobserveTapson that engine. Taps heard betweenlaunch()andattachare 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_downloadunderadapters:and amain()that never calledconfigurereports oneFlutterErrorand the adapter does nothing; it never throws.AppMain.root()in a widget test runs nomain(), so the test callsconfigurewith 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, withfespalier.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).
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.Downloadsdeletes the file onremove,canceland sign-out and settles an unmentioned entry from the file;Uploadshas no file port at all, so it can never delete the file you sent, and an entry nobody mentions isFailed(killed), neverComplete.Uploadskeeps 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 filefespalier_uploads.jsonbeside the downloads' one, written by atomic rename, wiped byclearAccount()), oneobserveslot, no timer, no listener and no Riverpod import. The providers areuploadsEngine,uploadsanduploadStatus.UploadRequestis an id, a URL, a file as aDownloadBaseand a relative path (the same validation as a download: no absolute path, no.., no\, no NUL), a method (UploadMethod.postorput), an encoding (UploadEncoding.multipart, the default, withfileFieldand stringfields; orbinary, the file as the whole body, with no fields), headers, a network and a priority.isValidchecks all of it. ItstoString()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), thenComplete(file, bytes), wherefileis the file that was sent (untouched) andbytesits size, orFailed. An upload never showsPausedorVerifying: 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-Keyheader,fespalier_transport's rule for a write (HttpWrites.isWrite;UploadRequest.replaySafe). APOSTorPUTwithout one may already be on the server, so it is sent once:Situation Replay safe (has an Idempotency-Key)Not replay safe The platform retries after a failure BackgroundOptions(retries:)None: the task is made with retries: 0Pause and resume The plugin cannot pause an upload The same, so no pause cycle can send it twice 401 or 403 One renewed grant, then sent again Failed(unauthorized), no renewalThe app was killed, or no answer came Failed(killed);retrysends it againFailed(killed);retryandstartrefuseThe response was lost ( Failed(network))retrysends it againThe same: the server may have it ( outcomeUnknown(id)is true)A 5xx,408or499(since 0.16.0)Failed(other);retrysends it againFailed(other): the same, the server may have run itAnother 4xx(400,404,413, ...)Failed(rejected);retrysends againFailed(rejected): the server refused it,retrysends againFor a non-replay-safe upload,
Failed(network),Failed(killed)andFailed(other)mean the outcome is unknown:Uploads.outcomeUnknown(id)says so,retryreturns false andstartof the same id does nothing. Ask your server whether it has the upload, thenremove(id)andstartit again if you decide to: that decision is yours, never the engine's. A failure that is the server's refusal (a4xxother than408and499, which endrejected;unauthorized) or that happened before anything was sent (storage,invalidRequest) may be retried. An upload to a pre-signedPUTaddress is a write too by this rule; add anIdempotency-Keyheader (any value, the same on every attempt) when sending it again is harmless.A
5xxis not a refusal (since 0.16.0). A gateway's502or504, a503from a proxy, or the server's own500after it committed, says nothing about whether the write ran.Uploadsends such an uploadFailed(other)(an unknown outcome), and so a408(the server gave up on a request it was already handling) and a499(the connection closed while the server went on). Only a client error proves the server did not apply the write, and it endsFailed(rejected). Before 0.16.0 every status of 400 and above except 401 and 403 endedFailed(rejected), andretrysent a non-replay-safe write again after a500or504, which could run it twice.failureOf, which also maps a download's failure, still saysrejectedfor a5xx: 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.putthrows (a full disk), the upload endsFailed(storage)and nothing is sent;retrytries again, and it is not an unknown outcome because nothing left the device.FileUploadStoreno 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) makesloadandputthrowUploadStoreException, and the file is left as it is, to be read again on the next call.Uploads.open()still succeeds, but an upload without anIdempotency-Keyis then not started (Failed(storage)) until the nextopen()reads the registry orclearAccount()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.removeandclearare best effort: acancelor a sign-out never fails because the registry could not be written. In debug buildsUploadsprints one line for each case (fespalier-troubleshootingquotes them). Before 0.16.0FileUploadStoreswallowed a write that failed and read an unreadable file as empty, so the upload was sent with no record that it had been, andstartsent it again after a restart.FileDownloadStoreis unchanged: a download is a read.Auth is a grant, as for a download (Credentials).
Uploads(grantor:)takes a function from anUploadRequestto aDownloadGrant: a short-livedurl(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 beforeresumeFromBackground; never the app'supdatesstream and never the plugin's globalstart,reset,configureorrescheduleKilledTasks; the plugin's database is not the registry.PluginUploadTransportis in the one file that calls the plugin.Notifications and priority. Off unless configured, as for downloads.
DownloadPriority.userInitiatedis priority 0 (a user-initiated data transfer job on Android 14 and newer, which needs a visible notification: without arunningtext the upload endsFailed(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 attributesbackground,network,priority,encodingandreplay_safe; end attributesresult,failure, andregrantedonly when true) andfespalier.upload.reconciled, asFespalierUploadConventions, pinned bytest/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
PluginUploadTransportis 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, theContent-Typeof the file part, large files) and what a binary upload sends asContent-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'sretries; what is delivered when the downloads engine and the uploads engine open one after the other (the plugin'sresumeFromBackgroundis 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 asFailed(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.mdanddoc/name none beyond the download steps above, which was read, not run).Test it with
FakeUploadBackend(emit,replay:),MemoryUploadStoreanduploadTestOverrides, or, for the backend itself,FakeUploadTransportinpackage:fespalier_download_background/testing.dart. The pureuploadTaskOfshows 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 asacceptsandcapabilitiessay, and plays the platform throughemit(id, status, httpStatus:)andtap(id)once a listener has calledopen.replay: {id: status}is what the platform reports atopen, to test a restart, andreplayTaps: [(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 binddownloadsEngine(sodownloadsanddownloadStatus) to an engine over the fakes, for aProviderContainerorProviderScope. The test keeps the backend and plays the platform withemit; 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,unsupportedfor the web), to testHttpDownloadBackendwith aFakeTransport(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.