API-reference topic
API Reference
English | 简体中文
Every public API in flutter_ota_kit is exposed as a static member on
the FlutterPatcher class. The plugin only executes patch logic on
Android. On iOS, Web, macOS, Windows, and Linux, calling these APIs is
a no-op — they don't throw, they print a one-time warning on first
call, and they return safe defaults.
This is the reference for the public SDK surface. For internals (how a patch actually loads, the signing protocol, the boot-time loader hook), see Architecture. For setup, see Getting Started.
Table of contents
- Initialization
- Check for updates
- Apply a patch
- Apply update (full server-driven flow)
- Handle the result
- Error codes
- Listen to progress
- Roll back
- Boot diagnostics
- Query state
- Blacklist
- Asset patching
- Custom update source
- PatchInfo
- Version compatibility
Initialization
FlutterPatcher.init
Configure patch loader, crash protection, and boot diagnostics.
Call this once before runApp(). Idempotent — repeated calls are
safe no-ops.
Future<void> init({
String publicKeyBase64 = '',
int maxCrashCount = 1,
bool strictSignature = true,
List<String> loaderFieldCandidates = const ['flutterLoader'],
bool loaderFallbackHeuristic = false,
Duration verifyAfter = const Duration(seconds: 5),
bool autoApplyUpdates = false,
})
| Parameter | Default | What it does |
|---|---|---|
publicKeyBase64 |
'' |
Ed25519 public key (X.509 SubjectPublicKeyInfo, base64) for patch signature verification. Empty disables signing. |
maxCrashCount |
1 |
Fail-fast: after this many early boot failures with a patch, auto-rollback. Set to 0 to disable. |
strictSignature |
true |
Reject signed patches on Android API < 33 (the platform Ed25519 implementation is unreliable there). Set false to allow signed patches on older Android. |
loaderFieldCandidates |
['flutterLoader'] |
Field names the native loader looks for when restoring. Don't change unless adapting a new Flutter version. |
loaderFallbackHeuristic |
false |
Enable a fallback loader path for unusual Flutter embedders. Don't enable unless instructed. |
verifyAfter |
5s |
Post-first-frame Dart error watch window. Errors during this window trigger crash rollback. |
autoApplyUpdates |
false |
Zero-click forced updates: after boot protection, run checkAndApplyUpdates in the background. A forced update downloads, stages, and the process restarts automatically. |
import 'package:flutter_ota_kit/flutter_ota_kit.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await FlutterPatcher.init();
runApp(const MyApp());
}
For zero-click forced updates, also set autoApplyUpdates: true and wrap
your app in FlutterOtaApp:
await FlutterPatcher.init(autoApplyUpdates: true);
runApp(const FlutterOtaApp(child: MyApp()));
The backend must be configured (via configureSupabase(...) etc., or
auto-detected from env vars) before init() is called for
autoApplyUpdates to work.
Check for updates
FlutterPatcher.checkUpdate(url)
Built-in minimal HTTP check for the simplest deployments. Returns a
PatchCheckResult you parse yourself.
Future<PatchCheckResult> checkUpdate(
String url, {
Map<String, String>? headers,
Duration timeout = const Duration(seconds: 10),
})
final result = await FlutterPatcher.checkUpdate(
'https://your-cdn.com/api/check-update?v=1.0.0',
headers: {'Authorization': 'Bearer <token>'},
);
if (result.hasUpdate && result.patch != null) {
await FlutterPatcher.applyPatch(result.patch!);
}
The endpoint must return this JSON:
{
"hasUpdate": true,
"patch": {
"version": "1.0.1",
"patchUrl": "https://your-cdn.com/v100/patch.zip",
"md5": "0123456789abcdef0123456789abcdef",
"signature": "ed25519-sig-base64",
"targetVersionCode": 100
},
"shouldForceUpdate": false,
"message": "New onboarding flow"
}
The SDK accepts both hasUpdate and has_update (snake_case). The
patch object may be a top-level map or nested under a patch key.
FlutterPatcher.checkForUpdate({timeout})
The richer check. Uses whichever backend you configured via
configureSupabase / configurePostgres / etc. (or auto-detected
from env vars).
Future<ServerUpdateResult> checkForUpdate({
Duration timeout = const Duration(seconds: 10),
})
final result = await FlutterPatcher.checkForUpdate();
if (result.hasUpdate) {
// result.patch is non-null when hasUpdate is true
await FlutterPatcher.applyPatch(result.patch!);
}
The timeout parameter bounds the two HTTP round-trips (DB query + signed-URL
mint). On timeout, a TimeoutException with a clear message is thrown
and the in-flight HTTP request is cancelled.
ServerUpdateResult fields:
| Field | Type | Meaning |
|---|---|---|
hasUpdate |
bool |
true if a patch is available |
patch |
PatchInfo? |
The downloadable patch (null when !hasUpdate) |
status |
AppUpdateStatus |
update, rollback, or upToDate |
shouldForceUpdate |
bool |
Server wants the patch applied even on a normal cold start |
id |
String? |
The bundle id (often the version string) |
message |
String? |
Human-readable "what's new" text — shown in the forced-update overlay |
raw |
Map<String, dynamic> |
The original backend response, in case you need extra fields |
Apply a patch
Option 1: let the SDK download the patch
Future<PatchApplyResult> applyPatch(
PatchInfo patchInfo, {
void Function(PatchApplyProgress)? onProgress,
})
final result = await FlutterPatcher.applyPatch(
PatchInfo(
version: '1.0.1',
patchUrl: 'https://your-cdn.com/v100/patch.zip',
md5: '0123456789abcdef0123456789abcdef',
signature: 'ed25519-sig-base64', // optional
targetVersionCode: 100,
),
onProgress: (p) {
print('${p.phase.name}: ${(p.fraction ?? 0) * 100}%');
},
);
if (result.ok) {
// Patch is staged. Takes effect on next cold start.
// For forced updates, FlutterPatcher.restart() is called automatically
// when you used applyUpdate() instead.
}
patchUrl may be:
https://your-cdn.com/...— standard, recommendedhttp://...— works but not recommended (Android 9+ blocks cleartext by default)file:///data/data/<pkg>/files/patch.zip— for bundled patches (e.g. the example app'sassets/patch_demo.zipstyle)
Option 2: apply patch bytes directly
Future<PatchApplyResult> applyPatchBytes(
Uint8List bytes, {
required String version,
String signature = '',
int? targetVersionCode,
void Function(PatchApplyProgress)? onProgress,
})
// For example, fetch the bytes yourself and apply them.
final bytes = await loadPatchFromIsolate();
final result = await FlutterPatcher.applyPatchBytes(
bytes,
version: '1.0.1',
targetVersionCode: 100,
);
Use this when:
- You've already downloaded the bytes (for example, in a FFI isolate)
- The patch comes from a non-HTTP source (asset bundle, secure element, in-memory cache)
- You need full control over the IO
Apply update (full server-driven flow)
The most common path: check, apply, auto-restart if forced.
Future<PatchApplyResult> applyUpdate(
ServerUpdateResult result, {
void Function(PatchApplyProgress)? onProgress,
})
final result = await FlutterPatcher.checkForUpdate();
if (result.hasUpdate && result.patch != null) {
await FlutterPatcher.applyUpdate(result, onProgress: (p) {
print('${p.phase.name}: ${(p.fraction ?? 0) * 100}%');
});
}
Behavior:
- If
result.shouldForceUpdateistrueandFlutterPatcher.showUpdateUiistrue(the default), the SDK shows the built-in progress overlay during the install. - If
result.shouldForceUpdateistrue, the SDK callsFlutterPatcher.restart()after a successful install. This restarts the process so the new code loads immediately. - If the install fails, the SDK shows the error in the overlay for 6 seconds, then the overlay is removed. The app keeps running on the old code.
The convenience wrapper:
Future<PatchApplyResult?> checkAndApplyUpdates({
void Function(PatchApplyProgress)? onProgress,
})
does checkForUpdate() + applyUpdate() with one extra guard: if the
device is already on the same version as the new patch, it skips the
install (no infinite restart loops). This is the method init(autoApplyUpdates: true)
fires in the background.
// From your background task or a "Check for updates" button:
await FlutterPatcher.checkAndApplyUpdates();
Handle the result
Both applyPatch and applyPatchBytes return a PatchApplyResult:
class PatchApplyResult {
final bool ok;
final PatchApplyError? error;
final String? message;
}
| Field | Meaning |
|---|---|
ok |
true if the patch is staged and ready for the next cold start. |
error |
A PatchApplyError enum value when ok is false. |
message |
Developer-facing error description. Don't show directly to users. |
Error codes
PatchApplyError is an exhaustive enum:
| Value | When |
|---|---|
invalidArgs |
Missing version/URL, malformed MD5, target version mismatch, unsupported mode. |
blacklisted |
The same (version, md5) payload is in the local bad-patch blacklist. Auto-recovered by crash protection. |
network |
Download failed after 3 retries with exponential backoff. |
md5Mismatch |
The downloaded bytes' MD5 didn't match PatchInfo.md5. Possible CDN corruption or tampering. |
signatureInvalid |
Ed25519 signature check failed. Possible tampering or wrong public key. |
unsupportedAbi |
The patch has no libapp.so for this device's ABI. |
assetPackageInvalid |
Bad zip/schema/manifest, unsafe path, missing asset entry. |
ioError |
Filesystem, disk space, copy, fsync, or rename failure. |
unknown |
Unclassified native / channel error. Check logcat. |
if (!result.ok && result.error == PatchApplyError.network) {
// The user's network is bad. Tell them to retry on Wi-Fi.
}
Listen to progress
FlutterPatcher.applyProgress
A broadcast Stream<PatchApplyProgress> that emits during applyPatch
and applyPatchBytes. Subscribe before calling apply to receive
events.
Stream<PatchApplyProgress> get applyProgress
PatchApplyProgress fields:
| Field | Type | Meaning |
|---|---|---|
phase |
PatchApplyPhase |
downloading, verifying, or finalizing |
bytesReceived |
int |
Bytes downloaded (only meaningful during downloading) |
totalBytes |
int |
Content-Length from the server; -1 if not provided |
fraction |
double? |
Download progress 0.0–1.0, or null if unknown |
final sub = FlutterPatcher.applyProgress.listen((p) {
if (p.phase == PatchApplyPhase.downloading && p.fraction != null) {
// update your custom progress bar
}
});
try {
await FlutterPatcher.applyPatch(patch);
} finally {
await sub.cancel();
}
Or use the convenience onProgress parameter on applyPatch /
applyUpdate / checkAndApplyUpdates for a one-off callback.
Roll back
Future<void> rollback()
Deletes the current patch. The app reverts to the base APK on the next
cold start. Does not clear the blacklist — blacklisted patches
stay blacklisted until you explicitly call clearBlacklist().
// Emergency: kill the patch and force a restart
await FlutterPatcher.rollback();
// The user reopens the app — they get the base APK's code.
This is a local operation. It does not change your backend. The
server still has the bad bundle; the device just stops trying to apply
it. To also remove the bundle from the server, run
flutter-ota bundle delete <id> on the CLI.
Boot diagnostics
FlutterPatcher.lastBootDiagnostic
Future<PatchBootDiagnostic?> get lastBootDiagnostic async
Returns a PatchBootDiagnostic? describing what happened on the most
recent cold start. Send this to your analytics pipeline to spot a bad
patch before users hit the app.
class PatchBootDiagnostic {
final String? version; // patch version that was loaded
final BootOutcome outcome; // success | md5Mismatch | signatureFailure | crashRollback | loaderFailure | unknown
final DateTime? timestamp;
}
outcome values:
| Value | Meaning |
|---|---|
success |
Patched libapp.so loaded cleanly. |
md5Mismatch |
The loader found the patch but its MD5 didn't match. The patch was auto-rolled back. |
signatureFailure |
The signature didn't match. Auto-rolled back. |
crashRollback |
The patch loaded but the app crashed within verifyAfter. Auto-rolled back. |
loaderFailure |
The Flutter loader hook couldn't load the patch. Auto-rolled back. |
unknown |
The diagnostic wasn't recorded (e.g. no patch was installed). |
FlutterPatcher.reportBootSuccess()
Normally called automatically by the SDK after the first frame. If you want to call it earlier (e.g. after a critical async init), you can:
Future<void> reportBootSuccess()
Once the SDK has called this, the boot window closes. Subsequent
crashes don't trigger auto-rollback. If your app's first frame is
delayed (e.g. a splash screen that takes 10s), call this from a
postFrameCallback to short-circuit the boot window.
Query state
static Future<int?> get appVersionCode async // current APK versionCode
static Future<String> get deviceAbi async // current device ABI
static Future<String?> get currentVersion async // installed patch version
static Future<List<BlacklistEntry>> get blacklist async
static Future<void> clearBlacklist() // for tests / ops
Example: ship a debug overlay that shows the current state:
final v = await FlutterPatcher.currentVersion;
final code = await FlutterPatcher.appVersionCode;
final abi = await FlutterPatcher.deviceAbi;
print('patch=$v versionCode=$code abi=$abi');
Blacklist
A local list of patch versions the SDK has refused to retry. Caused by:
- Cold-start MD5 mismatch — the patch was installed but its bytes on disk don't match the expected MD5. This usually means disk corruption, not tampering.
- Cold-start signature failure — the signature didn't match. Either a wrong public key in the build, or a tampered patch.
- Boot crash — the patch loaded but the app crashed within
verifyAfter. The most common cause is an actually-broken patch.
Each entry has a version, an md5, a reason (one of
md5Mismatch, signatureFailure, crashRollback), and a
timestamp. The SDK reads this list at update-check time and
excludes matching bundles.
// In a debug overlay:
for (final entry in await FlutterPatcher.blacklist) {
print('${entry.version} blacklisted: ${entry.reason}');
}
To clear (for tests or after fixing the root cause):
await FlutterPatcher.clearBlacklist();
Asset patching
Since 0.1.3, you can include Flutter assets in your patch. The workflow is:
# 1. Rebuild your APK with the NEW assets registered
flutter build apk --release
# 2. Pack with --assets. Each path is relative to the project root.
flutter-ota build --name 1.0.1 --platform android --arch x86_64 \
--assets assets/hero.png,assets/strings/zh.json
The patch's assets/ tree is overlaid on the base APK's
flutter_assets/ at install time. Existing Image.asset() and
rootBundle.load() calls in the new Dart code pick up the new bytes
without any code changes.
Payload layout (patch.zip, v2)
patch.zip
├── lib/<abi>/libapp.so
├── assets/AssetManifest.json
├── assets/AssetManifest.bin
├── assets/<your-file-1>
├── assets/<your-file-2>
├── manifest.json
└── version.json
The two manifests are required by Flutter's asset bundle loader. They
describe which assets the new APK declares. The SDK writes both from
the new APK's flutter_assets/ automatically.
Asset path requirements
| Path format | OK? |
|---|---|
assets/hero.png |
✅ Relative to project root |
assets/icons/home.svg |
✅ Nested |
/Users/me/project/assets/x.png |
❌ Absolute |
*.png |
❌ Glob (you must list each file) |
assets/* |
❌ Glob (use @file syntax) |
To list a file from a separate file:
flutter-ota build --assets @asset_list.txt
# asset_list.txt:
# assets/hero.png
# assets/icons/home.svg
# assets/strings/zh.json
ABI handling
The patch contains lib/<abi>/libapp.so for every ABI you built
(default: arm64-v8a, armeabi-v7a, x86_64). Each ABI is patched
independently. If you ship to a single ABI only:
flutter-ota build --abi arm64-v8a
the resulting patch is ~3× smaller and applies only to ARM64 devices. Mixed-ABI fleets need to deploy one bundle per ABI per release.
Validation errors
The pack command fails fast on these:
- Asset not in the new APK: The path you passed to
--assetsdoesn't exist inbuild/app/outputs/flutter-apk/app-release.apk'sflutter_assets/. Re-runflutter build apkafter adding the asset. - Path outside the project root: rejected with a clear error.
- Asset not registered in pubspec.yaml: Flutter will silently exclude the asset from the bundle. The patch installs but the asset is still loaded from the base APK.
Security
Asset patches inherit the patch's MD5 + Ed25519 signature verification.
A tampered patch.zip is rejected at install time the same way a
tampered libapp.so is.
Custom update source
The five built-in backends (Supabase / Postgres / Cloudflare / AWS / PocketBase) cover most production needs. For anything else, you can either:
- Adapt the built-in
XxxUpdateSourceby passing a custom client to itsconfig.clientFactory. For example, aMockSupabaseClientfor tests, or a customS3Clientfor S3-compatible storage. - Implement your own by calling
performSharedUpdateCheckdirectly:
import 'package:flutter_ota_kit/flutter_ota_kit.dart';
import 'package:flutter_ota_kit_plugin_core/flutter_ota_kit_plugin_core.dart';
final result = await performSharedUpdateCheck(
db: myCustomDb,
storage: myCustomStorage,
channel: 'production',
platform: Platform.android,
updateStrategy: UpdateStrategy.appVersion,
appVersion: '1.0.0',
fingerprintHash: null,
minBundleId: nilUuid,
);
You supply any DatabasePlugin and StoragePlugin that implement the
plugin-core interfaces. See
Plugin core for
the contract.
- Skip the SDK's check entirely and parse the response yourself
into a
ServerUpdateResult, then callapplyUpdate(result):
final response = await myCustomHttpGet(...);
final result = ServerUpdateResult(
isUpToDate: response.upToDate,
patch: response.hasPatch
? PatchInfo(version: response.version, patchUrl: response.url, md5: response.md5)
: null,
shouldForceUpdate: response.forceUpdate,
message: response.message,
);
if (result.hasUpdate) {
await FlutterPatcher.applyUpdate(result);
}
PatchInfo
class PatchInfo {
final String version; // unique id, e.g. "1.0.1" or "fix-2024-01-15"
final String patchUrl; // https://, http://, or file://
final String md5; // hex; "" disables MD5 check (test only)
final String signature; // Ed25519 sig, base64; "" disables sig check
final int? targetVersionCode; // host APK versionCode; null = any
final String? message; // human-readable "what's new"
factory PatchInfo.fromJson(Map<String, dynamic> json);
Map<String, dynamic> toJson();
}
fromJson accepts both patchUrl (camelCase) and patch_url
(snake_case) for cross-language backend compatibility. Same for
targetVersionCode / target_version_code.
md5 must be lowercase hex, 32 chars (128 bits). The empty string is
allowed for test deployments but produces a warning at runtime.
signature must be a base64-encoded Ed25519 signature (88 chars
unpadded, or 88 with padding). The signature is over the MD5 hex
string, not the raw bytes — this is what the SDK computes at
verification time.
targetVersionCode is required when you want to prevent a patch
from applying to the wrong host APK. The backend should set it, but
the client also rejects mismatches as a defense-in-depth measure.
Version compatibility
flutter_ota_kit follows semver. Compatibility
matrix (last verified):
| flutter_ota_kit | Dart SDK | Flutter | Status |
|---|---|---|---|
| 0.1.10 | ≥ 3.13.0 | ≥ 3.47.0 | Current; recommended |
| 0.1.9 | ≥ 3.13.0 | ≥ 3.32.0 | Previous release; auto-update safe |
| 0.1.5 | ≥ 3.0.0 | ≥ 3.19.0 | Legacy; loader hook stable |
| 0.1.0 – 0.1.4 | ≥ 3.0.0 | ≥ 3.0.0 | Pre-autoApplyUpdates; no UI |
If you upgrade flutter_ota_kit, expect to need a new app release (the loader hook is part of the native plugin and the patch is bound to the host binary).
If you upgrade Flutter itself, you must ship a new app release before shipping more patches. Old patches are byte-coupled to the old Flutter Engine.
See also
- Architecture — internals, server protocol, signing, advanced config
- Crash Protection — auto-rollback, blacklist, Android version differences
- Configuration — env vars,
.env, resolution order - Backends — per-backend setup, env vars, CLI snippets
- Production Playbook — staged rollout, diagnostic reporting, emergency rollback
- Golden Testing — how Flutter's pixel-perfect test system works (and how this project uses it for the forced-update overlay)
Classes
- FlutterPatcher Architecture API-reference Crash-protection
- Android-only Flutter hot-update entrypoint.