v1.8.0
native_workmanager v1.8.0
Published to pub.dev.
Added
-
Opt-in TLS certificate pinning for every HTTP-ish worker
(HttpRequestWorker,HttpDownloadWorker,HttpUploadWorker,
HttpSyncWorker,ParallelHttpDownloadWorker,ParallelHttpUploadWorker,
WebSocketWorker) via a newcertificatePinningparameter and the
CertificatePin/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 acertificatePinningconfig 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 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 which
URLSessionserves 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 toperformDefaultHandlingrather than manually calling
SecTrustEvaluateWithErrorand 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. See
TLS 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 repeat
enqueue(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 s
BGAppRefreshTaskRequestbudget 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 to
queryTasks/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 turns
true. Wired on both platforms: Android (CoroutineWorker
cancellation), iOS foreground/simulator (main-isolateactiveTasks
cancel), and iOS true-background BGTask expiration. See
DartTaskCancellationRegistry(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 running
Taskon 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: trueHttpDownloadWorkeror
HttpUploadWorkernever actually stopped the transfer
(#69). Both
registered their backgroundURLSessionTaskwith
BackgroundSessionManagerunder 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 parses
pubspec.yaml,.fvmrc, or.fvm/fvm_config.json, so it silently
failed to parse and fell back to thechannelinput'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 emptychannelas defense in depth. native_workmanager_genhad noanalysis_options.yamlof its own, so
dart analyzewalked up to the root plugin's — which includes
package: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. A
DartWorkerwhosetimeoutMsfires 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 terminal
WorkInfostate 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)with
maxRetries: 0delivers its terminal event at ~1020 ms, exactly at the
timeout mark, confirmed on both platforms. Fixed by adding
maxRetries: 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.
Also published: native_workmanager_gen v1.8.0