native_workmanager 1.8.0
native_workmanager: ^1.8.0 copied to clipboard
Background task scheduling for Flutter — 25+ native workers (HTTP, image, crypto, file), task chains, zero Flutter Engine overhead.
Changelog #
All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
1.8.0 - 2026-09-11 #
Added #
-
Opt-in TLS certificate pinning for every HTTP-ish worker (
HttpRequestWorker,HttpDownloadWorker,HttpUploadWorker,HttpSyncWorker,ParallelHttpDownloadWorker,ParallelHttpUploadWorker,WebSocketWorker) via a newcertificatePinningparameter and theCertificatePin/CertificatePinningclasses:NativeWorker.httpRequest( url: 'https://api.example.com/data', certificatePinning: CertificatePinning([ CertificatePin( hostname: 'api.example.com', sha256Pins: ['sha256/AAAA…', 'sha256/BBBB…'], // current + backup ), ]), )Per-request, not process-wide — pinning one host leaves every other host, including other hosts in the same task's redirects, on default validation. Pinning is additional to the platform's own chain validation, never a replacement: a matching pin still results in the OS performing its own expiry, hostname and trust-store checks (Android: OkHttp's
CertificatePinner; iOS: aURLSessionDelegatethat checks the pin and defers toperformDefaultHandling).This closes a real gap, not a green-field feature: both platform bridges already had a
certificatePinningconfig field on some workers (added at an unknown earlier point, evidently for exactly this), but it was unreachable from the public Dart API on every worker —lib/had zero references to it. Fixed here: wired into the 2 platforms × 7 workers that needed it, and 2 real bugs found and fixed along the way:- iOS: the pin comparison hashed the wrong bytes.
SecKeyCopyExternalRepresentationreturns a certificate's raw public key, but asha256/…pin — the form every pin-generating tool (OkHttp, openssl, TrustKit) emits, and the same form Android'sHttpSecurityHelperalready expected — is the hash of the full SubjectPublicKeyInfo, which prefixes the key with an ASN.1 AlgorithmIdentifier specific to the key type. Confirmed empirically against a live TLS handshake: the raw-key hash and the correct SPKI hash are different values, and only the SPKI one matches an openssl-verified reference. Any pin generated by a standard tool would never have matched on iOS. Fixed with an SPKI header table (RSA-2048/4096, EC P-256/P-384, transcribed byte-for-byte from kmpworkmanager's own verifiedTlsPinning.ios.ktrather than re-derived) and rejection of unsupported key types rather than silently waving them through. - iOS: a pinned session could serve a cached response from an earlier,
differently-pinned request to the same URL, without a new TLS
handshake and without the pin ever being checked on that particular
call —
URLCache.sharedis process-wide and independent of whichURLSessionserves a request. Found by the device test itself: "wrong pin rejects the connection" passed in isolation but failed when run right after "correct pin lets the request through" against the same URL. Fixed by disabling caching on every pinned session (requestCachePolicy = .reloadIgnoringLocalAndRemoteCacheData,urlCache = nil) — a pinned session's whole purpose is to verify the live connection every time.
iOS's trust-evaluation shape was also changed defensively (check the pin, then always defer to
performDefaultHandlingrather than manually callingSecTrustEvaluateWithErrorand supplying a credential) on kmpworkmanager's own field report that the manual-evaluation pattern rejected valid chains on their test hardware — not reproduced on this project's own hardware (SecTrustEvaluateWithErrorreturnedok=truecleanly here), so this is recorded as a defensive simplification adopted from a peer implementation's experience, not a bug this project independently confirmed.Device-verified on a real Pixel 6 Pro and an iOS simulator: a correct pin lets a real HTTPS request through, a wrong pin genuinely rejects the connection (not silently ignored), and a worker with no
certificatePinningconfigured is byte-for-byte unaffected. SeeTLS Certificate Pinningindevice_integration_test.dart. - iOS: the pin comparison hashed the wrong bytes.
Changed #
-
kmpworkmanager engine
3.4.1→3.5.0("Hardening" — 20 bug fixes, 0 public API changes;kmpworker.api, the JVM ABI, is byte-identical between the two tags, soKMPSchedulerBridge.swift, the FROZEN bridge file, needed no changes this bump). Fixes that reach this plugin's actual behavior without any Dart-side change:ExistingPolicy.KEEPno longer deletes a pending task's spilled input file before the enqueue decision is made — a repeatenqueue(id, policy: ExistingPolicy.keep)call could previously run withinput = null.- A chain step's input and its predecessor's output are now checked against a shared budget, not independently against 8 KB each — a legal pair could meet at ~16 KB and silently kill the chain before any worker code ran, because WorkManager merges and caps the pair at 10 240 bytes.
- Retry backoff is no longer perfectly deterministic (equal jitter,
[delay/2, delay]) — stops synchronized retry storms after an outage. This is an intentional timing change; tests asserting exact retry delays may need widened tolerances. TaskTrigger.Exact:AlarmManagerrefusing an exact alarm no longer leavesAlarmStoreclaiming an alarm the system never held.TaskTrigger.Windowed(iOS): a task no longer loses every constraint (requiresNetwork,requiresCharging,isHeavyTask,maxRetries) on its first retry — it was silently downgraded to a ~30 sBGAppRefreshTaskRequestbudget instead ofBGProcessingTaskRequest, with the caller'smaxRetriesreplaced by the default.- iOS: a chain long enough to span 5+ BGTask windows is no longer
quarantined as a "poison pill" and deleted; a cancellation during
executeChain's prologue (BGTask expiry is the normal way that happens) no longer loses the chain outright. - iOS: task/chain ids starting with
.are no longer invisible toqueryTasks/computeIosTaskState/cancelByTagwhile still being directly loadable by id — user-supplied task ids could hit this. - Both platforms' event-store low-disk guard no longer returns a freshly minted event id after writing nothing — that silent-data-loss path now surfaces as a failure instead.
Both of 3.5.0's own "breaking changes" are non-issues for this plugin, verified by inspection:
KmpHeavyWorker.FGS_MEDIA_PROCESSING's constant fix (4096→8192) only affects callers who hardcoded the old wrong value, and this plugin already reads the platform constant; this plugin does not useFakeBackgroundTaskSchedulerin its own tests.The bundled
KMPWorkManager.xcframeworkwas rebuilt from the kmpworkmanagerv3.5.0git tag (notHEAD).
Fixed #
- Android & iOS:
SecurityValidator.sanitizedURL()leaked HTTP Basic credentials in a URL's authority (https://user:pass@host/...) into logs and persistedWorkerResultfailure messages. It redacted the query string but never touched RFC 3986 UserInfo, so a URL carrying its credentials in the authority — still common for internal services and S3-style pre-signed endpoints — printed the password verbatim on every HTTP worker (HttpRequestWorker,HttpUploadWorker,HttpDownloadWorker,HttpSyncWorker, the parallel variants). Same bug shape kmpworkmanager's own (separate, unrelated)SecurityValidator.sanitizedURLhad just fixed — found by comparison while reviewing that fix, not by kmpworkmanager itself (this plugin'sSecurityValidatorshares no code with theirs).
Added #
NativeWorkManager.isTaskCancelled(taskId)— answers #66: cancelling a task (viacancel/cancelAll, or the OS reclaiming background time) does not interrupt a runningDartWorkercallback, because Dart has no API to preemptively abort aFuturethat is already executing. A callback doing long-running work can now poll this cooperatively between chunks of work and return early once it turnstrue. Wired on both platforms: Android (CoroutineWorkercancellation), iOS foreground/simulator (main-isolateactiveTaskscancel), and iOS true-background BGTask expiration. SeeDartTaskCancellationRegistry(Kotlin and Swift) and theissue_66_*entries indevice_integration_test.dart. Device-verified on a Pixel 6 Pro and an iOS simulator — the Android half was a no-op until the registry-clear-timing fix below.
Fixed #
- Android: cancelling a
DartWorkertask while its callback was running could leak the headless Flutter engine (~50 MB) or dispose it while an orphaned callback was still executing.FlutterEngineManager .executeDartCallbackrethrew externalCancellationExceptionbefore its own dispose/idle-timer logic ever ran. Found investigating #66. - Android:
isTaskCancelled(taskId)cleared its own answer the instant it was set, making the feature above a no-op on real hardware — the registry entry was cleared from afinallytied to the cancelling coroutine's own lifetime, but the orphaned Dart callback keeps polling for a while after that coroutine unwinds (the entire premise of cooperative cancellation). Every unit test stayed green because a mocked channel can't reproduce this timing race. Found only once a real device became available to run theissue_66device test on. - iOS:
BGTaskSchedulerManagernever actually cancelled the runningTaskon BGTask expiration — onlyactiveWorker.stop()was called (a no-op forDartCallbackWorker), so the work backing an expired task kept running in the background past the task's own completion. Found investigating #66. - iOS: cancelling a
useBackgroundSession: trueHttpDownloadWorkerorHttpUploadWorkernever actually stopped the transfer (#69). Both registered their backgroundURLSessionTaskwithBackgroundSessionManagerunder a throwaway random id instead of the real task id, socancel()/cancelAll()/cancelByTag()— which look the task up by the real id — always missed. The download/upload kept running in the background regardless. Found auditing for bugs similar to #66; verified red-then-green with a device test that reproduces the bug on the pre-fix code before confirming the fix. - CI never actually honoured any Flutter version pin.
flutter-version-file: .flutter-versionpointed at a plain-text file — subosito/flutter-action'sflutter-version-fileonly parsespubspec.yaml,.fvmrc, or.fvm/fvm_config.json, so it silently failed to parse and fell back to thechannelinput's default ofstable(non-empty even when the key is omitted from the workflow yaml), floating every job to whatever Flutter was newest that day. Switched to.fvmrc(the project already manages Flutter locally viafvm) and explicitly emptychannelas defense in depth. native_workmanager_genhad noanalysis_options.yamlof its own, sodart analyzewalked up to the root plugin's — which includespackage:flutter_lints/flutter.yaml, unresolvable against a pure-Dart package that only depends onlints. That silently broke analysis for the whole generator package (every run just warned and skipped), hiding one unused import and two lint issues in its own test suite.
Test infrastructure #
stress_and_system_test.dart'sissue_30 stresscase had a wait-budget bug that looked, from the outside, exactly like a real "timeoutMs drops the terminal event" product bug — enough that an earlier draft of this entry claimed exactly that before the real cause was traced down. ADartWorkerwhosetimeoutMsfires returns a retryable failure by design (matching #46/#47's "return falseretries" behavior), and the test never setmaxRetries: 0. With the default of 3 retries and each platform's default backoff (Android: WorkManager's own; iOS: 30 s initial, exponential), a timed-out case doesn't reach a terminalWorkInfostate until all retries are exhausted — which routinely exceeds the test's own wait budget. That is not a dropped event: isolating a singleDartWorker(timeoutMs: 1000, delayMs: 2000)withmaxRetries: 0delivers its terminal event at ~1020 ms, exactly at the timeout mark, confirmed on both platforms. Fixed by addingmaxRetries: 0to the enqueue calls (matching what the test actually intends to measure) and replacing the silentcatch (_) { actuals.add(0) }— which made "no event ever arrived" and "correctly failed" read as the same outcome — with an explicitexpect(neverArrived, isEmpty)that names the case if a real dropped-event regression ever does occur.
1.6.1 - 2026-09-07 #
Fixes a regression in 1.6.0. If you are on 1.6.0 and use any worker whose result contains a
nested list or object — FileSystemWorker (fileCopy, fileMove, list, …) above all — its
completion event never reached NativeWorkManager.events on Android, so code awaiting that event
waited forever. Upgrade.
Added #
- A device regression guard,
issue_62:indevice_integration_test.dart, that fails if a completion event is ever dropped again. It differs from the existing tests in the two ways that let 1.6.0 ship broken: it uses a worker whose payload is nested (the crypto/hash workers return a flat map, which is why probing with one missed the bug entirely), and it subscribes toNativeWorkManager.eventsdirectly, with nogetTaskRecordfallback to synthesise an event when none arrives. Verified by re-introducing the defect: the test goes red and names it.
Fixed #
-
Android: a completed task could emit no completion event at all (#62). Affected every worker whose result payload contains a nested list or object —
FileSystemWorker(fileCopy,fileMove,list, …) most visibly. The task ran, wrote its result and persistedstatus=completed, but nothing arrived onNativeWorkManager.events, so an app awaiting the event waited forever.Root cause was in v1.6.0's own
resultDatafix:unwrapStepOutputdecoded thekmp_step_outputenvelope withJSONObject.get(), which returnsorg.jsontypes for nested values. Flutter'sStandardMessageCodeccannot encode those, soeventSink.success()threwIllegalArgumentException: Unsupported value: [...] of type 'class org.json.JSONArray'and the entire event — not just the payload — was dropped. It now decodes recursively to plain Kotlin types through the converter the plugin already used elsewhere.
Also fixed on the same path: the plugin's own
TaskEventBuscarriesoutputDataas a JSON string, and Dart readsmap['resultData'] is Map ? … : null, so a string was silently discarded and events delivered that way arrived withresultData == null. Both paths now hand Dart the same decoded shape.Not caught earlier because
native_workers_test.dart's_waitEventfalls back togetTaskRecordand synthesises an event when none arrives — the device tests passed while the public API was broken. The benchmark harness has no such fallback, which is what exposed it.
1.6.0 - 2026-09-06 #
Engine bump, a new Android diagnostics API, and a documentation correction that removes every performance number the project could not reproduce.
Added #
-
Battery-restriction diagnostics (Android). The most common real-world reason a periodic task runs late is the OS — or the OEM — deferring it, and there was no way to see that from Dart.
NativeWorkManager.batteryRestriction()reportsisExempt(PowerManager.isIgnoringBatteryOptimizations),manufacturer(Build.MANUFACTURER, lowercased) andcanOpenSettings— the last resolved withresolveActivityon the actual device rather than assumed. It is a pure diagnostic: it schedules nothing and does not requireinitialize(), so it is safe to call during startup.NativeWorkManager.openBatteryOptimizationSettings()opens the system list. Needs no permission.NativeWorkManager.requestDisableBatteryOptimization()shows the direct "allow" dialog.
isExemptis not a guarantee. It reflects one stock-Android list; Xiaomi, Samsung, Huawei, Oppo and Vivo run their own task killer on top of it, so a device can reporttrueand still stretch a 15-minute task into hours. The API and the docs say so rather than implying otherwise.There are deliberately no per-manufacturer settings deep links. Those "autostart" and "protected apps" screens are undocumented internal activities that get renamed between firmware builds; a shipped table of them rots on devices this project cannot test.
manufactureris passed up raw so an app can word its own guidance.requestDisableBatteryOptimization()requires the host app to declareREQUEST_IGNORE_BATTERY_OPTIMIZATIONS. This plugin will never declare it: it is Play-policy restricted and a library manifest merges into every consumer app, so declaring it would drag apps that never call this API into a policy review — the same mistake that got the foreground-service permissions removed. Without it the call returnsmissingPermissionrather than throwing.ManifestGuardTestnow guards both permissions.On iOS all three report "not applicable" —
isExemptisnullsoBatteryRestrictionReport.isSupporteddistinguishes "we asked and the answer is no" from "there is nothing to ask".
Changed #
-
kmpworkmanager core upgraded 3.3.1 → 3.4.1 (spans two upstream releases). No Dart API change — this release is the engine bump plus the one bridge fix it forced. The parts that reach plugin users without any code change on their side:
- Android:
TaskTrigger.exactactually runs its worker now. The defaultAlarmReceiverregistered byKmpWorkManager.initialize()— the one this plugin uses — logged the fired alarm and finished the broadcast without ever resolving or invoking the scheduled worker. Every exact-alarm task fired on time and did nothing. Fixed upstream in 3.4.0. - Android: exact-alarm tasks survive a process kill mid-execution. Alarm metadata was
removed from
AlarmStorebefore the work ran; a kill in that window lost the task with no trace and no reboot recovery. Removal now happens after a definitive outcome. - Android: expedited work is now gated on task priority. Previously every eligible task
(no delay, not heavy, no charging/unmetered requirement) was requested as expedited work
regardless of priority. Standalone
enqueue()has no priority parameter, so plugin tasks areNORMALand are no longer blanket-expedited — expect slightly later scheduling for them under WorkManager quota pressure. Chain steps markedCRITICAL/HIGHare unaffected. - Android:
KmpHeavyWorkerretries instead of discarding on a transient foreground-service denial. ASecurityException/IllegalStateExceptionfrom OS background-start policy (battery saver, OEM restriction) returned a permanent failure and dropped the task. - Android: file leaks closed — the large-input overflow file for a task rescheduled under
ExistingPolicy.replaceis now deleted rather than orphaned until the 24 h janitor sweep, and chain-step overflow files are cleaned up when a chain is cancelled before the step runs. - Android:
getExecutionHistory()records the real failure reason.ExecutionRecord.errorMessagewas always persisted asnull, so persisted history lost every diagnostic detail (live completion events were unaffected). - iOS:
ExistingPolicy.keepno longer behaves likereplace. For a task id not declared inInfo.plist— the normal case, since ids are usually per-instance — the KEEP check queriedBGTaskSchedulerfor an identifier that is never submitted under its own name, so it always missed. A repeatenqueue(policy: keep)therefore discarded the first call's metadata and could duplicate the task. - iOS: chain progress can no longer regress. A failed progress flush unconditionally re-buffered its pre-failure snapshot, which could clobber a newer in-memory value; a process kill after that made a resumed chain re-run an already-completed step.
- iOS: a dynamic task is no longer silently dropped when a host app's
WorkerFactorythrows something other thanIllegalArgumentException— the case that permanently stopped a periodic task's recurring schedule. - iOS: standalone tasks now honour
requiresUnmeteredNetwork,requiresChargingand the battery-not-low constraint, andConstraints.backoffPolicy/backoffDelayMsaffect retry timing when explicitly set. Previously only chain steps enforced any of these. - iOS: metadata, chain definitions and chain progress are written atomically
(temp file +
replaceItemAtURL) instead of viaNSString.writeToFile(atomically:), and a completed background download is moved without the previous delete-then-move window.
Upstream 3.4.0 carries one breaking change —
AlarmReceiver.onReceive()no longer removesAlarmStoremetadata before dispatch — that affects only apps subclassingAlarmReceiverdirectly. This plugin subclasses neither it norBaseAlarmReceiver, so no host-app migration is required. - Android:
-
Removed every unmeasured performance claim from the documentation. The docs advertised a
~2 MBRAM footprint,< 50 mstask startup, and100% Guaranteedsurvival of process death — for this package and, in the comparison table, for four competitors. None came from a run anyone could reproduce, andbenchmark/results/had been empty since the harness was built in February.Two of those were not merely unsourced but wrong. Nothing guarantees background execution on either platform; the real property is that a task is restored after process death via WorkManager/SQLite and BGTaskScheduler persistence, which is what the docs now say. And
< 50 msis contradicted by this project's own harness, which reports 698 ms and 794 ms for its two startup benchmarks.Comparison tables now carry dated capability rows only. Numbers return when there are runs to back them.
-
Build Validation (iOS)could never pass on a release PR. The job runsflutter build ios, and CI's Flutter has SwiftPM on by default, so it resolvedPackage.swift's remotebinaryTarget— a GitHub release asset that does not exist until the release is cut. Every release that changes the bundled framework therefore had a red check by construction, which is how a gate stops being read.Split into two: the build validates the CocoaPods path (both are shipped), and a new
scripts/verify_spm_release.shchecks everything about the SwiftPM path that does not need a live asset — manifest parses, product is hyphenated (issue #52),binaryTargetis remote with a checksum (issue #49), notestTargetis declared (v1.4.3) and the xcframework has both slices — then verifies the published zip's sha256 against the declared checksum once the asset exists. That last check is a hard failure onmainand on tags, and the exact check that would have caught the wrong checksum shipped in v1.4.5. It is runnable locally before tagging. -
benchmark/README.mdno longer claims the project provides independent community verification. It provides transparent methodology and reproducibility; the third leg needs published results, which do not exist yet. -
ROADMAP.md: replaced the adoption-metric KPI table (pub.dev likes, stars, weekly downloads, "Enterprise Users") — those targets predate the July 2026 decision that this is a portfolio project, not a commercial one, and steering by them pushed prioritisation toward breadth. Cross-integration adapters, the templates repository, desktop support, cloud coordination and enterprise rate limiting moved to a Deliberately deferred section with the reason for each recorded.
Fixed #
-
An inverted
TaskTrigger.windowed(earliest:, latest:)window (latest < earliest) is rejected with a normal Dart-visible error instead of taking the app down on iOS. kmpworkmanager 3.4.1 added construction-time validation toTaskTrigger.Windowed; a KotlinIllegalArgumentExceptionthrown from a constructor exported to Swift cannot be caught and terminates the process.KMPSchedulerBridgenow rejects the inverted window before constructing the trigger, which flows into the existing "Invalid trigger configuration" error path. Android already parsed the trigger inside a guarded block and surfacesENQUEUE_ERROR. No Dart-sideassertwas added — the Dart API still accepts the combination and lets the platform answer. Covered bykmp_341: inverted windowed trigger errors instead of crashingindevice_integration_test.dart. That test is only meaningful on iOS — removing the guard kills the app there; Android passes either way, because its existing guarded parse already turns upstream's own exception intoENQUEUE_ERROR. -
CI never honoured
.flutter-versionat all. Everysubosito/flutter-actionstep passed bothflutter-version-file: .flutter-versionandchannel: stable, and the channel wins — so the pin was decorative and all 15 job setups ran whatever stable happened to be latest (3.47.2 at time of writing). Thechannel:line is now removed wherever a version file is given, so the pin actually takes effect.This was also the real cause of the long-red
Analyze & Formatjob, which the Flutter bump below did not fix: newer stable Flutter appendsbuild/**,android/**andios/**to theanalyzer.excludelist inanalysis_options.yamlandexample/analysis_options.yamlwhen it runs. That left the working tree dirty mid-job, anddart pub publish --dry-runexits 65 on a dirty checked-in file. Both files now declare those excludes up front — they are correct excludes in their own right — so nothing rewrites them. -
CI was validating against a Flutter the project no longer uses.
.flutter-versionpinned 3.27.4 (Dart 3.6.2, released 2025-02-05) while development and the consuming app run 3.41.9 (Dart 3.11.5). Two concrete consequences, both now fixed by bumping the pin:Analyze & Formathad been red onmainsince before v1.5.0.dart pub publish --dry-runreportedanalysis_options.yamlandexample/analysis_options.yamlas "modified in git" during the run — an old-toolchain artefact that does not reproduce on 3.41.9 (0 warnings).native_workmanager_gendeclaressdk: '>=3.9.0', which cannot resolve on Dart 3.6.2, so the generator package was never properly validated by CI. That is the source of theFailed to resolve package URI "package:flutter_lints/flutter.yaml"warnings in the logs.
The published minimums are unchanged — the plugin still declares
flutter: '>=3.27.0'/sdk: '>=3.6.0', so no consumer is dropped. Note the trade-off this creates: CI now exercises the version actually shipped against, and no longer exercises the declared floor. A matrix over both is the proper fix and is tracked in ROADMAP. -
pubspec_overrides.yamlis now gitignored. CI writesnative_workmanager_gen/pubspec_overrides.yamlto point the generator at the local plugin. Committing it — or apubspec.lockresolved with it in place — drags the Flutter SDK's pinnedmetainto the generator and forcesanalyzerbelow the version it targets, which is exactly what that package's pubspec comment warns against. -
TaskEvent.resultDatanow actually carries a worker's result on Android — theworker_results.darthelpers have never returned data there before. Measured on a Pixel 6 Pro with a SHA-256 hash task:resultDatakmpworkmanager 3.3.1 (v1.5.0) null3.4.1, before this fix {kmp_step_output: "{\"hash\":\"2cf2…\"}"}3.4.1, after this fix {hash: 2cf2…, algorithm: SHA-256, fileSize: 5}kmpworkmanager 3.4.0's InputMerger change serialises
WorkerResult.Success.datainto WorkManager's outputDataunder a singlekmp_step_outputkey as a JSON string, so the next chain step can merge it. ButWorkInfo.outputDatais also what this plugin forwards to Dart, soCryptoResult.from(...),ImageResult.from(...)and every sibling helper were reading a map whose only key was the envelope and returning all-null fields. The plugin now flattens the envelope before forwarding. Maps without it pass through untouched, and a malformed payload degrades to the raw map rather than failing a worker that genuinely succeeded. -
Every
worker_results.dartparser was reading at least one key the native workers never send. WithresultDatapermanently null on Android, nothing ever exercised these against a real payload, so the schema they were written to had drifted from what the workers emit:Parser Was reading Workers actually send DecompressionResultoutputPath,extractedCount,totalSizenone of them — Android targetDir/extractedFiles/totalBytes, iOSfilesExtractedCompressionResultfileCount,totalSizeAndroid filesCompressed,originalSizeImageProcessResultwidth,height,fileSizeAndroid processedWidth,processedHeight,processedSizeFileSystemResultentries,countfiles,fileCount(iOS also sendsentries)CryptoResultoperationiOS only — Android omits it ParallelUploadResultfileResultsiOS only — Android sends counters alone DecompressionResult.fromwas the worst: not one of its three keys exists on either platform, so it returnednullfor every real payload. Each parser now accepts the spellings its platforms actually use, preferring the most specific. This is purely additive — every key that worked before still works. -
ParallelDownloadResultdocuments a shape no worker produces.ParallelHttpDownloadWorkerdownloads a single file over parallel range requests and reports the single-file shape, soDownloadResultis the correct parser for it. The class doc now says so instead of pointing at that worker; it is kept rather than removed because it is exported public API. -
FileSystemResult.entrieswas always null on Android, and.countalways null on both platforms. The parser readentriesandcount; the workers emitfiles(objects carryingpath) andfileCount, and only iOS also sends an explicitentriesarray.countwas emitted by neither. Both fields are now derived from the sharedfiles/fileCountpayload, with the platform-specific keys preferred when present — rather than widening the native payload, sincefilesalready carries the paths and duplicating them costs room against WorkManager'sDatabudget. -
iOS offline-queue enqueue had never worked. Dart invokes the channel method
offlineQueueEnqueueand Android registers that name, but iOS registeredenqueueOfflineQueue— the same two words the other way round — so every call fell through toFlutterMethodNotImplementedand threwMissingPluginException. Found by auditing every Dart call site against both native dispatch tables after thegetTasksByStatusbug, which is the same defect class. -
Added a method-channel parity guard (
test/unit/channel_method_parity_test.dart). It reads the real dispatch tables inNativeWorkmanagerPlugin.ktandNativeWorkmanagerPlugin.swiftand asserts every method Dart invokes is registered on both, the wayManifestGuardTestreads AndroidManifest.xml. This class of bug is invisible to normal testing — a mock will happily answer a method no platform implements, so unit tests, analysis and both native compilers all stay green while a public API is dead in production. Verified to fail (naming the exact method) when either bug is re-introduced. -
A result parser could throw
TypeErrorinstead of returning data. The two delivery paths disagree on the Dart type of a nested field: the event channel hands lists over already decoded, while thegetTaskRecordfallback — used whenever the completion event is missed — hands them over as the JSON text they were persisted as.data['files'] as List?therefore threwtype 'String' is not a subtype of type 'List<dynamic>?'on the fallback path only, taking down a caller trying to read a task that had actually succeeded. Every list field inworker_results.dartnow accepts either form and returnsnullrather than throwing when it is neither. -
The
ImageProcessWorkerdevice tests failed on every Android run against a corrupt fixture.native_workers_test.dart's_minimalPngdescribed itself as a minimal valid PNG but was not one: its IDAT chunk carried a wrong CRC and the bytes following it did not form a valid IEND.file(1)still reported "PNG image data, 32 x 32" — it only reads the header — and iOS's decoder accepted it, but Android'sBitmapFactoryvalidates the whole stream and refused it with "Failed to decode image". Android was correct and the fixture was wrong. Replaced with a genuinely valid 32×32 RGB PNG with correct per-chunk CRCs, which is also smaller (99 bytes vs 138). -
The
exact triggerdevice test failed on every iOS run.ExactTriggerhas been rejected in Dart on iOS since v1.2.1 — BGTaskScheduler cannot honour an exact time, so the API refuses rather than accepting a request it would miss by hours — but the test calledenqueueunconditionally and asserted a contract the library deliberately does not have. It now assertsthrowsUnsupportedErroron iOS and keeps the accepted/rejected assertion on Android. -
doc/ANDROID_SETUP.mdtold readers to hand-roll aMethodChannelcallingACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONSwith no mention of the Play-policy restriction. Replaced with the new API and the warning. Also corrected a "see §3 above" pointer that aimed at Killed-App Support rather than the battery section.
Notes #
BackgroundTaskScheduler.enqueue()gained defaultedtagsanddeadlineMsparameters upstream. Kotlin defaults keep Android source-compatible, but they are not exported to Swift, so the ObjC selector changed andKMPSchedulerBridge.swiftnow passes both explicitly at their Kotlin defaults (empty set /nil) — behaviour is identical to 3.3.1.- The new upstream API surface — task tags with
cancelByTag, per-task deadlines, the chainInputMerger(mergeOutputFromPreviousStep) andExistingPolicy.UPDATE— is not exposed through the Dart API in this release. Wiring it is tracked separately.
1.5.0 - 2026-08-23 #
Added #
-
NativeWorkManager.iosLiveActivity— ataskId-scoped progress filter for iOS Live Activities.iosLiveActivity.onProgress(taskId: ...)returns just one task's slice of the existing progress stream, so a Live Activity / Dynamic Island can subscribe to the task it renders without filtering by hand.Read the scope carefully: this is a Dart-side convenience filter over the progress EventChannel that
NativeWorkManager.progressalready exposes. It does not call ActivityKit, and it does not wrap the KMPIosLiveActivityBridgein the bundledKMPWorkManager.xcframework. Starting, updating and ending theActivity<Attributes>remains your app's job — theActivityAttributestype lives in your target, not in this plugin. On non-iOS platformsonProgressreturns an already-closed stream; useNativeWorkManager.progressfor cross-platform progress.If progress never needs to reach Dart, observe the KMP bridge directly from Swift instead —
IosLiveActivityBridge.companion.shared.startObserving(taskId:onProgress:)runs with no Flutter engine attached, which suits a killed-app background download better. Both routes are documented onIosLiveActivityBridge. -
Public
GraphExecutionconstructor.GraphExecution(graphId, result)is now public API;GraphExecution.internal(...)is deprecated and forwards to it. This is what letsFakeWorkManagerbuild a graph handle without tripping the analyzer (see Fixed below). -
CLI SwiftUI
@maindetection.dart run native_workmanager:setup(andnative_workmanager:setup_ios) now inspectios/Runnerfor a SwiftUI@mainApp and report whether@UIApplicationDelegateAdaptor(AppDelegate.self)is wired — without it the AppDelegate lifecycle never runs, so BGTask launch handlers registered in+loadnever attach.
Fixed #
- Pub.dev static analysis back to 160/160.
FakeWorkManagercalledGraphExecution.internal, a@visibleForTestingmember, fromlib/— aninvalid_use_of_visible_for_testing_memberwarning that cost analysis points. The constructor is public now and the annotation is gone. - Analyzer guardrail:
invalid_use_of_visible_for_testing_member: erroradded toanalysis_options.yamlso the same class of violation fails CI instead of quietly costing pub points. setup's iOS checks no longer stop at the first Info.plist problem. The SwiftUI@maincheck now runs even whenios/Runner/Info.plistis missing or malformed — a non-standard plist layout is exactly what a SwiftUI-lifecycle project is likely to have.OfflineQueuecould lose a queued task or crash when a task was cancelled mid-flight (pre-existing)._processHead()captured the head slot, then awaited the task's completion event for up to an hour.cancel()is synchronous and mutates the pending list directly, so it could land inside that window — after which the failure path still wrote back positionally (_pending[0] = …/removeAt(0)). If the cancel emptied the queue the retry write threwRangeError (index): Valid value range is empty: 0; if another entry had become the head, that entry was silently overwritten by the cancelled task's retry slot and never ran. Both branches now resolve the slot by identity — matching the success path, which already did. A cancelled in-flight task is dropped rather than retried or dead-lettered. Covered bytest/unit/offline_queue_cancel_race_test.dart, which reproduces both failures.- Flutter engine could leak on Android after a channel error (pre-existing).
FlutterEngineManager.executeDartCallbackincrementedactiveTaskCountbefore the try whosefinallydecremented it, so anything thrown in between —channel.invokeMethodhitting a detached engine, for instance — leaked the counter permanently.activeTaskCount.get() <= 0then never held, so the engine was never auto-disposed (~50 MB retained for the process lifetime). The count is now released exactly once on every exit path, still before the timeout/dispose checks that read it. - iOS Dart-callback continuation leaked on timeout (pre-existing).
invokeCallbacksuspended on a barewithCheckedThrowingContinuationwith no cancellation handling. When the enclosing task group's timeout won — the hung-isolate case, where the method-channel reply never arrives — the continuation was never resumed: Swift loggedSWIFT TASK CONTINUATION MISUSE: continuation was leakedand the child task stayed suspended holding the channel. It now runs underwithTaskCancellationHandlerwith a single-resume guard, so cancellation settles it. - DartWorker cancellation was swallowed on Android (pre-existing, not a 1.5.0 regression).
FlutterEngineManager.executeDartCallbackwrappedwithTimeout { resultDeferred.await() }in a genericcatch (e: Exception).CancellationExceptionis-aException, so cancelling a DartWorker — or cancelling its parent Job — was reported as an ordinaryfalseresult instead of propagating, breaking structured concurrency. It is now rethrown ahead of the generic catch. The timeout path is unchanged:TimeoutCancellationExceptionis caught at thewithTimeoutcall site and converted totimedOut, so it never reaches the new guard. OfflineQueueclass doc contradicted the implementation (pre-existing). The class-level docs saidenqueuethrows aStateErrorwhen the queue is full; it has always dropped the entry silently and returned normally (asenqueue's own doc correctly stated). A caller following the class doc would have written atry/catch (StateError)that never fires. The class doc now matches the behaviour and points atpendingCount.- Swift snippet in
IosLiveActivityBridgedocs did not compile. It showedIosLiveActivityBridge.shared, but Kotlin/Native exposes the singleton through the Companion object — the generated header declares only acompanionclass property on the bridge. The example now usesIosLiveActivityBridge.companion.shared.
Changed #
-
The iOS graph-node delay is no longer inline in DAG logic.
TaskGraph._scheduleNodehard-coded a 1-secondTaskTriggerdelay for iOS to work around BGTaskScheduler dropping back-to-back submissions. The workaround stays (removing it needs a per-submission hook in the KMP scheduler — tracked in ROADMAP), but it is now a named_iosNodeSubmissionStaggerconstant behind_nodeTrigger(), documented as a platform quirk rather than domain logic. Downstream scheduling also marks its fire-and-forget call explicitly withunawaited(). -
The cancellation-rethrow invariant guard is now checked per function, not per file.
test/unit/cancellation_rethrow_invariant_test.dartused to regex the whole worker source for a singlecatch (e: CancellationException) { throw e }.HttpUploadWorker.kthas two suspend functions, and the one rethrow indoWork()made the file pass whilehandleRawBodyUpload()had no guard at all — the test built to catch this bug class could not see it. It now parses eachsuspend funbody by brace depth and requires either a rethrow or an explicit exemption carrying a written reason.handleRawBodyUpload()gained the matching rethrow (uniformity: its guarded region is blocking OkHttp with no suspension point, so there was no live bug — but the two upload paths must not diverge). -
⚠️ Android
compileSdkraised 35 → 36, and consuming apps now needcompileSdk 36or higher. This is forced by the kmpworkmanager bump, not a choice: 3.3.0 droppedkoin-androidand began declaringandroidx.coredirectly, which resolvesandroidx.core:core-ktxto 1.17.0, and that artifact's AAR metadata requires everything depending on it to compile against API 36+. Verified by a controlled A/B on this repo — with kmpworkmanager 3.2.0:native_workmanager:testDebugUnitTestexits 0 and nocore-ktx:1.17.0appears on the classpath; with 3.3.1 it exits 1 with "requires libraries and applications that depend on it to compile against version 36 or later". Apps already on Flutter's current defaultcompileSdkare unaffected; apps pinned to 35 must raise it. -
extension/devtoolsversion bumped 1.3.0 → 1.5.0 to match the monorepo (publish_to: none, so this affects nothing published). -
kmpworkmanager core bumped 3.2.0 → 3.3.1 — this spans two upstream releases (3.3.0 and 3.3.1). Both are pulled in by this bump:
From 3.3.0 — ⚠️ BREAKING for apps that used Koin transitively: kmpworkmanager no longer depends on Koin, and
kmpWorkerModule()/kmpWorkerCoreModule()are removed upstream. This plugin is unaffected — it has always calledKmpWorkManager.initialize()directly and never referenced Koin — but if your app was relying onkoin-corearriving transitively through this plugin's dependency tree, it no longer does; declare it yourself. Also from 3.3.0: iOS execution history and task events were being silently dropped (EventStore/ExecutionHistoryStorewere lazy bindings nothing ever resolved, sogetExecutionHistory()returned an empty list on iOS), andshutdown()left stale global registrations behind so ashutdown()→initialize()cycle pointed the event store at a dead registry.From 3.3.1: iOS single (non-chained) tasks never persisted their completion event or execution history — only chain executions showed up in
getExecutionHistory()on iOS; iOSSingleTaskExecutorused a wall-clock diff forExecutionRecord.durationMs, which an NTP sync mid-task could corrupt, nowTimeSource.Monotonic; and the KSP processor now fails the build on two@Workerclasses claiming the same name or alias instead of silently making one unreachable.
Security #
- iOS path traversal in task-metadata filenames (via kmpworkmanager 3.3.1). Caller-supplied
task and chain ids were used unsanitized as filenames at 13 call sites in
IosFileStorage; ids containing/, or equal to./.., could escape the intended directory. They are now percent-encoded. The escaping is deliberately narrow — only/, a bare./.., and a literal%— so ordinary ids ("nightly-sync","com.example.sync", UUIDs) map to the same on-disk filename as before and tasks scheduled before the upgrade keep resolving.
1.4.5 - 2026-08-06 #
Fixed #
- Task chain
{{taskId.outputKey}}data-flow placeholders (#57): the documented syntax for passing one chain step's output into a later step's config never actually worked on either platform.- iOS: step results were stored under flat, unprefixed keys, but substitution looked up the whole
"taskId.key"string as one key — the two never matched, so placeholders always stayed literal text. A parallel step's tasks also overwrote each other's stored result (only the last task to finish survived). Separately,AnyCodable'sCodableconformance had noInt32/Int64/Float/UInt64cases, so any worker result containing one of those types (e.g.ImageProcessWorker'soriginalSize/processedSize) silently failed to persist — the next step's substitution data came back empty with no error surfaced. - Android: had no substitution mechanism at all. Chains were built by enqueuing every step's
WorkRequestupfront via WorkManager's native.then()chaining, which freezes each step's config before any earlier step has even run — there was no point in time a later step could see a real predecessor output. Fixed by moving to a dynamic per-step enqueue (ChainHelper.buildAndEnqueueStep) driven byWorkInfocompletion, with output captured via a newChainResultCapturingWorkerdecorator and resumed idempotently viaenqueueUniqueWork(..., KEEP). - Both platforms now namespace each task's result under
"<taskId>.<key>", merge (not overwrite) across parallel tasks, and resolve a whole-match placeholder (the entire config value is one{{...}}) to the original typed value rather than a stringified one, so substitution can target numeric/bool config fields, not just strings.
- iOS: step results were stored under flat, unprefixed keys, but substitution looked up the whole
- Android: Foreground-service permissions no longer bundled unconditionally.
android/src/main/AndroidManifest.xmlused to declareFOREGROUND_SERVICE/FOREGROUND_SERVICE_DATA_SYNCand a hardcodedSystemForegroundServicetype override, merging them into every consumer app's APK regardless of whether it usedisHeavyTask/ForegroundNativeWorker— Google Play flags apps carrying foreground-service permissions they never exercise. These permissions are now consumer-app opt-in; seedoc/ANDROID_SETUP.md's "Android 14+ Foreground Services" section if you useisHeavyTask: true. Enforced going forward by a newManifestGuardTest.
Changed #
- kmpworkmanager core bumped 3.1.0 → 3.2.0, bringing the Android FGS-permission fix above (same root cause, fixed independently in this plugin's own manifest too — kmpworkmanager's manifest and this plugin's manifest are separate merge sources) plus two iOS-only changes bundled in the same upstream release:
FileCompressionWorkeron iOS now produces a real PKZIP archive viaplatform.zlib(previously an uncompressed-copy stub gated behindallowIosUncompressedFallback), and a newIosLiveActivityBridgeAPI for relaying worker progress to Live Activities/Dynamic Island (not yet wired into this plugin's public Dart API). BundledKMPWorkManager.xcframeworkrebuilt from kmpworkmanager v3.2.0 source and re-verified through the full 4-layer SwiftPM check.
1.4.4 - 2026-07-26 #
Fixed #
- DevTools Extension Loading Error (#55): Fixed extension failure where DevTools failed with
could not read file as String: devtools_extensions/.../index.html. Bundled compiled web assets into package rootextension/devtools/build/and removedextension/devtools/build/from.pubignoreso compiled DevTools extension assets are included inpub.devreleases.
1.4.3 - 2026-07-17 #
Fixed #
-
iOS SwiftPM: manifest rejected by stricter SwiftPM toolchains.
Package.swiftdeclared a test target withpath: "../Tests", which escapes the package root. Newer SwiftPM (Xcode 26.x) tolerates the escape, but stricter toolchains reject the entire manifest at load time with "target 'NativeWorkManagerTests' in package 'native_workmanager' is outside the package root" — which breaks dependency resolution for every SPM-enabled consumer app on those toolchains, the same failure mode as #49/#52. The test target is removed from the consumer-facing manifest (nothing ever executed it — no workflow or script invokesswift test— so no coverage is lost; the Swift test sources remain in the repo). Root cause confirmed by a controlled experiment: the CI job that reproduced the failure on the stricter toolchain goes green with the target removed.This closes the last known gap in the SwiftPM install path. All four layers are now verified automatically on every PR: remote binary target resolution, hyphenated product-name resolution, a full
flutter buildof a consuming app with SwiftPM enabled, and compile under SPM's strict module isolation.
1.4.2 - 2026-07-17 #
Fixed #
-
iOS SwiftPM resolution failed one layer above the 1.4.1 fix — Issue #52. Flutter's generated
FlutterGeneratedPluginSwiftPackagereferences every plugin by the hyphenated library product name (plugin.name.replaceAll('_', '-')influtter_tools— SwiftPM uses the product name asCFBundleIdentifierwhen linking dynamically, and bundle identifiers cannot contain underscores).Package.swiftexported the product asnative_workmanager, so SPM-enabled apps failed dependency resolution with "product 'native-workmanager' … not found in package 'native_workmanager'". The library product is nownative-workmanager; the package and target names keep their underscores. Thanks @zaqwery for the precise diagnosis — again. -
iOS: two workers did not compile under SwiftPM.
CryptoWorkerandFileSystemWorkeruseUIApplication(background-task API) without an explicitimport UIKit; CocoaPods builds compiled anyway via transitive module re-export, SwiftPM builds do not. Surfaced by the new end-to-end verification below; explicit imports added.
Changed #
- Release verification now includes a real SwiftPM app build. Both #49 and
#52 escaped because the plugin package was only ever built in isolation or
consumed via CocoaPods. The SPM check now builds a scratch Flutter app with
--enable-swift-package-managerdepending on the plugin, which exercises Flutter's generated manifest (hyphenated product reference, platform minimums, full plugin compile under SPM).
1.4.1 - 2026-07-16 #
Fixed #
-
iOS Swift Package Manager builds failed for pub.dev consumers — Issue #49.
Package.swiftdeclaredKMPWorkManageras a local.binaryTarget(path: "../Frameworks/KMPWorkManager.xcframework"), but that xcframework is stripped from the published package by.pubignoreand only re-created by the CocoaPodsprepare_commandat install time. SwiftPM has no equivalent install hook, so with Flutter's SwiftPM integration enabled the local binary target resolved to nothing andxcodebuildaborted with "local binary target 'KMPWorkManager' … does not contain a binary artifact" — and because Flutter routes a plugin through SwiftPM whenever aPackage.swiftexists (excluding it from CocoaPods), there was no fallback. Replaced the local target with a remote, checksummed.binaryTargetpointing at the same GitHub-release zip the podspec already downloads, so SwiftPM fetches the identical versioned artifact CocoaPods does. Verified end-to-end: SwiftPM downloads the asset, validates the checksum, and builds. Thanks @zaqwery for the precise root-cause report. -
CancellationExceptionswallowed by generic exception handling in 11 Android workers. A worker cancelled mid-run (user callscancel()/cancelAll(), or WorkManager stops the worker because constraints are no longer met) could have itsCancellationExceptioncaught by the worker's owncatch (e: Exception)and converted into a normalWorkerResult.Failure— inHttpDownloadWorker's case withshouldRetry: true, meaning a task the user explicitly cancelled could reschedule itself.ForegroundNativeWorkerwas the most exposed case: it bypassesBaseKmpWorker, so nothing else catches cancellation correctly for the FGS-bypass path. Fixed by addingcatch (e: CancellationException) { throw e }before the generic catch in every worker where the wrapped scope contains a real suspension point (network I/O awaited via child coroutines,delay(),setForeground()). Affected:DbCleanupWorker,FileCompressionWorker,FileDecompressionWorker,FileSystemWorker,ForegroundNativeWorker(two sites),HttpDownloadWorker,HttpRequestWorker,HttpSyncWorker,HttpUploadWorker,ImageProcessWorker,ParallelHttpDownloadWorker(two sites). Five workers audited and confirmed already safe without changes (CryptoWorker,MoveToSharedStorageWorker,ParallelHttpUploadWorker,PdfWorkerrely onBaseKmpWorker's outerCancellationExceptionhandling since they have no local catch around their dispatch;WebSocketWorkeralready usedtry/finallyinstead oftry/catcharound its cancellation-sensitive section). -
Intermittent "Failed host lookup" on Android 15/16. Bumped
androidx.work:work-runtime-ktx2.10.1 → 2.11.2, which fixes an upstream AndroidX WorkManager bug where a backgroundWorkRequestcould start running before the device's network/connectivity state was fully attached, causing spuriousSocketException: Failed host lookupfailures on HTTP calls made from background tasks. Found by auditingflutter_workmanager's issue tracker (still pinned to 2.10.2 at the time of writing, with an open unresolved report) — see issuetracker.google.com/issues/445324855.kmpworkmanagerpulls inwork-runtime-ktx2.9.1 transitively; the directapideclaration here wins Gradle's highest-version resolution (verified:./gradlew :native_workmanager:dependenciesresolves2.9.1 -> 2.11.2).
1.4.0 - 2026-07-16 #
Changed #
- Bumped
kmpworkmanagercore to 3.1.0 (was 3.0.1). 3.1.0 enforcesConstraints.maxRetriesinsideBaseKmpWorker: it reads themaxRetrieskey off the WorkRequest input data and capsFailure(shouldRetry=true)/RetryatN + 1total runs (WorkManager itself has no max-retry API — a rawResult.retry()reschedules forever). The bundled iOSKMPWorkManager.xcframeworkwas rebuilt from 3.1.0.
Fixed #
-
DartWorker
return falsenever retried — permanentResult.failure(). AndroidDartCallbackWorkerand iOS Dart callback paths mapped afalsecallback result toWorkerResult.Failure/.failurewithoutshouldRetry: true. BecauseFailure.shouldRetrydefaults tofalse, WorkManager receivedResult.failure()(reschedule = false) andConstraints.maxRetries/backoffDelayMswere ignored despite docs promising retry-on-false. Native engine/setup exceptions still useshouldRetry = falseso broken engine configuration does not loop forever. -
Android
Constraints.maxRetrieswas silently ignored. Even once a task asked to retry, WorkManager'sResult.retry()is unbounded, so a callback that kept returningfalselooped forever.maxRetriesis now forwarded from the Dart constraints map onto the KMPConstraints(soNativeTaskScheduler-scheduled triggers cap via core) and stamped onto the WorkRequest input data for every direct-enqueue path (one-time, chain, graph) soBaseKmpWorkercan enforce theN + 1ceiling. Periodic work is intentionally excluded — itsrunAttemptCountonly resets on success, so a per-run cap would permanently disable retries after the first cap hit.ForegroundNativeWorker(which maps results itself, bypassingBaseKmpWorker) enforces the same cap inline. iOSRetryConfignow readsmaxRetriesviaNSNumber(MethodChannel integers were silently dropped to0= no retry) and defaults to3to match the Dart contract.
1.3.3 - 2026-07-14 #
Fixed #
-
DartWorker progress events dropped (UI stuck at 0%) — Issue #38. Native emitted the progress map without a
timestamp, so the Dart session-filter (timestamp < _sessionStartTime, defaulting the missing value to0) silently discarded every progress event. AndroidProgressUpdate.toMap()/toJson()and iOSProgressReporter/emitProgressnow stamptimestamp; the Dart filter treats a missing/0timestamp as "current" for backward compatibility with older native builds. Covered byissue_38_*indevice_integration_test.dart.Fixing this on iOS surfaced two further iOS-only gaps (caught by the device test) that PR #40 alone did not close: (a)
__taskIdwas never injected into a foreground DartWorker's input, so the callback had no id to report progress with —executeDartWorkerViaMethodChannelnow merges it in, mirroring Android'sDartCallbackWorker; (b) thedev.brewkits/dart_worker_channelreportProgresshandler existed only on theFlutterEngineManagerbackground engine, so foreground callbacks threwMissingPluginException— the main engine now registers the same handler, routed throughProgressReporter. -
DartWorker TaskStore status stuck on
pendingafter success — Issue #39. Only theTaskEventBuspath persisted terminal status to SQLite, andDartCallbackWorkernever emits on that bus, so completed DartWorkers stayedpendingforever inallTasks(). The WorkInfo fallback inobserveWorkCompletionnow callstaskStore.updateStatus(...)for running/completed/failed/cancelled, plus asyncTaskStoreWithWorkManager()reconciliation on restart to repair rows left stale by process death. Covered byissue_39_*indevice_integration_test.dart.
Security #
- Constant-time HMAC signature comparison in RemoteTrigger. The remote-trigger
HMAC verification compared signatures with plain string equality (Android
String.equals, iOS==), which short-circuits on the first differing byte and can leak — via response timing — how many leading bytes matched (a signature verification timing side-channel). Both platforms now compare the raw HMAC bytes in constant time (AndroidMessageDigest.isEqual, iOS CryptoKitHMAC.isValidAuthenticationCode). Behavior-preserving: canonicalization and HMAC computation are unchanged, so valid signatures still verify and invalid ones are still rejected.
1.3.2 - 2026-07-07 #
Fixed #
- iOS: startup crash on Flutter 3.38+ (UIScene template) — Issue #36.
Apps created with the Flutter 3.38+ iOS template register plugins in
AppDelegate.didInitializeImplicitFlutterEngine, which runs afterapplication(_:didFinishLaunchingWithOptions:)returns. CallingBGTaskScheduler.registerat that point violates Apple's "all launch handlers must be registered before application finishes launching" rule and threwNSInternalInconsistencyExceptionat startup (reported on iPhone 15 / iOS 18.6.2; affects any device on the new template).- BGTask launch handlers are now registered in an ObjC
+loadhook (NWMBGTaskRegistrar) that runs at binary load time — always inside the launch window, on both the old and the new template. Plugin registration only attaches the Swift handlers afterwards. - All
BGTaskScheduler.registercalls now go through ObjC@try/@catch(Swift cannot catchNSException): late or duplicate registration degrades to aBGTASK_REGISTRATION_FAILEDsystem error instead of a crash. - Fixed a latent duplicate-registration crash:
registerHandlers()had no idempotency guard, soGeneratedPluginRegistrantre-running on the headless background engine (FlutterEngineManager) re-registered the identifiers and threw the sameNSInternalInconsistencyException. - BGTasks that fire before the Swift side attaches (cold-start background launch) are buffered and delivered once handlers attach.
- BGTask launch handlers are now registered in an ObjC
Changed #
- kmpworkmanager core upgraded 2.5.1 → 3.0.1 (Android Maven dependency +
bundled iOS XCFramework rebuilt from source).
- v3.0.1 fixes a critical crash on Android 8–11 (API 26–30): expedited tasks
failed with
IllegalStateException: Not implementeddue to a missinggetForegroundInfo()override (regressed in core v2.3.8). - v3.0.0 extracted Ktor HTTP workers into the optional
kmpworkmanager-httpartifact — not needed by this plugin (it ships its own native workers); no API changes affect the plugin bridge.
- v3.0.1 fixes a critical crash on Android 8–11 (API 26–30): expedited tasks
failed with
Added #
- iOS:
NativeWorkmanagerPlugin.registerBGTaskHandlers()— optional explicit registration fromdidFinishLaunchingWithOptions(idempotent, exception-safe). Only needed if a build setup strips ObjC+loadsections. - Example app migrated to the Flutter 3.38+ UIScene template
(
FlutterImplicitEngineDelegate+SceneDelegate) so the device test suite runs on the lifecycle that triggered the crash; newissue_36device regression test asserts handlers are registered in+load, exactly once.
1.3.1 - 2026-06-07 #
Fixed #
- Android (critical regression, since v1.2.4): All file-based native workers
(
HttpDownload,HttpUpload,ParallelHttpDownload/Upload,FileCompression,FileDecompression,ImageProcess,Cryptohash/encrypt/decrypt,Pdf,WebSocket,FileSystem,MoveToSharedStorage) failed on real devices with "Invalid or unsafe file path". v1.2.4 added a blanket"/data"entry toSecurityValidator's blocked-prefix list, which rejected the app's own private sandbox (/data/data/<pkg>,/data/user/<n>/<pkg>— exactly whatpath_providerreturns). The validator now blocks only the genuinely OS-owned sub-directories of/data(/data/local,/data/system,/data/misc,/data/app, …) while allowing the app sandbox. Path-traversal protection (canonical-path resolution) and blocking of/proc,/sys,/etc,/system,/vendor,/dev,/rootare unchanged. AddedSecurityValidatorFilePathTest(Kotlin) plus device coverage in the "All Workers" integration group. - iOS: Fixed an issue where the
KMPWorkManager.xcframeworkwas extracted into a double-nested path (Frameworks/Frameworks/KMPWorkManager.xcframework) duringpod install, causing iOS builds to fail with "Unable to find module dependency: 'KMPWorkManager'". Theprepare_commandinnative_workmanager.podspecis now layout-agnostic (Resolves #33).
1.3.0 - 2026-06-04 #
Added #
-
Android Auto-Init (
NativeWorkManagerInitializer): Plugin now ships anandroidx.startupInitializerdeclared in its ownAndroidManifest.xml. It runs automatically beforeApplication.onCreate(), restoring thecallbackHandlefrom SharedPreferences and initializingKmpWorkManagerwithSimpleAndroidWorkerFactory.- Breaking zero-config change:
DartWorkerkilled-app support now requires no customApplicationclass and no manualAndroidManifest.xmledits for the common case. - Opt-out for apps with custom WorkManager configuration: add
<meta-data android:name="native_workmanager.auto_init" android:value="false" />to<application>in yourAndroidManifest.xml, then followdoc/ANDROID_SETUP.md. isSchedulerInitializedflag prevents double-initialization whenonAttachedToEngineruns after the Initializer.
- Breaking zero-config change:
-
Unified setup CLI (
dart run native_workmanager:setup): Evolvessetup_iosinto a universal command covering both platforms.--android: validates the app manifest has no conflicts with auto-init.--ios: patchesInfo.plistwithUIBackgroundModesandBGTaskSchedulerPermittedIdentifiers(same as the legacysetup_ioscommand).--check: read-only validation mode — no files are written.--help: full usage reference.setup_iosexecutable retained for backward compatibility.
-
iOS
WorkerResult.retry(): Addedretry(reason:delayMs:attemptCap:)factory on the SwiftWorkerResultstruct, providing parity withWorkerResult.Retryintroduced in kmpworkmanager v2.5.0.
Changed #
-
Core: Upgraded KMP WorkManager core dependency from v2.4.3 to v2.5.1.
- Android: added
WorkerResult.Retrybranch inForegroundNativeWorkerto satisfy sealed-class exhaustiveness (maps toResult.retry()). - iOS
KMPWorkManager.xcframeworkrebuilt from v2.5.1 source.
- Android: added
-
iOS retry semantics (
executeWorkerSync): the retry loop now respectsWorkerResult.shouldRetry. A worker returningfailure(shouldRetry: false)stops retrying immediately instead of exhausting allmaxRetriesattempts. -
iOS
maxRetrieshonored on the direct-task execution path:RetryConfig.from(constraintsMap:)is now called and passed toexecuteWorkerSync. PreviouslyConstraints.maxRetrieswas silently ignored on iOS (dead code). -
iOS direct-task
qosnow read fromconstraintsMap["qos"]instead of being hardcoded to"background".
Fixed #
-
Android
DartCallbackWorker:CancellationExceptionis now rethrown before the outercatch (Exception)block.executeDartCallbackis a suspending function; without this fix, WorkManager task cancellation was silently converted to aFailureresult. -
iOS WebSocket:
NativeWorker.webSocket()now throwsUnsupportedErrorat call-site when run on iOS. Previously the task was enqueued and silently failed with "Unknown worker class" becauseIosWorkerFactoryhas noWebSocketWorkercase. -
Android
handleResume: constraint JSON parse failure now logs aNativeLogger.wwarning instead of silently falling back to empty constraints (which could cause resumed downloads to ignorerequiresNetwork/requiresCharging). -
Dart
resolveDispatcherTimeout: values ≤ 0 (zero, negative, NaN, ±Infinity) now fall back to the 25 s default. ADuration(milliseconds: -n).timeout()fires immediately, which would kill every DartWorker. Added four regression tests. -
Android
HttpDownloadWorker— data corruption (directory mode): concurrent downloads to the same directory now each use their own temp file (__pending_<taskId>__.tmp) instead of sharing the hardcoded__pending__.tmp. Two workers writing to the same temp path produced a mixed-byte file; the first to finish would rename corrupted data. -
Android
HttpDownloadWorker— TOCTOU rename (onDuplicate: "rename"): replacedfindNextAvailableFile() + Files.move(REPLACE_EXISTING)with an atomic probe loop usingATOMIC_MOVEonly (noREPLACE_EXISTING). AFileAlreadyExistsExceptionnow signals the next candidate rather than silently overwriting a file from a concurrent download. -
Android constraint conflict warning: enqueueing with
allowWhileIdle: trueandisHeavyTask: truesimultaneously now logs aNativeLogger.wat enqueue time. The long-running worker already bypasses Doze mode, makingallowWhileIdleredundant and potentially causing WorkManager rejection on some Android versions.
1.2.8 - 2026-06-04 #
Changed #
- Core: Upgraded KMP WorkManager core dependency from v2.4.3 to v2.5.1.
- Android: added
WorkerResult.Retrybranch inForegroundNativeWorkerto satisfy sealed-class exhaustiveness (maps toResult.retry()). - iOS: added
WorkerResult.retry(reason:delayMs:attemptCap:)factory method for parity with the new KMP sealed variant; existingfailure(shouldRetry: true)callers unchanged. - iOS
KMPWorkManager.xcframeworkrebuilt from v2.5.1 source.
- Android: added
1.2.7 - 2026-05-11 #
Fixed #
- Core: Enforced
DartWorker.timeoutMsend-to-end (Issue #30).- Android and iOS bridges now correctly forward
timeoutMsto the Dart callback dispatcher. - Added
resolveDispatcherTimeouthelper in Dart to securely parse the timeout, protecting againstNaN,Infinity, and invalid types. - Enforced
timeoutMsin both the background dispatcher and the foregroundMethodChannel(_executeDartCallback). - Added comprehensive unit, integration, performance, and security test coverage.
- Android and iOS bridges now correctly forward
1.2.6 - 2026-05-08 #
Added #
- Android: Industrial-grade Foreground Service (FGS) Support. Added
ForegroundNotificationConfigtoConstraints, allowing tasks to run as prioritized Foreground Services to bypass Android 12+ background restrictions. - Android: Full compliance with Android 14 (API 34) Foreground Service Types. Automatically maps task types (dataSync, location, media, etc.) to system-level flags.
- Android: Proactive task promotion using
setForeground()to ensure immediate execution even when the app is in the background. - Android: FGS state persistence: configuration is automatically restored after device reboots or task resumes.
- Core: Added comprehensive unit tests and a new Demo page in the example app for FGS bypass.
Fixed #
- Android: Fixed regression where background tasks would not fire when the device screen was locked (Doze mode) even after the app was killed. Resolved by correctly mapping
allowWhileIdleto WorkManager's expedited mode (#28). - iOS: Fixed Swift Concurrency deadlocks by migrating SQLite queues (DispatchQueue) from concurrent to serial.
- iOS: Improved scheduling reliability by adjusting internal
TaskTriggerexecution delays on iOS to ensureBGTaskSchedulercorrectly enqueues tasks. - Test: Added platform-aware timeouts for iOS integration tests and automatically excluded timeout-prone integration tests (
TaskGraphandOfflineQueue) when running on the iOS Simulator.
1.2.5 - 2026-05-06 #
1.2.4 - 2026-04-29 #
Fixed #
- Android: Added automatic ProGuard rules to prevent task classes from being stripped in Release builds (#24).
- Android: Clarified that
Applicationclass setup is required for all tasks to survive app kill. - iOS: Synchronized background task identifiers between
setup_ios.dartand Swift code. - iOS:
getTaskStatus()now correctly returnsTaskStatus.completedfor finished tasks. Previously, the iOS plugin wrote"success"to SQLite but Dart'sTaskStatusenum has nosuccesscase, so every call returnednull. - Android: Removed duplicate
taskStore.updateStatus()call on task completion. The redundant second write usedJSONObject(map).toString()which could corrupt nested result maps, overwriting the correctly-encoded first write. - iOS:
FlutterEngineManagernow disposes the engine after a Dart callback timeout. Previously the engine remainedisInitialized = truewith a hungMethodChannel, causing all subsequentDartCallbackWorkertasks to silently fail (timeout again).
Changed #
- Engine: Upgraded core
kmpworkmanagerto v2.4.3 (re-publish of v2.4.2 to fix Maven Central artifact issue; no code changes).
1.2.3 - 2026-04-24 #
Added #
- Feature: Support
initialDelayandrunImmediatelyfor periodic tasks (#21)- Allows delaying the first execution of a periodic task.
- Added
runImmediatelyflag to skip the first execution. - On Android, uses native
PeriodicWorkRequest.setInitialDelay(). - On iOS, maps
initialDelaytoearliestBeginDatefor optimized scheduling. - Added parameters to
TaskTrigger.periodic().
- Security: Advanced Input Validation
- All native workers now perform strict validation to block Null Byte Injection, Path Traversal (
..,%2e%2e), and Shell Injection characters in URLs and file paths.
- All native workers now perform strict validation to block Null Byte Injection, Path Traversal (
- Enterprise-Grade Testing:
- Implemented comprehensive
scripts/run_all_tests.shcovering Unit, Integration, Security, Performance, and Stress tests. - Added specific performance benchmarks for task scheduling overhead.
- Added malicious payload protection tests.
- Implemented comprehensive
- Improved CI/CD: Integrated automated Security, Performance, and Stress testing into the GitHub Actions pipeline.
Fixed #
- Android: Upgraded to
kmpworkmanager 2.4.1- Switched to native
setInitialDelayinstead of manual bypass logic. - Fixed edge-case crashes on Android 15.
- Switched to native
- iOS: Improved Periodic Task Lifecycle
- Fixed regression where periodic tasks were not tracked in
activeTasks, preventing cancellation.
- Fixed regression where periodic tasks were not tracked in
- Android: Fixed broken
expeditedflag logic in direct enqueue path.
1.2.2 - 2026-04-22 #
Added #
registerPluginsparameter inNativeWorkManager.initialize(): opt-in flag to register all Flutter plugins in the background engine, required when using plugins likeflutter_local_notificationsinsideDartWorkercallbacks. Defaults tofalseto preserve the Zero-Engine I/O principle and avoid side-effects (e.g. Bluetooth disconnects). Also addedNativeWorkmanagerPlugin.setPluginRegistrantCallbackon Android and iOS to allow selective plugin registration whenregisterPluginsis false. (#18)
Fixed #
- iOS:
openFilealways fails on Flutter 3.38+ / scene-based apps —UIApplication.shared.keyWindowreturnsnilinUIWindowScenelifecycle. Replaced with a newactiveRootViewControllerextension that traversesconnectedScenesto find the active key window. (#16) - Android:
StackOverflowErrorwhen middleware is registered — Kotlin companion extensionapplyMiddlewarewas shadowing the internal package-level function of the same name, causing infinite recursion. Renamed the internal function toapplyMiddlewareInternalto eliminate the ambiguity. (#17) native_workmanager_genincompatible with Flutter 3.41.x —analyzer >=11.0.0requiresmeta ^1.18.0which conflicts with the Flutter SDK'smeta 1.17.0pin. Widened constraint to>=10.0.0 <13.0.0;analyzer 10.xsupports all APIs used by the generator and requires onlymeta ^1.15.0. (#15)
1.2.1 - 2026-04-19 #
Added #
- Security Hardening: All HTTP workers now support HTTPS Enforcement and Private IP Blocking (SSRF Protection) via
NativeWorkManager.initialize(enforceHttps: true, blockPrivateIPs: true). - Path Traversal Protection: Enhanced file path validation to block null-byte injection and encoded dot-segments (
%2e%2e) across all native workers. WorkManagerLoggerinterface: A type-safe delegate for forwarding background task events to third-party SDKs like Firebase or Sentry without dynamic reflection.- New Test Suite: Added 100+ new test cases covering input sanitization, security policy enforcement, performance benchmarks for large directory operations, and multi-stage task chains.
Fixed #
- Android: Dart Isolate Timeouts: Implemented hard timeout handling for background Dart execution. If an isolate hangs, the engine is now force-disposed to prevent 50MB+ RAM leaks.
- Android: Task Store Performance: Added batch deletion for task history cleanup to prevent long SQLite write-locks on high-traffic apps.
- Migration Tool: Moved the
migrate.dartscript to thebin/directory and added it to theexecutablessection inpubspec.yamlto resolve theCould not find bin/migrate.darterror when runningdart run native_workmanager:migrate(#14). Also changeddeveloper.logtoprintso the CLI output displays correctly. - Test Infrastructure: Fixed a bug in
TaskEventTrackerwhere it incorrectly resolved on "task started" events instead of terminal completion events, leading to flakey stress tests.
1.2.0 - 2026-04-17 #
Added #
- Android cold-start
DartWorkerpersistence:DartWorkertasks now execute reliably after app kill. ThecallbackHandleis persisted toSharedPreferences(Android) andUserDefaults(iOS) duringinitialize()and automatically restored when WorkManager restarts the process. Requires host app to implementConfiguration.Provider— seedoc/ANDROID_SETUP.md. - Advanced Remote Trigger: Support for direct commands in push payloads (
native_wmkey). Execute tasks, chains (enqueue_chain), graphs, and offline queues without waking Flutter. Both Android and iOS support executing task chains completely in the background. - HMAC Security: Robust HMAC SHA-256 signature verification for remote triggers (supporting nested objects) to prevent unauthorized task execution.
- Real-time Observability: DevTools extension now supports real-time event streaming via
developer.postEvent. - Global Middleware API: Global interceptors for task configuration (Headers, RemoteConfig, Logging).
- Code Generation Enhancements:
native_workmanager_gennow generates type-safe enqueue wrappers and automatic worker registries from@WorkerCallbackannotations. - Task Graphs (DAG): Support for complex non-linear task dependencies on Android.