Skip to content

v1.8.0

Choose a tag to compare

@vietnguyentuan2019 vietnguyentuan2019 released this 11 Sep 06:52
· 40 commits to main since this release

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 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.1 → 3.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 (4096→8192) 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.

Also published: native_workmanager_gen v1.8.0