flutter_app_updater 3.2.0
flutter_app_updater: ^3.2.0 copied to clipboard
Verified app updates with default or custom UI for stores, Android markets, packages, and desktop installers.
Flutter App Updater #
English | 简体中文
Flutter App Updater checks trusted release manifests and delivers updates with a ready-to-use dialog. Use the default UI, replace it with your own, or use the UI-independent core APIs.
Supported platforms and actions:
- Android: Google Play URL, Chinese Android markets, APK download, APK install, and download then install.
- iOS: App Store URL.
- macOS: Mac App Store URL, DMG and ZIP installer download then open.
- Windows: MSIX, MSI, and EXE installer download then open.
Install #
Version 3.2 adds default/custom update UI and automatic runtime metadata while preserving 3.1 constructors, core APIs, and three-branch exhaustive event switches. See the 3.1-to-3.2 upgrade guide for optional UI adoption and cache behavior. The manifest schema remains v3.
dependencies:
flutter_app_updater: ^3.2.0
This is the installation constraint for the 3.2.0 release once published.
Quick Start #
Create an updater once, then call checkAndShow(context) from a button inside
your MaterialApp. It shows confirmation, download progress, cancellation,
retry, and Android installation-permission recovery. No action starts before
the user confirms.
import 'package:flutter/material.dart';
import 'package:flutter_app_updater/flutter_app_updater.dart';
final updater = await AppUpdater.fromManifest(
manifestUrl: Uri.parse('https://example.com/app-updates.json'),
trustedPublicKeys: trustedManifestPublicKeys,
);
await updater.checkAndShow(context);
Version, build number, application identity, platform, and current process
architecture are read automatically. channel defaults to stable; pass
channel: 'beta' only for another release channel. trustedManifestPublicKeys
maps key IDs to Base64-encoded raw 32-byte Ed25519 public keys. Self-hosted
updates require trusted keys; omit trustedPublicKeys for bare official-store
or Android-market manifests. Initialization captures runtime information once,
without fetching a manifest or executing an update. Native lookup or validation
failures fail the initialization Future; handle them separately from structured
check/action results. No fabricated version fallback is used.
Downloads use an application-private directory by default.
Android private APK installation also requires the host permission described in
Self-Hosted Android APK; store delivery does not need
that permission. Sandboxed macOS hosts must enable
com.apple.security.network.client in both Debug and Release entitlements.
The default dialog follows your theme and uses Chinese for zh locales,
English otherwise. Optional updates can be deferred. Required updates block
back navigation and dismissal, including after a store or installer handoff:
the user must finish updating and reopen the app. The host still owns enforcement
outside the dialog. A successful action reports a download or handoff, not a
completed installation.
Required flows can check again after a failure or handoff. Refreshing clears
stale actions but preserves the dismissal barrier even if the next check fails,
finds no update, or returns an optional release. When multiple verified actions
are available, the user may select another update method; selecting it never
executes it without a new confirmation. Custom UIs can use canCheck,
isRequired, selectedAction, and selectAction on UpdateController.
The returned UpdateFlowResult? describes the latest check, after dismissal;
it is null if the caller was unmounted or closed the dialog before checking
finished. Repeated calls on the same updater share the active dialog. Checks and
settings recovery never automatically execute an update.
expectedAppId defaults to the installed package identity and can be overridden.
Windows uses the executable InternalName; set expectedAppId when it differs
from the publisher's manifest app ID. The remote manifest must still match the
resolved identity; another application fails with APP_ID_MISMATCH before
selection or execution.
Existing v2 integrations should follow the v2-to-v3 migration guide. The complete transport, signature, artifact, and platform trust boundaries are documented in the security model.
Custom UI #
Configure a reusable UI during initialization with withUI(builder: ...):
final updateUI = (await AppUpdater.fromManifest(
manifestUrl: Uri.parse('https://example.com/app-updates.json'),
trustedPublicKeys: trustedManifestPublicKeys,
)).withUI(
builder: (context, controller) => MyUpdateDialog(controller: controller),
);
// Reuse from a startup prompt, settings button, or another update entry point.
await updateUI.checkAndShow(context);
MyUpdateDialog is your widget; it receives the same UpdateController as the
default dialog. withUI returns an immutable AppUpdaterWithUI, stores only
the builder, and does not check or show anything. Each show creates fresh state
and disposes its controller on dismissal. The original updater is unchanged;
updateUI.updater.checkAndShow(context) still shows the default UI and
updateUI.updater also exposes the core APIs. Concurrent calls through any UI
configuration or the original updater share one active dialog; the first call
determines its builder.
You can still supply builder directly for one show, including to override a
configured updateUI.checkAndShow(context, builder: ...). That override does not
change its saved builder. Either form rebuilds when the shared controller
changes, so your UI needs no separate download or retry state machine. This
minimal example builds confirmation and dismissal:
await updater.checkAndShow(
context,
builder: (context, controller) => AlertDialog(
title: const Text('App update'),
content: Text(controller.update?.candidate.releaseNotes ??
'Checking for updates…'),
actions: [
if (controller.canDismiss)
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Close'),
),
if (controller.canPerform)
FilledButton(
onPressed: controller.perform,
child: const Text('Update / Retry'),
),
],
),
);
This minimal example covers confirmation and dismissal. Use phase,
checkResult, actionResult, progress, canCancel, and
canOpenInstallSettings for complete feedback and recovery. Call cancel()
or openInstallSettings() from explicit user gestures. After settings, another
perform() confirmation rechecks permission. A missing Android manifest
permission requires rebuilding the host; settings cannot fix it. Custom
executors own permission preflight unless you provide installPermission.
For Android download-and-install and desktop installers, isHandingOff marks
native verification and opening;
canCancel becomes false and the default dialog removes download cancellation.
Back navigation remains guarded; custom close buttons must honor canDismiss.
For a custom streaming executor, its first completion result is authoritative.
Later events and errors thrown while canceling its stream during cleanup cannot
replace that result. Finish failure-prone work before emitting completion, and
await stream completion or subscription cancellation to know cleanup has ended.
For a sheet or embedded page, create UpdateController(updater: updater), call
check(), render it with ListenableBuilder, and call dispose() when the UI
goes away. UpdateDialog(controller: controller) is also reusable directly.
The example's Update UI tab demonstrates default and custom dialogs using
simulated execution.
For fully headless integration, retain checkAndPrepare() and
performRecommended() / performRecommendedStream(). Checking never presents
UI or executes an action:
final prepared = await updater.checkAndPrepare();
if (prepared case PreparedUpdateAvailable()) {
// Call only after your own user confirmation or explicit host policy.
final actionResult = await updater.performRecommended(prepared);
debugPrint('${actionResult.isSuccess}: ${actionResult.code}');
}
Advanced configuration and test injection #
Architecture is detected from the current process ABI using arm, arm64,
x86, or x64. Unknown ABIs match only universal releases. Override legacy
server naming through advanced options:
final updater = await AppUpdater.fromManifest(
manifestUrl: manifestUrl,
trustedPublicKeys: trustedManifestPublicKeys,
options: const AppUpdaterOptions(architecture: 'aarch64'),
);
AppUpdaterOptions also holds headers, artifact storage, distribution/selection
policy, retries, executors, and runtimeInfoProvider for tests. An advanced
signaturePolicy can configure signature timing and cannot be combined with
the ordinary trustedPublicKeys argument.
A supplied executors list replaces all default executors. The first supporting
executor handles the action; use StreamingUpdateActionExecutor when the UI
needs progress and cooperative cancellation. A later streaming fallback does
not make an earlier non-streaming executor cancelable.
Use AppUpdater.manifest or AppUpdater(source: ..., selector: ...) for explicit
installed version, build, or platform values. Tests can inject an
AppRuntimeInfoProvider returning AppRuntimeInfo without native package lookup.
The REST transport requests exactly the supplied URL; it does not append runtime
query parameters. Servers may return a complete manifest for client-side
selection. If a server requires runtime query parameters, read them first with
PlatformAppRuntimeInfoProvider().load() and construct the endpoint explicitly.
The added dependencies retain Flutter 3.29 compatibility: package_info_plus
8.x and path_provider ^2.1.5. Pub selects an SDK-compatible version.
Reliability and integration boundaries #
UpdateSelectionPolicy.latestRelease remains the default: an unsupported newest
compatible release returns noSupportedAction. Opt into
selectionPolicy: UpdateSelectionPolicy.latestExecutable on AppUpdater or
AppUpdater.manifest to try older compatible newer-than-installed optional
releases. Actions retain publisher order after distribution and capability
filtering. A required release, or one whose minimum supported version excludes
the installed version, blocks fallback. The complete remote manifest still
passes trust verification before selection. If all actions are filtered out,
UpdateCheckFailed.isRequired and PreparedUpdateCheckFailed.isRequired retain
that known required policy. The controller and default/custom dialog guards
remain blocking even on the first check failure; hosts using core APIs must
honor the same flag.
Default package downloads are available on Android, macOS, and Windows; iOS supports store/URL handoffs, not APK downloads. A per-check selector must target the updater's execution platform. Custom executors remain host-owned extensions.
All network artifact downloads, including direct typed actions and desktop
installers, require positive exact size and a 64-hex SHA-256 before any request.
The trusted local InstallPackageAction keeps its optional paired size/hash
contract. Artifact URLs reject embedded credentials and missing hosts. Low-level
HTTP test support retains only localhost, 127.0.0.1, and ::1; remote manifest
transport follows its separately configured HTTPS policy.
Cancellation before dispatch (including from UpdateActionStarted) prevents
executor side effects. Cancellation cannot undo a store or installer already
opened. Only the download owner may remove partial state. Subscription
cancellation awaits response/file cleanup and ownership release.
Canceling the token is only a request; await the terminal event or subscription
cancellation before retrying or deleting the download directory.
If filesystem cleanup fails, the download returns PACKAGE_DOWNLOAD_FAILED
with a diagnostic and releases ownership; partial files or checkpoints may
remain. Restore storage access before retrying or removing those files.
Unix ownership uses
handle-scoped flock plus the legacy process lock on a separate inode; keep both
.download.owner and .download.lock files, and do not delete them during use.
New isolates are mutually exclusive. A legacy downloader in the same process
is not covered by the new ownership layer; upgrade all isolate entry points.
Android production downloads use
filesDir/flutter_app_updater/foreground/, explicitly allowed by the bundled
FileProvider (${applicationId}.flutter_app_updater.fileprovider,
@xml/flutter_app_updater_file_paths). Custom directories must match a
host-configured provider root. The host's legacy ${applicationId}.fileprovider
is tried only if the dedicated provider cannot share that path.
The example's debug-only provider probe reads a fixture URI without opening the
installer. macOS hosts need com.apple.security.network.client in both Debug and
Release sandbox entitlements; the example targets macOS 10.15 or later.
Parser output and checked/prepared actions are immutable snapshots. Public const
constructors remain compatible; callers constructing models directly own the
mutability of the lists they supply. Successful download, installer handoff, and
a newly running version are three separate states; handoff success does not
prove installation. Foreground packages use packages/<sha256>/<filename> and
desktop installers use installers/<sha256>/<filename> under the configured
download directory. The full normalized digest isolates different artifacts,
even when their URL filenames match. Always use the returned file path.
macOS and Windows recheck exact size
and SHA-256 on a worker thread before handoff. Windows denies file writes and
deletion through the shell call; macOS rejects symlinks and rechecks file identity
immediately before opening. macOS still has a path-based handoff window, and
neither platform guarantees file identity after the system accepts the handoff.
Custom platform implementations must provide equivalent native validation.
Use the checks in CONTRIBUTING.md before release. The manually
triggered Device verification workflow exercises Android native ownership,
FileProvider and macOS sandbox HTTPS without installing an update. Configured
CI checks do not establish successful Windows or real-device validation.
Progress and cancellation #
Download and installer actions expose started, progress, and one terminal event.
Android download-and-install and desktop installers also emit
UpdateActionHandoffStarted, a subtype of UpdateActionProgress, before native
verification/opening. Existing three-branch switches handle it as completed
transfer progress. To show a separate handoff stage, match it before ordinary
progress, as below. Download completion does not establish installation.
Stop offering cancellation at this boundary; cancellation
received while delivering the event still prevents dispatch.
final cancelToken = UpdateActionCancelToken();
await for (final event in updater.performRecommendedStream(
result,
cancelToken: cancelToken,
)) {
switch (event) {
case UpdateActionStarted():
showProgress();
case UpdateActionHandoffStarted():
showInstallerHandoff(); // Disable download cancellation.
case UpdateActionProgress(:final fraction):
updateProgress(fraction);
case UpdateActionCompleted(:final result):
handleResult(result);
}
}
// Call from your UI when the user cancels.
cancelToken.cancel(); // Only while the transfer is still cancelable.
perform() and performRecommended() remain available when a single terminal
result is sufficient.
Network and artifact safety #
- Remote manifests and artifacts require absolute HTTPS URLs. Plain HTTP is an opt-in loopback-only development exception. Redirects are limited to five, every target is revalidated, HTTPS cannot downgrade to HTTP, and sensitive request headers cross only a same-origin redirect. The loopback option allows an initial HTTP development endpoint, including HTTP redirects between loopback endpoints. Once a request uses HTTPS, it may never redirect back to HTTP, even to loopback.
- Remote self-hosted actions require exact byte size and a required SHA-256 digest, plus an authenticated Ed25519 envelope before payload parsing. The declared size must be positive.
- Downloads use 30-second request and idle timeouts, retry transient failures, preserve resume state only with a strong ETag, and default to a 1 GiB maximum. Last-Modified alone or a weak ETag causes a clean restart. A URL fingerprint protects checkpoint privacy, and an isolate-local fast guard plus persistent operating-system locks reject competing writers.
- Declared artifact sizes must be positive and are enforced before and during streaming. After ownership is acquired, cancellation, size violations, and hash mismatches attempt to remove partial files; cleanup failures return a structured failure with a diagnostic. Pre-cancellation preserves existing state.
- The manifest
appId, platform, channel, and architecture are checked before action selection. Unknown runtime architecture fails closed for an architecture-specific release; only a genuinely universal release matches. - Actions remain in publisher order. Host
UpdateDistributionPolicyand executor capability filtering remove disallowed actions without reordering; the first remaining action is recommended. UseUpdateDistributionPolicy.storeOnlyorUpdateDistributionPolicy.selfHostedOnlyto make the host boundary explicit.
Getting update information #
AppUpdater.fromManifest and the explicit AppUpdater.manifest entry use an HTTPS GET request for both a static JSON file and
a RESTful API. The two delivery styles use the same manifest v3 response schema.
Static JSON file #
Publish the manifest payload, or its signed envelope, as an HTTPS document on a
CDN, object store, or web server. The repository includes a complete payload at
doc/examples/update-manifest-v3.json.
Here, static JSON means a document hosted over HTTPS. Neither entry
reads local file:// URLs or Flutter asset paths. A trusted adapter can
instead construct an UpdateManifest and use UpdateSource.staticManifest.
final updater = await AppUpdater.fromManifest(
manifestUrl: Uri.parse(
'https://cdn.example.com/releases/app-updates.json',
),
trustedPublicKeys: trustedManifestPublicKeys,
);
final result = await updater.checkAndPrepare();
RESTful API #
A REST endpoint can select releases dynamically from request parameters. The
default fetcher sends a GET, so put selection inputs in the URL and optional
credentials in headers:
GET /v1/updates HTTP/1.1
Host: updates.example.com
Accept: application/json
Authorization: Bearer <short-lived-token>
final endpoint = Uri.https(
'updates.example.com',
'/v1/updates',
);
final updater = await AppUpdater.fromManifest(
manifestUrl: endpoint,
trustedPublicKeys: trustedManifestPublicKeys,
options: AppUpdaterOptions(
headers: {
'Accept': 'application/json',
'Authorization': 'Bearer $accessToken',
},
),
);
final result = await updater.checkAndPrepare();
The default transport requires HTTP 200, accepts at most 1 MiB, and parses the
response body directly as either a manifest v3 object or a signed envelope. Do
not wrap it in { "data": ... }, { "result": ... }, or another business
response object. If an existing API cannot return that shape, provide a custom
ManifestFetcher. Caller headers are removed after a cross-origin redirect.
For either delivery style, a bare response is accepted only for official-store
or Android-market actions when ManifestSignaturePolicy.optional is used.
Self-hosted package or installer actions require a valid Ed25519 envelope.
Signing every production response is the recommended policy.
The CLI manifest sign validates the payload schema and remote action policy
before signing; invalid input leaves an existing output untouched.
manifest verify validates a plain payload, not a signed envelope. Runtime
ManifestSignatureVerifier verifies envelopes against host-owned trusted keys
and timestamp policy before parsing the payload.
Publish and renew a signed manifest #
Create a payload template, then calculate metadata from the final artifact:
dart run flutter_app_updater manifest generate > manifest-payload.json
dart run flutter_app_updater hash app.apk
wc -c < app.apk
The byte-count command above is for macOS/Linux. In PowerShell, use
(Get-Item app.apk).Length.
Edit the template's app identity, version/build, platform/channel, artifact URL,
exact byte size, and SHA-256. generate contains example values, and sign does
not calculate artifact metadata. Validate and sign the edited payload:
dart run flutter_app_updater manifest verify manifest-payload.json
# The release environment supplies FLUTTER_APP_UPDATER_ED25519_PRIVATE_KEY_BASE64.
dart run flutter_app_updater manifest sign manifest-payload.json \
--key-id release-2026-01 --expires-in 24h --output manifest-signed.json
The private-key environment variable contains a Base64-encoded raw 32-byte
Ed25519 seed, not PEM. Keep it in the publishing environment. In the application,
trustedManifestPublicKeys contains the matching raw 32-byte public key:
const trustedManifestPublicKeys = <String, String>{
'release-2026-01': '<Base64-encoded raw public key>',
};
Serve manifest-signed.json at the configured manifest URL. A signed envelope
expires even when the release payload has not changed: the default maximum
validity is seven days, and the command above issues a 24-hour envelope. Renew
and republish before expiresAt, including periods without a new app release.
The manifest URL should remain stable while its signed response is replaced;
keep CDN cache lifetimes within the remaining validity window. Artifact URLs
must continue to serve the same immutable bytes and digest. Without renewal,
clients reject the expired response; they do not silently skip verification.
Manifest v3 response schema #
{
"schemaVersion": 3,
"appId": "com.example.app",
"channel": "stable",
"releases": [
{
"version": "2.0.0",
"buildNumber": "42",
"platform": "android",
"architecture": "arm64",
"releaseNotes": "Bug fixes",
"releasedAt": "2026-07-03T10:00:00Z",
"policy": {
"level": "recommended",
"minSupportedVersion": "1.5.0"
},
"actions": [
{
"type": "downloadAndInstallPackage",
"packageUrl": "https://example.com/app.apk",
"packageType": "apk",
"packageSizeBytes": 25600000,
"sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
}
]
}
]
}
Manifest fields: required and optional #
Root object:
| Field | Required | Type and constraints | Meaning |
|---|---|---|---|
schemaVersion |
Yes | Integer, exactly 3 |
Selects the manifest contract. |
appId |
Yes | Non-empty string | Must equal the client's expectedAppId. |
channel |
Yes | Non-empty string | Default channel inherited by releases that omit channel. |
releases |
Yes | Array | Ordered release candidates; it may be empty when no release exists. |
Each item in releases:
| Field | Required | Type and constraints | Meaning |
|---|---|---|---|
version |
Yes | Semantic-version string | Used as the primary update ordering key. |
platform |
Yes | android, ios, macos, or windows for the stable package |
Must exactly match the client platform. |
releaseNotes |
Yes | Non-empty string | Publisher text shown by the host application. |
actions |
Yes | Non-empty array | Delivery choices in publisher order; the first allowed supported action is recommended. |
buildNumber |
No | Non-negative ASCII decimal integer string; iOS/macOS also allow one to three dot-separated integer components | Breaks ties when semantic versions are equal; components are compared numerically. |
channel |
No | Non-empty string | Overrides the root channel for this release. |
architecture |
No | Non-empty string | Exact runtime architecture; omit it for a universal release. |
releasedAt |
No | ISO-8601 string | Publisher release timestamp. |
policy |
No | Object | Update recommendation and minimum supported version. |
Optional policy fields:
| Field | Required | Type and constraints | Meaning |
|---|---|---|---|
level |
No | optional, recommended, or required; defaults to optional |
How strongly the host should present the update. |
minSupportedVersion |
No | Semantic-version string no greater than this release's version |
Makes the update required when the installed version is older. |
Action-specific fields:
Every action object requires a non-empty type discriminator. The value selects
one row below; the remaining required and optional fields are exact for that
action type.
type |
Required fields | Optional fields | Notes |
|---|---|---|---|
openStore |
store, storeUrl |
None | store is googlePlay, appStore, or macAppStore; the remote URL must use the matching official host. |
openAndroidMarket |
market, targetPackageName |
fallbackUrl |
targetPackageName must equal the manifest appId. |
downloadPackage |
packageUrl, packageType, packageSizeBytes, sha256 |
None | Exact positive size and a 64-hex-character SHA-256 are mandatory. |
downloadAndInstallPackage |
packageUrl, packageType, packageSizeBytes, sha256 |
None | Runtime installation supports Android APKs only. |
openInstaller |
installerUrl, installerType, installerSizeBytes, sha256 |
None | Stable types are Windows msix/msi/exe and macOS dmg/zip. |
installPackage |
packagePath |
packageType (defaults to apk) |
Trusted local typed code only; remote manifests reject this action. |
buildNumber is optional. Android and Windows require a non-negative ASCII
decimal integer string (0, 42, and 00042 are valid). Leading zeroes are
allowed and the value is parsed as an integer. iOS and macOS additionally accept
one to three integer components, such as 12.3.4. Missing trailing components
compare as zero: 12.3 equals 12.3.0, and 12.10 is newer than 12.9.
The same rule applies to installedBuildNumber; prerelease suffixes are not
supported. Any other value rejects the entire response. Older clients still
reject dotted manifest builds; keep publishing integer builds until those
clients are upgraded, or omit build numbers when same-version comparison is
unnecessary. minSupportedVersion, when present, must be less than or equal to
that release's version.
Manifest v3 is an exact allowlist at every object boundary: the root, each
release, policy, and action object reject unknown fields. There is no
extensions escape hatch. The signed envelope also rejects extra fields.
Adding a field requires a new schema version. A v3 response with any unknown,
mis-typed, or invalid field rejects the entire response rather than ignoring
the field.
All remote URLs must be absolute. Remote policy further requires HTTPS, exact
positive artifact sizes, and a 64-character hexadecimal sha256 for every
self-hosted artifact. Removed earlier-schema field names are rejected anywhere
in the document; use only the v3 fields listed above.
The parser also recognizes linux/fuchsia platform values and
appImage/deb/rpm installer values for typed model compatibility. They are
not stable default executor or plugin-registration support and can end in
NO_SUPPORTED_ACTION; the platform matrix below is the executable contract.
This JSON is the signed payload, not the network response. For self-hosted
actions, encode the exact payload bytes in a versioned Ed25519 envelope with
keyId, issuedAt, expiresAt, payload, and signature. Rotate keys by
shipping an overlap period in which the host trusts both key IDs. Bare remote
manifests are accepted only for official-store or Android-market actions when
the host uses the optional signature policy.
Signed network response fields are all required. The envelope is also an exact allowlist; any extra field rejects the response:
{
"format": "flutter_app_updater.ed25519.v1",
"keyId": "release-2026-01",
"issuedAt": "2026-07-14T12:00:00Z",
"expiresAt": "2026-07-15T12:00:00Z",
"payload": "<Base64 of the exact manifest JSON bytes>",
"signature": "<Base64 Ed25519 signature>"
}
| Field | Required | Type and constraints |
|---|---|---|
format |
Yes | Exactly flutter_app_updater.ed25519.v1. |
keyId |
Yes | Non-empty key identifier present in trustedPublicKeys. |
issuedAt |
Yes | ISO-8601 validity-window start. |
expiresAt |
Yes | ISO-8601 validity-window end; later than issuedAt and within the configured maximum validity. |
payload |
Yes | Base64 of the exact manifest v3 JSON bytes. |
signature |
Yes | Base64 Ed25519 signature over the package's domain-separated input. |
Recipes #
Official Store #
{
"type": "openStore",
"store": "googlePlay",
"storeUrl": "https://play.google.com/store/apps/details?id=com.example.app"
}
Use appStore for iOS and macAppStore for macOS.
Chinese Android Markets #
{
"type": "openAndroidMarket",
"market": "xiaomi",
"targetPackageName": "com.example.app",
"fallbackUrl": "https://app.mi.com/details?id=com.example.app"
}
Supported market names: huawei, honor, xiaomi, oppo, vivo, meizu, tencentMyApp, and generic.
Self-Hosted Android APK #
Self-hosted APK installation is intended for policy-compliant enterprise,
private, or non-Play distribution. Google Play builds should use store actions
and must not request REQUEST_INSTALL_PACKAGES solely to update themselves.
The plugin does not add this sensitive permission automatically. Apps that are
eligible to install APKs must opt in from their application manifest:
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
AndroidInstallPermission().status() distinguishes granted, denied, and
notDeclared. After an explicit user gesture, openSettings() opens this
host's installation settings. Recheck status() on return and ask the user to
confirm the action again. Before API 26 there is no per-app authorization;
granted does not bypass the system's legacy unknown-sources setting.
Use one action when you want the package to download and then start Android installation:
{
"type": "downloadAndInstallPackage",
"packageUrl": "https://example.com/app.apk",
"packageType": "apk",
"packageSizeBytes": 25600000,
"sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
}
Use separate actions when your app wants to download now and install later:
{
"type": "downloadPackage",
"packageUrl": "https://example.com/app.apk",
"packageType": "apk",
"packageSizeBytes": 25600000,
"sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
}
Remote manifests cannot request installation of an arbitrary local path. A
host may construct an InstallPackageAction only at a trusted typed boundary,
for example after its own verified download. For ordinary local APKs, Android
rechecks exact size and SHA-256 only when both are supplied; a path-only action
checks package identity and signing lineage without an expected size or digest.
Pass the original trusted download metadata when creating the install action:
// downloaded is the successful result of performing downloadAction.
final installAction = InstallPackageAction(
packagePath: downloaded.file!.path,
packageSizeBytes: downloadAction.packageSizeBytes,
sha256: downloadAction.sha256,
);
// Call updater.perform(installAction) after explicit installation confirmation.
Foreground download-and-install supplies this metadata automatically. Managed
background APKs are rechecked against their persisted size and SHA-256 at
installation, including actions returned by createInstallAction().
Advanced Android-only background downloads #
AndroidBackgroundDownloadManager is an opt-in API for one durable,
user-visible APK transfer at a time. It is separate from the default
AppUpdater action flow and is available only on Android.
Foreground downloads and Android durable downloads have different URL
persistence contracts. The foreground action flow may use an HTTPS artifact URL
with short-lived query credentials. The foreground checkpoint stores only a
SHA-256 URL fingerprint, never the raw URL or query token. Android durable
start() accepts only a stable, credential-free entry URL with no userinfo,
query, or fragment. The durable task record persists only that stable entry URL.
For expiring download credentials, configure the stable entry endpoint to return an HTTPS redirect to a short-lived signed URL. The signed redirect target exists only as the current process's in-memory transport target and is never persisted. Each redirect target is revalidated, and every HTTPS-to-HTTP downgrade is rejected.
A start request must come from a visible, user-initiated host flow and must include that stable HTTPS URL, the exact content length, and a lowercase or uppercase SHA-256 digest:
final downloads = AndroidBackgroundDownloadManager();
final task = await downloads.start(
DownloadPackageAction(
packageUrl: Uri.parse('https://downloads.example.com/app.apk'),
packageType: PackageType.apk,
packageSizeBytes: 25600000,
sha256: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
),
);
await for (final snapshot in downloads.watch(task.id)) {
updateBackgroundDownloadUi(snapshot);
}
Use list(), listUnfinished(), and get() after startup to reconcile your
UI with durable native state. Call resume() only in response to a user action
when a task is waiting or paused. cancel() is terminal. Call remove() only
after cancel or another terminal result; it then deletes the durable task and
its private artifact. Canceling an already canceled task is idempotent;
completed/failed tasks reject cancellation. Neither path cancels a newer task's
shared Android job. Stale terminal-task notification actions are ignored rather
than restoring an older notification. Scheduling and cancellation recheck task
ownership under the same admission lock, so a delayed notification retry cannot
replace a newer task's shared Android job or notification. Notification controls
run independently of installer verification and do not wait for startup APK
hashing. Their actual scheduling path still checks the required host setup;
notification broadcast handling does not wait for unrelated long-running I/O.
If cancellation commits before a worker opens its partial file, the worker closes
that writer and retries artifact cleanup before finishing. A cleanup failure keeps
the task canceled; repeated cancel(), remove(), or startup reconciliation can
retry cleanup after storage access returns.
Storage failures become waitingForStorage, including failures entering
running or persisting completion. If storage also rejects that state write,
the durable snapshot can retain its previous status. Once storage access is
restored, explicit resume() retries this recorded recovery in the same process;
it does not resume an unrelated active execution. Valid APKs and checkpoints
are reverified and reused. This recovery also covers startup directory-listing,
state-read, and state-write failures after automatic retries are exhausted:
restore storage access and explicitly resume the original startup task in the
same process. Directory-listing failures are reported as storage failures rather
than an empty task list. Startup recovery does not take over newly admitted tasks or
registered active executions, and startup retries do not pause newer revisions.
Newly admitted tasks have separate execution-bound recovery: a temporary state-read
failure or system stop before the first readable snapshot still leaves explicit
resume available after storage access returns. The recovery is bound to the admitted
execution and checked against the durable revision; it cannot take over an ordinary
active task or overwrite a newer revision or terminal result.
If a running-state, checkpoint, or completion write fails and the next read also
fails, recovery retains that execution's last trusted snapshot. A verified APK
already on disk can be reused without another download.
Corrupt-state recovery advances beyond the last observed task revision so
current and reattached observers receive the failure instead of stale progress.
Malformed raw maximum revisions are ignored as recovery floors; an already
observed maximum counter saturates as a terminal failure and remains removable.
Native observers and Dart watch() accept that saturated corrupt-state failure
once even at the same revision; watch() then closes on the terminal snapshot.
On API 21–33, a canceled or completed task's foreground worker can still be
finishing when another download is accepted. The new task then moves to
pausedBySystem instead of remaining queued without a worker. Observe watch()
or refresh get(), and offer a user-triggered resume() after the old worker
has stopped. Duplicate starts for the same execution reuse its worker. An explicit
resume for a newer revision of that same task is retained during old-worker
cleanup and starts after cleanup; a successful resume need not be repeated.
On API 34+, network changes stop only the execution bound to the old network
lease. A change before the first lease uses the new network without pausing it.
A system stop before engine execution also leaves a nonterminal task explicitly
resumable. Job completion coordinates release of the local execution slot with the
system completion callback, so the next accepted job is not lost during handoff.
Delayed callbacks from the old job cannot stop or finalize that new job.
UIDT callbacks check an internal generation token as well as the task and job IDs;
this does not change public task IDs or notification actions.
Deferred recovery is bound to the task revision captured when the service start
was scheduled. Once a retry advances that revision, an older callback cannot
pause it, including during its intermediate queued state before running.
On other platforms, manager operations throw BackgroundDownloadException
with UpdateErrorCode.backgroundDownloadUnavailable before calling native code.
Invalid task IDs or action metadata throw ArgumentError on Android. IDs are
trimmed, then must match [A-Za-z0-9_-]{1,80}, consistently with native storage.
Durable task state is stored under Android noBackupFilesDir; APK and partial
artifacts are stored under the app-private filesDir exposed by the package's
FileProvider. On first use after upgrading, tasks and artifacts from the
pre-release single-root layout are reset instead of migrated. Hosts must treat
those old tasks as no longer resumable and start a fresh user-visible transfer.
Required host manifest #
The plugin manifest deliberately does not opt applications into background
execution. Merge all of the following declarations into the host app's
android/app/src/main/AndroidManifest.xml, keeping the app's existing
activities, providers, metadata, and other components:
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.RUN_USER_INITIATED_JOBS" />
<application>
<!-- Keep the host's existing activity and application declarations. -->
<service
android:name="com.indiegeeker.flutter_app_updater.background.UserInitiatedDownloadJobService"
android:permission="android.permission.BIND_JOB_SERVICE"
android:exported="false" />
<service
android:name="com.indiegeeker.flutter_app_updater.background.BackgroundDownloadForegroundService"
android:foregroundServiceType="dataSync"
android:exported="false" />
<receiver
android:name="com.indiegeeker.flutter_app_updater.background.BackgroundDownloadActionReceiver"
android:exported="false" />
</application>
</manifest>
The host application owns notification permission: it decides when and how to
request POST_NOTIFICATIONS at runtime on Android 13+, explains the visible
transfer to the user, and handles denial. The package declares neither this
runtime request nor a battery-exemption prompt. A manifest declaration alone
does not grant notification permission.
On API 21-25 the package enters the visible foreground-service lifecycle after
startService; on API 26-33 it does so after startForegroundService. The
dataSync foreground-service type has platform meaning on API 29+. A process
stop or network loss can leave durable state that the host shows after reopen;
recovery is explicit through resume() or the user-visible notification
action. There is no unattended API 21-33 network recovery guarantee.
On API 34+ the package submits a user-initiated data transfer job with an internet
requirement. Android still controls admission, stop reasons, and execution
time, so the host must handle scheduling rejection and expose retry.
Google Play distribution does not make self-hosted APK updates or foreground
services policy-compliant. Play apps should normally use store delivery, and
the publisher remains responsible for current foreground-service and
user-initiated-transfer declarations, eligibility, and review requirements.
Do not add REQUEST_INSTALL_PACKAGES solely to self-update a Play build.
Server and installation contracts #
Production artifacts must use HTTPS and immutable bytes. Supply the exact
content length and SHA-256 to start(). Safe and efficient resume requires the
server to support Range: bytes=N-, return a precise 206 Content-Range, and
keep a strong ETag stable for those bytes so If-Range cannot append a changed
artifact. A server that ignores Range may cause a clean restart; weak or
changing validators are not resume evidence.
Download completion and installation are deliberately separate:
final installAction = await downloads.createInstallAction(task.id);
// createInstallAction revalidates the private APK but does not install the APK.
final installResult = await updater.perform(installAction);
createInstallAction() rechecks the private file, expected size, SHA-256,
package name, and signing lineage, then returns an InstallPackageAction.
Only the later explicit AppUpdater.perform() call can open Android's package
installer, which remains the final installation authority. The plugin does not
provide silent installation.
Recovery limits #
Native records and partial bytes survive Flutter engine detach and ordinary
process recreation. They do not make Android continue work after force-stop,
and jobs are not persisted across reboot. Reopen the app, call listUnfinished(),
and let the user choose resume() where the state permits it. Recents swipe,
Task Manager Stop, battery restriction, background-start limits, storage
pressure, and OEM process management can stop or reject execution.
The implementation does not use or promise WorkManager or DownloadManager, does not request battery exemptions, and does not promise uninterrupted background execution on any OEM family.
iOS App Store #
{
"type": "openStore",
"store": "appStore",
"storeUrl": "https://apps.apple.com/app/id123456789"
}
macOS and Windows Installers #
{
"type": "openInstaller",
"installerUrl": "https://example.com/app.msi",
"installerType": "msi",
"installerSizeBytes": 82000000,
"sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
}
Supported stable installer types:
- Windows:
msix,msi,exe - macOS:
dmg,zip
Platform Matrix #
| Platform | Official store | Chinese markets | Package download | Package install | Desktop installer |
|---|---|---|---|---|---|
| Android | Stable | Stable | Stable | Stable | Not applicable |
| iOS | Stable | Not applicable | Unsupported | Unsupported | Not applicable |
| macOS | Stable | Not applicable | Stable | Unsupported | Stable |
| Windows | Unsupported | Not applicable | Stable | Unsupported | Stable |
Unsupported actions return structured failures instead of throwing platform exceptions through the public API.
Error Handling #
checkAndPrepare() returns:
PreparedUpdateAvailablePreparedUpdateNotAvailablePreparedUpdateCheckFailed
perform() and performRecommended() return UpdateActionResult.
Useful error codes include:
MANIFEST_FETCH_FAILEDMANIFEST_INVALIDAPP_ID_MISMATCHNO_SUPPORTED_ACTIONSTORE_NOT_AVAILABLEMARKET_NOT_AVAILABLEPACKAGE_DOWNLOAD_FAILEDPACKAGE_TOO_LARGEPACKAGE_HASH_MISMATCHPACKAGE_INSTALL_PERMISSION_REQUIREDPACKAGE_FILE_NOT_FOUNDPACKAGE_INSTALL_FAILEDINSTALLER_OPEN_FAILEDPLATFORM_NOT_SUPPORTEDDOWNLOAD_IN_PROGRESSACTION_FAILEDACTION_CANCELED
Example #
The bundled example opens on a configurable update simulator with no external
side effects.
It also includes a separately labeled production integration tab that is
disabled by default. When explicitly configured, that tab exercises the real
signed-manifest fetch, verification, identity binding, selection, preparation,
and user-confirmed action path. See
example/README.md for the safety boundary, configuration,
and runnable commands.
Maintainer Verification #
The supported floor is Flutter 3.29.0. The reusable full gate tests that exact version and current stable Flutter, validates root/example analysis and tests, enforces total and critical coverage at 80%, generates API docs, builds every registered platform example, runs Android and Windows native tests, and checks a clean publish archive. Run this local subset with the current stable SDK; repeat package/example analysis and tests with Flutter 3.29.0.
flutter pub get
(cd example && flutter pub get)
dart format --output=none --set-exit-if-changed lib test example/lib example/test example/integration_test tool
flutter analyze --no-pub
flutter test --coverage --no-pub
dart pub global activate dartdoc 9.0.8
dart pub global run dartdoc --no-generate-docs
(cd example && flutter analyze --no-pub && flutter test --no-pub)
(cd example && flutter build apk --debug --no-pub)
bash tool/ci/publish_dry_run.sh
The docs check uses dartdoc 9.0.8, matching CI, because the current SDK's
bundled 9.0.6 can crash on Flutter @docImport comments. This is a documentation
tool compatibility requirement on the current SDK, not a change to the package's
Flutter 3.29 support. The minimum-SDK Android gate separately builds a fresh host
with Flutter 3.29.0's default Kotlin 1.8.22 toolchain.
The Android-native reliability gate runs from example/android:
../../android/gradlew :flutter_app_updater:testDebugUnitTest :flutter_app_updater:lintDebug :app:processDebugMainManifest
For a release, update pubspec.yaml and CHANGELOG.md, merge the release commit
to main, and tag that exact commit as v<version>. The publish workflow runs
the same full gate and requires tag, version, CHANGELOG, and origin/main
ancestry to agree. Validate locally with:
version="$(awk '/^version:/ {print $2; exit}' pubspec.yaml)"
dart run tool/ci/verify_release_metadata.dart --tag "v$version"
See CONTRIBUTING.md for development checks and SECURITY.md for private vulnerability reporting.
License #
Apache-2.0