native_workmanager 1.8.0 copy "native_workmanager: ^1.8.0" to clipboard
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 new certificatePinning parameter and the CertificatePin/CertificatePinning classes:

    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: a URLSessionDelegate that checks the pin and defers to performDefaultHandling).

    This closes a real gap, not a green-field feature: both platform bridges already had a certificatePinning config 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. SecKeyCopyExternalRepresentation returns a certificate's raw public key, but a sha256/… pin — the form every pin-generating tool (OkHttp, openssl, TrustKit) emits, and the same form Android's HttpSecurityHelper already 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 verified TlsPinning.ios.kt rather 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.shared is process-wide and independent of which URLSession serves 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 performDefaultHandling rather than manually calling SecTrustEvaluateWithError and 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 (SecTrustEvaluateWithError returned ok=true cleanly 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 certificatePinning configured is byte-for-byte unaffected. See TLS Certificate Pinning in device_integration_test.dart.

Changed #

  • kmpworkmanager engine 3.4.13.5.0 ("Hardening" — 20 bug fixes, 0 public API changes; kmpworker.api, the JVM ABI, is byte-identical between the two tags, so KMPSchedulerBridge.swift, the FROZEN bridge file, needed no changes this bump). Fixes that reach this plugin's actual behavior without any Dart-side change:

    • ExistingPolicy.KEEP no longer deletes a pending task's spilled input file before the enqueue decision is made — a repeat enqueue(id, policy: ExistingPolicy.keep) call could previously run with input = 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: AlarmManager refusing an exact alarm no longer leaves AlarmStore claiming 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 s BGAppRefreshTaskRequest budget instead of BGProcessingTaskRequest, with the caller's maxRetries replaced 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 to queryTasks/computeIosTaskState/cancelByTag while 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 (40968192) only affects callers who hardcoded the old wrong value, and this plugin already reads the platform constant; this plugin does not use FakeBackgroundTaskScheduler in its own tests.

    The bundled KMPWorkManager.xcframework was rebuilt from the kmpworkmanager v3.5.0 git tag (not HEAD).

Fixed #

  • Android & iOS: SecurityValidator.sanitizedURL() leaked HTTP Basic credentials in a URL's authority (https://user:pass@host/...) into logs and persisted WorkerResult failure 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.sanitizedURL had just fixed — found by comparison while reviewing that fix, not by kmpworkmanager itself (this plugin's SecurityValidator shares no code with theirs).

Added #

  • NativeWorkManager.isTaskCancelled(taskId) — answers #66: cancelling a task (via cancel/cancelAll, or the OS reclaiming background time) does not interrupt a running DartWorker callback, because Dart has no API to preemptively abort a Future that is already executing. A callback doing long-running work can now poll this cooperatively between chunks of work and return early once it turns true. Wired on both platforms: Android (CoroutineWorker cancellation), iOS foreground/simulator (main-isolate activeTasks cancel), and iOS true-background BGTask expiration. See DartTaskCancellationRegistry (Kotlin and Swift) and the issue_66_* entries in device_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 DartWorker task while its callback was running could leak the headless Flutter engine (~50 MB) or dispose it while an orphaned callback was still executing. FlutterEngineManager .executeDartCallback rethrew external CancellationException before 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 a finally tied 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 the issue_66 device test on.
  • iOS: BGTaskSchedulerManager never actually cancelled the running Task on BGTask expiration — only activeWorker.stop() was called (a no-op for DartCallbackWorker), 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: true HttpDownloadWorker or HttpUploadWorker never actually stopped the transfer (#69). Both registered their background URLSessionTask with BackgroundSessionManager under a throwaway random id instead of the real task id, so cancel()/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-version pointed at a plain-text file — subosito/flutter-action's flutter-version-file only parses pubspec.yaml, .fvmrc, or .fvm/fvm_config.json, so it silently failed to parse and fell back to the channel input's default of stable (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 via fvm) and explicitly empty channel as defense in depth.
  • native_workmanager_gen had no analysis_options.yaml of its own, so dart analyze walked up to the root plugin's — which includes package:flutter_lints/flutter.yaml, unresolvable against a pure-Dart package that only depends on lints. 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's issue_30 stress case 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. A DartWorker whose timeoutMs fires returns a retryable failure by design (matching #46/#47's "return false retries" behavior), and the test never set maxRetries: 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 terminal WorkInfo state until all retries are exhausted — which routinely exceeds the test's own wait budget. That is not a dropped event: isolating a single DartWorker(timeoutMs: 1000, delayMs: 2000) with maxRetries: 0 delivers its terminal event at ~1020 ms, exactly at the timeout mark, confirmed on both platforms. Fixed by adding maxRetries: 0 to the enqueue calls (matching what the test actually intends to measure) and replacing the silent catch (_) { actuals.add(0) } — which made "no event ever arrived" and "correctly failed" read as the same outcome — with an explicit expect(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: in device_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 to NativeWorkManager.events directly, with no getTaskRecord fallback 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 persisted status=completed, but nothing arrived on NativeWorkManager.events, so an app awaiting the event waited forever.

    Root cause was in v1.6.0's own resultData fix: unwrapStepOutput decoded the kmp_step_output envelope with JSONObject.get(), which returns org.json types for nested values. Flutter's StandardMessageCodec cannot encode those, so eventSink.success() threw

    IllegalArgumentException: 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 TaskEventBus carries outputData as a JSON string, and Dart reads map['resultData'] is Map ? … : null, so a string was silently discarded and events delivered that way arrived with resultData == null. Both paths now hand Dart the same decoded shape.

    Not caught earlier because native_workers_test.dart's _waitEvent falls back to getTaskRecord and 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() reports isExempt (PowerManager.isIgnoringBatteryOptimizations), manufacturer (Build.MANUFACTURER, lowercased) and canOpenSettings — the last resolved with resolveActivity on the actual device rather than assumed. It is a pure diagnostic: it schedules nothing and does not require initialize(), so it is safe to call during startup.
    • NativeWorkManager.openBatteryOptimizationSettings() opens the system list. Needs no permission.
    • NativeWorkManager.requestDisableBatteryOptimization() shows the direct "allow" dialog.

    isExempt is 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 report true and 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. manufacturer is passed up raw so an app can word its own guidance.

    requestDisableBatteryOptimization() requires the host app to declare REQUEST_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 returns missingPermission rather than throwing. ManifestGuardTest now guards both permissions.

    On iOS all three report "not applicable" — isExempt is null so BatteryRestrictionReport.isSupported distinguishes "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.exact actually runs its worker now. The default AlarmReceiver registered by KmpWorkManager.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 AlarmStore before 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 are NORMAL and are no longer blanket-expedited — expect slightly later scheduling for them under WorkManager quota pressure. Chain steps marked CRITICAL/HIGH are unaffected.
    • Android: KmpHeavyWorker retries instead of discarding on a transient foreground-service denial. A SecurityException/IllegalStateException from 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.replace is 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.errorMessage was always persisted as null, so persisted history lost every diagnostic detail (live completion events were unaffected).
    • iOS: ExistingPolicy.keep no longer behaves like replace. For a task id not declared in Info.plist — the normal case, since ids are usually per-instance — the KEEP check queried BGTaskScheduler for an identifier that is never submitted under its own name, so it always missed. A repeat enqueue(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 WorkerFactory throws something other than IllegalArgumentException — the case that permanently stopped a periodic task's recurring schedule.
    • iOS: standalone tasks now honour requiresUnmeteredNetwork, requiresCharging and the battery-not-low constraint, and Constraints.backoffPolicy/backoffDelayMs affect 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 via NSString.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 removes AlarmStore metadata before dispatch — that affects only apps subclassing AlarmReceiver directly. This plugin subclasses neither it nor BaseAlarmReceiver, so no host-app migration is required.

  • Removed every unmeasured performance claim from the documentation. The docs advertised a ~2 MB RAM footprint, < 50 ms task startup, and 100% Guaranteed survival of process death — for this package and, in the comparison table, for four competitors. None came from a run anyone could reproduce, and benchmark/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 ms is 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 runs flutter build ios, and CI's Flutter has SwiftPM on by default, so it resolved Package.swift's remote binaryTarget — 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.sh checks everything about the SwiftPM path that does not need a live asset — manifest parses, product is hyphenated (issue #52), binaryTarget is remote with a checksum (issue #49), no testTarget is 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 on main and 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.md no 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 to TaskTrigger.Windowed; a Kotlin IllegalArgumentException thrown from a constructor exported to Swift cannot be caught and terminates the process. KMPSchedulerBridge now 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 surfaces ENQUEUE_ERROR. No Dart-side assert was added — the Dart API still accepts the combination and lets the platform answer. Covered by kmp_341: inverted windowed trigger errors instead of crashing in device_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 into ENQUEUE_ERROR.

  • CI never honoured .flutter-version at all. Every subosito/flutter-action step passed both flutter-version-file: .flutter-version and channel: 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). The channel: 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 & Format job, which the Flutter bump below did not fix: newer stable Flutter appends build/**, android/** and ios/** to the analyzer.exclude list in analysis_options.yaml and example/analysis_options.yaml when it runs. That left the working tree dirty mid-job, and dart pub publish --dry-run exits 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-version pinned 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 & Format had been red on main since before v1.5.0. dart pub publish --dry-run reported analysis_options.yaml and example/analysis_options.yaml as "modified in git" during the run — an old-toolchain artefact that does not reproduce on 3.41.9 (0 warnings).
    • native_workmanager_gen declares sdk: '>=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 the Failed 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.yaml is now gitignored. CI writes native_workmanager_gen/pubspec_overrides.yaml to point the generator at the local plugin. Committing it — or a pubspec.lock resolved with it in place — drags the Flutter SDK's pinned meta into the generator and forces analyzer below the version it targets, which is exactly what that package's pubspec comment warns against.

  • TaskEvent.resultData now actually carries a worker's result on Android — the worker_results.dart helpers have never returned data there before. Measured on a Pixel 6 Pro with a SHA-256 hash task:

    resultData
    kmpworkmanager 3.3.1 (v1.5.0) null
    3.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.data into WorkManager's output Data under a single kmp_step_output key as a JSON string, so the next chain step can merge it. But WorkInfo.outputData is also what this plugin forwards to Dart, so CryptoResult.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.dart parser was reading at least one key the native workers never send. With resultData permanently 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
    DecompressionResult outputPath, extractedCount, totalSize none of them — Android targetDir/extractedFiles/totalBytes, iOS filesExtracted
    CompressionResult fileCount, totalSize Android filesCompressed, originalSize
    ImageProcessResult width, height, fileSize Android processedWidth, processedHeight, processedSize
    FileSystemResult entries, count files, fileCount (iOS also sends entries)
    CryptoResult operation iOS only — Android omits it
    ParallelUploadResult fileResults iOS only — Android sends counters alone

    DecompressionResult.from was the worst: not one of its three keys exists on either platform, so it returned null for 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.

  • ParallelDownloadResult documents a shape no worker produces. ParallelHttpDownloadWorker downloads a single file over parallel range requests and reports the single-file shape, so DownloadResult is 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.entries was always null on Android, and .count always null on both platforms. The parser read entries and count; the workers emit files (objects carrying path) and fileCount, and only iOS also sends an explicit entries array. count was emitted by neither. Both fields are now derived from the shared files/fileCount payload, with the platform-specific keys preferred when present — rather than widening the native payload, since files already carries the paths and duplicating them costs room against WorkManager's Data budget.

  • iOS offline-queue enqueue had never worked. Dart invokes the channel method offlineQueueEnqueue and Android registers that name, but iOS registered enqueueOfflineQueue — the same two words the other way round — so every call fell through to FlutterMethodNotImplemented and threw MissingPluginException. Found by auditing every Dart call site against both native dispatch tables after the getTasksByStatus bug, 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 in NativeWorkmanagerPlugin.kt and NativeWorkmanagerPlugin.swift and asserts every method Dart invokes is registered on both, the way ManifestGuardTest reads 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 TypeError instead 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 the getTaskRecord fallback — used whenever the completion event is missed — hands them over as the JSON text they were persisted as. data['files'] as List? therefore threw type '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 in worker_results.dart now accepts either form and returns null rather than throwing when it is neither.

  • The ImageProcessWorker device tests failed on every Android run against a corrupt fixture. native_workers_test.dart's _minimalPng described 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's BitmapFactory validates 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 trigger device test failed on every iOS run. ExactTrigger has 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 called enqueue unconditionally and asserted a contract the library deliberately does not have. It now asserts throwsUnsupportedError on iOS and keeps the accepted/rejected assertion on Android.

  • doc/ANDROID_SETUP.md told readers to hand-roll a MethodChannel calling ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS with 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 defaulted tags and deadlineMs parameters upstream. Kotlin defaults keep Android source-compatible, but they are not exported to Swift, so the ObjC selector changed and KMPSchedulerBridge.swift now 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 chain InputMerger (mergeOutputFromPreviousStep) and ExistingPolicy.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 — a taskId-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.progress already exposes. It does not call ActivityKit, and it does not wrap the KMP IosLiveActivityBridge in the bundled KMPWorkManager.xcframework. Starting, updating and ending the Activity<Attributes> remains your app's job — the ActivityAttributes type lives in your target, not in this plugin. On non-iOS platforms onProgress returns an already-closed stream; use NativeWorkManager.progress for 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 on IosLiveActivityBridge.

  • Public GraphExecution constructor. GraphExecution(graphId, result) is now public API; GraphExecution.internal(...) is deprecated and forwards to it. This is what lets FakeWorkManager build a graph handle without tripping the analyzer (see Fixed below).

  • CLI SwiftUI @main detection. dart run native_workmanager:setup (and native_workmanager:setup_ios) now inspect ios/Runner for a SwiftUI @main App and report whether @UIApplicationDelegateAdaptor(AppDelegate.self) is wired — without it the AppDelegate lifecycle never runs, so BGTask launch handlers registered in +load never attach.

Fixed #

  • Pub.dev static analysis back to 160/160. FakeWorkManager called GraphExecution.internal, a @visibleForTesting member, from lib/ — an invalid_use_of_visible_for_testing_member warning that cost analysis points. The constructor is public now and the annotation is gone.
  • Analyzer guardrail: invalid_use_of_visible_for_testing_member: error added to analysis_options.yaml so 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 @main check now runs even when ios/Runner/Info.plist is missing or malformed — a non-standard plist layout is exactly what a SwiftUI-lifecycle project is likely to have.
  • OfflineQueue could 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 threw RangeError (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 by test/unit/offline_queue_cancel_race_test.dart, which reproduces both failures.
  • Flutter engine could leak on Android after a channel error (pre-existing). FlutterEngineManager.executeDartCallback incremented activeTaskCount before the try whose finally decremented it, so anything thrown in between — channel.invokeMethod hitting a detached engine, for instance — leaked the counter permanently. activeTaskCount.get() <= 0 then 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). invokeCallback suspended on a bare withCheckedThrowingContinuation with 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 logged SWIFT TASK CONTINUATION MISUSE: continuation was leaked and the child task stayed suspended holding the channel. It now runs under withTaskCancellationHandler with a single-resume guard, so cancellation settles it.
  • DartWorker cancellation was swallowed on Android (pre-existing, not a 1.5.0 regression). FlutterEngineManager.executeDartCallback wrapped withTimeout { resultDeferred.await() } in a generic catch (e: Exception). CancellationException is-a Exception, so cancelling a DartWorker — or cancelling its parent Job — was reported as an ordinary false result instead of propagating, breaking structured concurrency. It is now rethrown ahead of the generic catch. The timeout path is unchanged: TimeoutCancellationException is caught at the withTimeout call site and converted to timedOut, so it never reaches the new guard.
  • OfflineQueue class doc contradicted the implementation (pre-existing). The class-level docs said enqueue throws a StateError when the queue is full; it has always dropped the entry silently and returned normally (as enqueue's own doc correctly stated). A caller following the class doc would have written a try/catch (StateError) that never fires. The class doc now matches the behaviour and points at pendingCount.
  • Swift snippet in IosLiveActivityBridge docs did not compile. It showed IosLiveActivityBridge.shared, but Kotlin/Native exposes the singleton through the Companion object — the generated header declares only a companion class property on the bridge. The example now uses IosLiveActivityBridge.companion.shared.

Changed #

  • The iOS graph-node delay is no longer inline in DAG logic. TaskGraph._scheduleNode hard-coded a 1-second TaskTrigger delay 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 _iosNodeSubmissionStagger constant behind _nodeTrigger(), documented as a platform quirk rather than domain logic. Downstream scheduling also marks its fire-and-forget call explicitly with unawaited().

  • The cancellation-rethrow invariant guard is now checked per function, not per file. test/unit/cancellation_rethrow_invariant_test.dart used to regex the whole worker source for a single catch (e: CancellationException) { throw e }. HttpUploadWorker.kt has two suspend functions, and the one rethrow in doWork() made the file pass while handleRawBodyUpload() had no guard at all — the test built to catch this bug class could not see it. It now parses each suspend fun body 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 compileSdk raised 35 → 36, and consuming apps now need compileSdk 36 or higher. This is forced by the kmpworkmanager bump, not a choice: 3.3.0 dropped koin-android and began declaring androidx.core directly, which resolves androidx.core:core-ktx to 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:testDebugUnitTest exits 0 and no core-ktx:1.17.0 appears 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 default compileSdk are unaffected; apps pinned to 35 must raise it.

  • extension/devtools version 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 called KmpWorkManager.initialize() directly and never referenced Koin — but if your app was relying on koin-core arriving 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 / ExecutionHistoryStore were lazy bindings nothing ever resolved, so getExecutionHistory() returned an empty list on iOS), and shutdown() left stale global registrations behind so a shutdown()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; iOS SingleTaskExecutor used a wall-clock diff for ExecutionRecord.durationMs, which an NTP sync mid-task could corrupt, now TimeSource.Monotonic; and the KSP processor now fails the build on two @Worker classes 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's Codable conformance had no Int32/Int64/Float/UInt64 cases, so any worker result containing one of those types (e.g. ImageProcessWorker's originalSize/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 WorkRequest upfront 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 by WorkInfo completion, with output captured via a new ChainResultCapturingWorker decorator and resumed idempotently via enqueueUniqueWork(..., 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.
  • Android: Foreground-service permissions no longer bundled unconditionally. android/src/main/AndroidManifest.xml used to declare FOREGROUND_SERVICE / FOREGROUND_SERVICE_DATA_SYNC and a hardcoded SystemForegroundService type override, merging them into every consumer app's APK regardless of whether it used isHeavyTask/ForegroundNativeWorker — Google Play flags apps carrying foreground-service permissions they never exercise. These permissions are now consumer-app opt-in; see doc/ANDROID_SETUP.md's "Android 14+ Foreground Services" section if you use isHeavyTask: true. Enforced going forward by a new ManifestGuardTest.

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: FileCompressionWorker on iOS now produces a real PKZIP archive via platform.zlib (previously an uncompressed-copy stub gated behind allowIosUncompressedFallback), and a new IosLiveActivityBridge API for relaying worker progress to Live Activities/Dynamic Island (not yet wired into this plugin's public Dart API). Bundled KMPWorkManager.xcframework rebuilt 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 root extension/devtools/build/ and removed extension/devtools/build/ from .pubignore so compiled DevTools extension assets are included in pub.dev releases.

1.4.3 - 2026-07-17 #

Fixed #

  • iOS SwiftPM: manifest rejected by stricter SwiftPM toolchains. Package.swift declared a test target with path: "../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 invokes swift 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 build of 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 FlutterGeneratedPluginSwiftPackage references every plugin by the hyphenated library product name (plugin.name.replaceAll('_', '-') in flutter_tools — SwiftPM uses the product name as CFBundleIdentifier when linking dynamically, and bundle identifiers cannot contain underscores). Package.swift exported the product as native_workmanager, so SPM-enabled apps failed dependency resolution with "product 'native-workmanager' … not found in package 'native_workmanager'". The library product is now native-workmanager; the package and target names keep their underscores. Thanks @zaqwery for the precise diagnosis — again.

  • iOS: two workers did not compile under SwiftPM. CryptoWorker and FileSystemWorker use UIApplication (background-task API) without an explicit import 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-manager depending 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.swift declared KMPWorkManager as a local .binaryTarget (path: "../Frameworks/KMPWorkManager.xcframework"), but that xcframework is stripped from the published package by .pubignore and only re-created by the CocoaPods prepare_command at install time. SwiftPM has no equivalent install hook, so with Flutter's SwiftPM integration enabled the local binary target resolved to nothing and xcodebuild aborted with "local binary target 'KMPWorkManager' … does not contain a binary artifact" — and because Flutter routes a plugin through SwiftPM whenever a Package.swift exists (excluding it from CocoaPods), there was no fallback. Replaced the local target with a remote, checksummed .binaryTarget pointing 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.

  • CancellationException swallowed by generic exception handling in 11 Android workers. A worker cancelled mid-run (user calls cancel()/cancelAll(), or WorkManager stops the worker because constraints are no longer met) could have its CancellationException caught by the worker's own catch (e: Exception) and converted into a normal WorkerResult.Failure — in HttpDownloadWorker's case with shouldRetry: true, meaning a task the user explicitly cancelled could reschedule itself. ForegroundNativeWorker was the most exposed case: it bypasses BaseKmpWorker, so nothing else catches cancellation correctly for the FGS-bypass path. Fixed by adding catch (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, PdfWorker rely on BaseKmpWorker's outer CancellationException handling since they have no local catch around their dispatch; WebSocketWorker already used try/finally instead of try/catch around its cancellation-sensitive section).

  • Intermittent "Failed host lookup" on Android 15/16. Bumped androidx.work:work-runtime-ktx 2.10.1 → 2.11.2, which fixes an upstream AndroidX WorkManager bug where a background WorkRequest could start running before the device's network/connectivity state was fully attached, causing spurious SocketException: Failed host lookup failures on HTTP calls made from background tasks. Found by auditing flutter_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. kmpworkmanager pulls in work-runtime-ktx 2.9.1 transitively; the direct api declaration here wins Gradle's highest-version resolution (verified: ./gradlew :native_workmanager:dependencies resolves 2.9.1 -> 2.11.2).


1.4.0 - 2026-07-16 #

Changed #

  • Bumped kmpworkmanager core to 3.1.0 (was 3.0.1). 3.1.0 enforces Constraints.maxRetries inside BaseKmpWorker: it reads the maxRetries key off the WorkRequest input data and caps Failure(shouldRetry=true) / Retry at N + 1 total runs (WorkManager itself has no max-retry API — a raw Result.retry() reschedules forever). The bundled iOS KMPWorkManager.xcframework was rebuilt from 3.1.0.

Fixed #

  • DartWorker return false never retried — permanent Result.failure(). Android DartCallbackWorker and iOS Dart callback paths mapped a false callback result to WorkerResult.Failure / .failure without shouldRetry: true. Because Failure.shouldRetry defaults to false, WorkManager received Result.failure() (reschedule = false) and Constraints.maxRetries / backoffDelayMs were ignored despite docs promising retry-on-false. Native engine/setup exceptions still use shouldRetry = false so broken engine configuration does not loop forever.

  • Android Constraints.maxRetries was silently ignored. Even once a task asked to retry, WorkManager's Result.retry() is unbounded, so a callback that kept returning false looped forever. maxRetries is now forwarded from the Dart constraints map onto the KMP Constraints (so NativeTaskScheduler-scheduled triggers cap via core) and stamped onto the WorkRequest input data for every direct-enqueue path (one-time, chain, graph) so BaseKmpWorker can enforce the N + 1 ceiling. Periodic work is intentionally excluded — its runAttemptCount only resets on success, so a per-run cap would permanently disable retries after the first cap hit. ForegroundNativeWorker (which maps results itself, bypassing BaseKmpWorker) enforces the same cap inline. iOS RetryConfig now reads maxRetries via NSNumber (MethodChannel integers were silently dropped to 0 = no retry) and defaults to 3 to 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 to 0) silently discarded every progress event. Android ProgressUpdate.toMap()/toJson() and iOS ProgressReporter/emitProgress now stamp timestamp; the Dart filter treats a missing/0 timestamp as "current" for backward compatibility with older native builds. Covered by issue_38_* in device_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) __taskId was never injected into a foreground DartWorker's input, so the callback had no id to report progress with — executeDartWorkerViaMethodChannel now merges it in, mirroring Android's DartCallbackWorker; (b) the dev.brewkits/dart_worker_channel reportProgress handler existed only on the FlutterEngineManager background engine, so foreground callbacks threw MissingPluginException — the main engine now registers the same handler, routed through ProgressReporter.

  • DartWorker TaskStore status stuck on pending after success — Issue #39. Only the TaskEventBus path persisted terminal status to SQLite, and DartCallbackWorker never emits on that bus, so completed DartWorkers stayed pending forever in allTasks(). The WorkInfo fallback in observeWorkCompletion now calls taskStore.updateStatus(...) for running/completed/failed/cancelled, plus a syncTaskStoreWithWorkManager() reconciliation on restart to repair rows left stale by process death. Covered by issue_39_* in device_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 (Android MessageDigest.isEqual, iOS CryptoKit HMAC.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 after application(_:didFinishLaunchingWithOptions:) returns. Calling BGTaskScheduler.register at that point violates Apple's "all launch handlers must be registered before application finishes launching" rule and threw NSInternalInconsistencyException at 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 +load hook (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.register calls now go through ObjC @try/@catch (Swift cannot catch NSException): late or duplicate registration degrades to a BGTASK_REGISTRATION_FAILED system error instead of a crash.
    • Fixed a latent duplicate-registration crash: registerHandlers() had no idempotency guard, so GeneratedPluginRegistrant re-running on the headless background engine (FlutterEngineManager) re-registered the identifiers and threw the same NSInternalInconsistencyException.
    • BGTasks that fire before the Swift side attaches (cold-start background launch) are buffered and delivered once handlers attach.

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 implemented due to a missing getForegroundInfo() override (regressed in core v2.3.8).
    • v3.0.0 extracted Ktor HTTP workers into the optional kmpworkmanager-http artifact — not needed by this plugin (it ships its own native workers); no API changes affect the plugin bridge.

Added #

  • iOS: NativeWorkmanagerPlugin.registerBGTaskHandlers() — optional explicit registration from didFinishLaunchingWithOptions (idempotent, exception-safe). Only needed if a build setup strips ObjC +load sections.
  • 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; new issue_36 device 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, Crypto hash/encrypt/decrypt, Pdf, WebSocket, FileSystem, MoveToSharedStorage) failed on real devices with "Invalid or unsafe file path". v1.2.4 added a blanket "/data" entry to SecurityValidator's blocked-prefix list, which rejected the app's own private sandbox (/data/data/<pkg>, /data/user/<n>/<pkg> — exactly what path_provider returns). 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, /root are unchanged. Added SecurityValidatorFilePathTest (Kotlin) plus device coverage in the "All Workers" integration group.
  • iOS: Fixed an issue where the KMPWorkManager.xcframework was extracted into a double-nested path (Frameworks/Frameworks/KMPWorkManager.xcframework) during pod install, causing iOS builds to fail with "Unable to find module dependency: 'KMPWorkManager'". The prepare_command in native_workmanager.podspec is now layout-agnostic (Resolves #33).

1.3.0 - 2026-06-04 #

Added #

  • Android Auto-Init (NativeWorkManagerInitializer): Plugin now ships an androidx.startup Initializer declared in its own AndroidManifest.xml. It runs automatically before Application.onCreate(), restoring the callbackHandle from SharedPreferences and initializing KmpWorkManager with SimpleAndroidWorkerFactory.

    • Breaking zero-config change: DartWorker killed-app support now requires no custom Application class and no manual AndroidManifest.xml edits 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 your AndroidManifest.xml, then follow doc/ANDROID_SETUP.md.
    • isSchedulerInitialized flag prevents double-initialization when onAttachedToEngine runs after the Initializer.
  • Unified setup CLI (dart run native_workmanager:setup): Evolves setup_ios into a universal command covering both platforms.

    • --android: validates the app manifest has no conflicts with auto-init.
    • --ios: patches Info.plist with UIBackgroundModes and BGTaskSchedulerPermittedIdentifiers (same as the legacy setup_ios command).
    • --check: read-only validation mode — no files are written.
    • --help: full usage reference.
    • setup_ios executable retained for backward compatibility.
  • iOS WorkerResult.retry(): Added retry(reason:delayMs:attemptCap:) factory on the Swift WorkerResult struct, providing parity with WorkerResult.Retry introduced in kmpworkmanager v2.5.0.

Changed #

  • Core: Upgraded KMP WorkManager core dependency from v2.4.3 to v2.5.1.

    • Android: added WorkerResult.Retry branch in ForegroundNativeWorker to satisfy sealed-class exhaustiveness (maps to Result.retry()).
    • iOS KMPWorkManager.xcframework rebuilt from v2.5.1 source.
  • iOS retry semantics (executeWorkerSync): the retry loop now respects WorkerResult.shouldRetry. A worker returning failure(shouldRetry: false) stops retrying immediately instead of exhausting all maxRetries attempts.

  • iOS maxRetries honored on the direct-task execution path: RetryConfig.from(constraintsMap:) is now called and passed to executeWorkerSync. Previously Constraints.maxRetries was silently ignored on iOS (dead code).

  • iOS direct-task qos now read from constraintsMap["qos"] instead of being hardcoded to "background".

Fixed #

  • Android DartCallbackWorker: CancellationException is now rethrown before the outer catch (Exception) block. executeDartCallback is a suspending function; without this fix, WorkManager task cancellation was silently converted to a Failure result.

  • iOS WebSocket: NativeWorker.webSocket() now throws UnsupportedError at call-site when run on iOS. Previously the task was enqueued and silently failed with "Unknown worker class" because IosWorkerFactory has no WebSocketWorker case.

  • Android handleResume: constraint JSON parse failure now logs a NativeLogger.w warning instead of silently falling back to empty constraints (which could cause resumed downloads to ignore requiresNetwork / requiresCharging).

  • Dart resolveDispatcherTimeout: values ≤ 0 (zero, negative, NaN, ±Infinity) now fall back to the 25 s default. A Duration(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"): replaced findNextAvailableFile() + Files.move(REPLACE_EXISTING) with an atomic probe loop using ATOMIC_MOVE only (no REPLACE_EXISTING). A FileAlreadyExistsException now signals the next candidate rather than silently overwriting a file from a concurrent download.

  • Android constraint conflict warning: enqueueing with allowWhileIdle: true and isHeavyTask: true simultaneously now logs a NativeLogger.w at enqueue time. The long-running worker already bypasses Doze mode, making allowWhileIdle redundant 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.Retry branch in ForegroundNativeWorker to satisfy sealed-class exhaustiveness (maps to Result.retry()).
    • iOS: added WorkerResult.retry(reason:delayMs:attemptCap:) factory method for parity with the new KMP sealed variant; existing failure(shouldRetry: true) callers unchanged.
    • iOS KMPWorkManager.xcframework rebuilt from v2.5.1 source.

1.2.7 - 2026-05-11 #

Fixed #

  • Core: Enforced DartWorker.timeoutMs end-to-end (Issue #30).
    • Android and iOS bridges now correctly forward timeoutMs to the Dart callback dispatcher.
    • Added resolveDispatcherTimeout helper in Dart to securely parse the timeout, protecting against NaN, Infinity, and invalid types.
    • Enforced timeoutMs in both the background dispatcher and the foreground MethodChannel (_executeDartCallback).
    • Added comprehensive unit, integration, performance, and security test coverage.

1.2.6 - 2026-05-08 #

Added #

  • Android: Industrial-grade Foreground Service (FGS) Support. Added ForegroundNotificationConfig to Constraints, 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 allowWhileIdle to 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 TaskTrigger execution delays on iOS to ensure BGTaskScheduler correctly enqueues tasks.
  • Test: Added platform-aware timeouts for iOS integration tests and automatically excluded timeout-prone integration tests (TaskGraph and OfflineQueue) when running on the iOS Simulator.

1.2.5 - 2026-05-06 #

Fixed #

  • Core: Removed over-restrictive assertion in TaskTrigger.periodic that prevented using initialDelay and runImmediately: false together (#26).
  • iOS: Fixed bug where runImmediately flag was incorrectly recomputed from initialDelay instead of using the user-provided value.

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 Application class setup is required for all tasks to survive app kill.
  • iOS: Synchronized background task identifiers between setup_ios.dart and Swift code.
  • iOS: getTaskStatus() now correctly returns TaskStatus.completed for finished tasks. Previously, the iOS plugin wrote "success" to SQLite but Dart's TaskStatus enum has no success case, so every call returned null.
  • Android: Removed duplicate taskStore.updateStatus() call on task completion. The redundant second write used JSONObject(map).toString() which could corrupt nested result maps, overwriting the correctly-encoded first write.
  • iOS: FlutterEngineManager now disposes the engine after a Dart callback timeout. Previously the engine remained isInitialized = true with a hung MethodChannel, causing all subsequent DartCallbackWorker tasks to silently fail (timeout again).

Changed #

  • Engine: Upgraded core kmpworkmanager to 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 initialDelay and runImmediately for periodic tasks (#21)
    • Allows delaying the first execution of a periodic task.
    • Added runImmediately flag to skip the first execution.
    • On Android, uses native PeriodicWorkRequest.setInitialDelay().
    • On iOS, maps initialDelay to earliestBeginDate for 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.
  • Enterprise-Grade Testing:
    • Implemented comprehensive scripts/run_all_tests.sh covering Unit, Integration, Security, Performance, and Stress tests.
    • Added specific performance benchmarks for task scheduling overhead.
    • Added malicious payload protection tests.
  • 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 setInitialDelay instead of manual bypass logic.
    • Fixed edge-case crashes on Android 15.
  • iOS: Improved Periodic Task Lifecycle
    • Fixed regression where periodic tasks were not tracked in activeTasks, preventing cancellation.
  • Android: Fixed broken expedited flag logic in direct enqueue path.

1.2.2 - 2026-04-22 #

Added #

  • registerPlugins parameter in NativeWorkManager.initialize(): opt-in flag to register all Flutter plugins in the background engine, required when using plugins like flutter_local_notifications inside DartWorker callbacks. Defaults to false to preserve the Zero-Engine I/O principle and avoid side-effects (e.g. Bluetooth disconnects). Also added NativeWorkmanagerPlugin.setPluginRegistrantCallback on Android and iOS to allow selective plugin registration when registerPlugins is false. (#18)

Fixed #

  • iOS: openFile always fails on Flutter 3.38+ / scene-based appsUIApplication.shared.keyWindow returns nil in UIWindowScene lifecycle. Replaced with a new activeRootViewController extension that traverses connectedScenes to find the active key window. (#16)
  • Android: StackOverflowError when middleware is registered — Kotlin companion extension applyMiddleware was shadowing the internal package-level function of the same name, causing infinite recursion. Renamed the internal function to applyMiddlewareInternal to eliminate the ambiguity. (#17)
  • native_workmanager_gen incompatible with Flutter 3.41.xanalyzer >=11.0.0 requires meta ^1.18.0 which conflicts with the Flutter SDK's meta 1.17.0 pin. Widened constraint to >=10.0.0 <13.0.0; analyzer 10.x supports all APIs used by the generator and requires only meta ^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.
  • WorkManagerLogger interface: 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.dart script to the bin/ directory and added it to the executables section in pubspec.yaml to resolve the Could not find bin/migrate.dart error when running dart run native_workmanager:migrate (#14). Also changed developer.log to print so the CLI output displays correctly.
  • Test Infrastructure: Fixed a bug in TaskEventTracker where 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 DartWorker persistence: DartWorker tasks now execute reliably after app kill. The callbackHandle is persisted to SharedPreferences (Android) and UserDefaults (iOS) during initialize() and automatically restored when WorkManager restarts the process. Requires host app to implement Configuration.Provider — see doc/ANDROID_SETUP.md.
  • Advanced Remote Trigger: Support for direct commands in push payloads (native_wm key). 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_gen now generates type-safe enqueue wrappers and automatic worker registries from @WorkerCallback annotations.
  • Task Graphs (DAG): Support for complex non-linear task dependencies on Android.
26
likes
160
points
848
downloads

Documentation

API reference

Publisher

verified publisherbrewkits.dev

Weekly Downloads

Background task scheduling for Flutter — 25+ native workers (HTTP, image, crypto, file), task chains, zero Flutter Engine overhead.

Repository (GitHub)
View/report issues
Contributing

Topics

#background #networking #files #images #cryptography

License

MIT (license)

Dependencies

flutter, plugin_platform_interface

More

Packages that depend on native_workmanager

Packages that implement native_workmanager