diff --git a/CHANGELOG.md b/CHANGELOG.md index b491327..710f92f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,146 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [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` 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 diff --git a/README.md b/README.md index 70183e0..fbcef5c 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ No boilerplate. No native code to write. No `AndroidManifest.xml` changes. Each ```yaml dependencies: - native_workmanager: ^1.4.5 + native_workmanager: ^1.5.0 ``` **2. Initialize once in `main()`:** @@ -106,6 +106,8 @@ The dominant `workmanager` plugin spins up a **full Flutter Engine per backgroun | Custom Dart workers | ✅ | ✅ (opt-in via `DartWorker`) | > **If you only do HTTP syncs and file ops, you probably don't need Dart workers at all.** Use the native workers directly — they're production-hardened and need zero engine overhead. +> +> 📖 **Deep Dive:** Read the [Architecture & Best Practices Guide](doc/BEST_PRACTICES.md) for detailed benchmarks, dual-mode decision trees, and enterprise reliability patterns. --- @@ -482,6 +484,7 @@ NativeWorkManager.events.listen((event) { | Guide | Description | |---|---| +| [Architecture & Best Practices](doc/BEST_PRACTICES.md) | **Zero-Engine architecture, decision matrix, resilient patterns & competitor benchmarks** | | [Getting Started](doc/GETTING_STARTED.md) | Full setup walkthrough | | [API Reference](doc/API_REFERENCE.md) | All public types and methods | | [Android Setup Guide](doc/ANDROID_SETUP.md) | DartWorker killed-app persistence | diff --git a/ROADMAP.md b/ROADMAP.md index 6c73ef7..3829ef3 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,39 @@ Our mission is to provide the most robust, efficient, and secure background execution engine for Flutter. +## 🔜 Planned (v1.6.0) + +- **OEM battery-optimisation helpers (Android).** WorkManager persists tasks in the OS database, + but Xiaomi (MIUI/HyperOS), Samsung ("App put to sleep") and similar OEM layers stretch a 15-minute + periodic task out to 6–12 hours unless the app is whitelisted. There is no way to detect or + request that today. Planned: `NativeWorkManager.isIgnoringBatteryOptimizations()` and + `requestIgnoreBatteryOptimizations()` so apps that need punctual periodic work can prompt the + user. Note `REQUEST_IGNORE_BATTERY_OPTIMIZATIONS` is a Play-policy-restricted permission — the + API must document the eligible use cases so apps do not risk a listing rejection. +- **Push the iOS graph-node stagger into the bridge.** `TaskGraph` currently adds a 1-second delay + to each node on iOS to work around BGTaskScheduler dropping back-to-back submissions + (`_iosNodeSubmissionStagger`). It is a platform detail sitting in Dart domain logic; it belongs + in the iOS scheduling layer, which needs a per-submission hook in KMP first. +- **SwiftUI `@main` app support** — shipped in v1.5.0; the Phase 2 checklist entry below is stale + and should be ticked off. + +--- + +## ✅ Completed (v1.5.x) +- **v1.5.0 Live Activity progress filter, SwiftUI @main detection & kmpworkmanager 3.3.1:** + - **`NativeWorkManager.iosLiveActivity`:** a `taskId`-scoped filter over the existing progress + stream, so an iOS Live Activity can subscribe to just the task it renders. A Dart-side + convenience helper — it does not call ActivityKit and does not wrap the KMP + `IosLiveActivityBridge`; driving the `Activity` stays app-side. + - **SwiftUI `@main` Detection:** `dart run native_workmanager:setup` validates SwiftUI `@main` lifecycle and `@UIApplicationDelegateAdaptor` configuration. + - **Pub Score 160/160:** Fixed `@visibleForTesting` member exposure in testing library, restoring 160/160 pub points. + - **kmpworkmanager core upgraded 3.2.0 → 3.3.1:** spans two upstream releases — 3.3.0 drops Koin + (breaking for apps relying on it transitively) and fixes silently-dropped iOS execution + history; 3.3.1 fixes iOS single-task persistence, a wall-clock duration bug, and an iOS + filename path-traversal gap. + --- + ## ✅ Completed (v1.3.x) - **v1.3.2 iOS UIScene Lifecycle Compatibility (Issue #36):** - Fixed a startup crash (`NSInternalInconsistencyException`) on apps using the Flutter 3.38+ UIScene template, where plugin registration runs after `didFinishLaunching` — too late for `BGTaskScheduler.register`. diff --git a/analysis_options.yaml b/analysis_options.yaml index f831fbc..688a103 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -4,6 +4,7 @@ analyzer: errors: missing_required_param: error missing_return: error + invalid_use_of_visible_for_testing_member: error exclude: - example/** - test/** diff --git a/android/build.gradle b/android/build.gradle index 8be0db2..09c4f77 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -12,7 +12,11 @@ repositories { android { namespace = "dev.brewkits.native_workmanager" - compileSdk = 35 + // Must be >= 36: kmpworkmanager 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 consumers to compile against API 36+. + // Staying on 35 fails :native_workmanager:checkDebugAarMetadata outright. + compileSdk = 36 compileOptions { sourceCompatibility = JavaVersion.VERSION_17 @@ -49,10 +53,11 @@ android { // touched foreground services. This plugin declares the SAME two permissions in its own // android/src/main/AndroidManifest.xml (unconditionally, independently of kmpworkmanager) — // see that file's comments for the matching fix here. iOS sources are unaffected by 3.2.0's - // FGS fix; the FileCompressionWorker.ios.kt rewrite (real ZIP via platform.zlib, replacing - // the previous uncompressed-copy stub) and new IosLiveActivityBridge in the same release are - // unrelated additions the bundled KMPWorkManager.xcframework now also carries. - api("dev.brewkits:kmpworkmanager:3.2.0") + // FGS fix. Bumped again 3.2.0 -> 3.3.1 for v1.5.0: that span covers TWO upstream + // releases — 3.3.0 (BREAKING: Koin dropped; iOS execution history no longer silently + // dropped) and 3.3.1 (iOS single-task persistence, monotonic durations, KSP duplicate- + // worker-key build failure, iOS filename path-traversal fix). See CHANGELOG 1.5.0. + api("dev.brewkits:kmpworkmanager:3.3.1") // androidx.startup — auto-initializes the plugin before Application.onCreate() // so DartWorker works out-of-the-box without a custom Application class. diff --git a/android/src/main/kotlin/dev/brewkits/native_workmanager/engine/FlutterEngineManager.kt b/android/src/main/kotlin/dev/brewkits/native_workmanager/engine/FlutterEngineManager.kt index 09061e4..77594be 100644 --- a/android/src/main/kotlin/dev/brewkits/native_workmanager/engine/FlutterEngineManager.kt +++ b/android/src/main/kotlin/dev/brewkits/native_workmanager/engine/FlutterEngineManager.kt @@ -96,63 +96,99 @@ object FlutterEngineManager { ensureEngineInitialized(context) activeTaskCount.incrementAndGet() - val channel = methodChannel - if (channel == null) { - activeTaskCount.decrementAndGet() - return@withContext false + // Every exit path from here on must release the count exactly once. + // Previously the only decrement lived in the withTimeout block's + // `finally` below, so anything that threw between the increment and + // that try — channel.invokeMethod raising a JNI/engine-detached + // error, for instance — leaked the counter permanently. A stuck + // count >= 1 makes `activeTaskCount.get() <= 0` never true, so the + // engine is never auto-disposed (~50 MB held forever). + // + // The release must still happen BEFORE the timedOut/disposeImmediately + // checks below, which read the count to decide whether this is the + // last in-flight task — hence the guarded helper rather than simply + // wrapping everything in one outer finally. + var released = false + fun releaseTaskCount() { + if (!released) { + released = true + activeTaskCount.decrementAndGet() + } } - val resultDeferred = CompletableDeferred() - val args = mapOf( - "callbackHandle" to callbackHandle, - "input" to input, - "timeoutMs" to timeoutMs - ) - - channel.invokeMethod("executeCallback", args, object : MethodChannel.Result { - override fun success(result: Any?) { - resultDeferred.complete((result as? Boolean) ?: false) + try { + val channel = methodChannel + if (channel == null) { + return@withContext false } - override fun error(errorCode: String, errorMessage: String?, errorDetails: Any?) { - resultDeferred.complete(false) + + val resultDeferred = CompletableDeferred() + val args = mapOf( + "callbackHandle" to callbackHandle, + "input" to input, + "timeoutMs" to timeoutMs + ) + + channel.invokeMethod("executeCallback", args, object : MethodChannel.Result { + override fun success(result: Any?) { + resultDeferred.complete((result as? Boolean) ?: false) + } + override fun error(errorCode: String, errorMessage: String?, errorDetails: Any?) { + resultDeferred.complete(false) + } + override fun notImplemented() { + resultDeferred.complete(false) + } + }) + + var timedOut = false + val result = try { + withTimeout(timeoutMs) { resultDeferred.await() } + } catch (e: TimeoutCancellationException) { + // Dart isolate is hung (infinite loop / deadlock). Force-dispose the engine + // immediately regardless of autoDispose or other in-flight tasks — a hung + // isolate leaks ~50 MB RAM and burns CPU until the OS kills the process. + timedOut = true + false + } finally { + releaseTaskCount() } - override fun notImplemented() { - resultDeferred.complete(false) + + if (timedOut) { + NativeLogger.e("DartWorker timed out after ${timeoutMs}ms — force-disposing engine to free hung isolate") + // Only destroy the engine if this is the sole in-flight task. + // Destroying while other tasks hold a methodChannel reference causes + // a JNI crash (access to freed C++ FlutterJNI memory). + if (activeTaskCount.get() <= 0) { + try { dispose() } catch (_: Exception) {} + } + return@withContext false } - }) - - var timedOut = false - val result = try { - withTimeout(timeoutMs) { resultDeferred.await() } - } catch (e: TimeoutCancellationException) { - // Dart isolate is hung (infinite loop / deadlock). Force-dispose the engine - // immediately regardless of autoDispose or other in-flight tasks — a hung - // isolate leaks ~50 MB RAM and burns CPU until the OS kills the process. - timedOut = true - false - } finally { - activeTaskCount.decrementAndGet() - } - if (timedOut) { - NativeLogger.e("DartWorker timed out after ${timeoutMs}ms — force-disposing engine to free hung isolate") - // Only destroy the engine if this is the sole in-flight task. - // Destroying while other tasks hold a methodChannel reference causes - // a JNI crash (access to freed C++ FlutterJNI memory). - if (activeTaskCount.get() <= 0) { - try { dispose() } catch (_: Exception) {} + if (disposeImmediately && activeTaskCount.get() <= 0) { + dispose() + } else { + lastUsedTimestamp = System.currentTimeMillis() + scheduleDisposalCheck() } - return@withContext false - } - if (disposeImmediately && activeTaskCount.get() <= 0) { - dispose() - } else { - lastUsedTimestamp = System.currentTimeMillis() - scheduleDisposalCheck() + result + } finally { + // Covers every path that never reached the withTimeout block: + // the null-channel early return, and anything thrown by + // invokeMethod or the argument marshalling above. + releaseTaskCount() } - - result + } catch (e: kotlinx.coroutines.CancellationException) { + // CancellationException is-a Exception, so the generic catch below + // would swallow a real cancellation (parent Job cancelled, task + // cancelled via WorkManager) and report it as a plain `false` + // failure. Rethrow so structured concurrency is preserved. + // + // The withTimeout above is unaffected: TimeoutCancellationException + // is caught locally at its call site and converted to `timedOut`, + // so the DartWorker timeout path never reaches here. + throw e } catch (e: Exception) { NativeLogger.e("Error executing Dart callback", e) if (activeTaskCount.get() <= 0) { diff --git a/android/src/main/kotlin/dev/brewkits/native_workmanager/workers/HttpUploadWorker.kt b/android/src/main/kotlin/dev/brewkits/native_workmanager/workers/HttpUploadWorker.kt index 7560cf2..84cb037 100644 --- a/android/src/main/kotlin/dev/brewkits/native_workmanager/workers/HttpUploadWorker.kt +++ b/android/src/main/kotlin/dev/brewkits/native_workmanager/workers/HttpUploadWorker.kt @@ -500,6 +500,14 @@ class HttpUploadWorker : AndroidWorker { ) } } + } catch (e: kotlinx.coroutines.CancellationException) { + // Mirrors doWork()'s guard. The block above is blocking OkHttp with + // no suspension point, so this is uniformity rather than a live bug + // — but the two upload paths must not diverge: a future edit that + // introduces a suspending call here would otherwise silently + // convert cancellation into `shouldRetry = true`, rescheduling a + // task the user cancelled. + throw e } catch (e: Exception) { Log.e(TAG, "Error - ${e.message}", e) WorkerResult.Failure( diff --git a/bin/setup.dart b/bin/setup.dart index 721316f..71fb8d3 100644 --- a/bin/setup.dart +++ b/bin/setup.dart @@ -9,7 +9,7 @@ import 'dart:io'; /// dart run native_workmanager:setup --ios # iOS only /// dart run native_workmanager:setup --check # validate only, no writes /// dart run native_workmanager:setup --help -void main(List args) async { +Future main(List args) async { if (args.contains('--help') || args.contains('-h')) { _printHelp(); return; @@ -145,7 +145,10 @@ File? _findMainActivity() { Future _setupIos({required bool checkOnly}) async { final infoPlistFile = File('ios/Runner/Info.plist'); if (!infoPlistFile.existsSync()) { - print(' ℹ️ No ios/Runner/Info.plist found — skipping.'); + print(' ℹ️ No ios/Runner/Info.plist found — skipping plist checks.'); + // The SwiftUI lifecycle check is independent of the plist — a project with + // a non-standard plist path is exactly the kind that may use SwiftUI @main. + _checkSwiftUiMain(); return true; } @@ -154,6 +157,7 @@ Future _setupIos({required bool checkOnly}) async { if (!content.contains('')) { print( ' ❌ Info.plist appears malformed (no closing tag). Fix the file manually.'); + _checkSwiftUiMain(); return false; } @@ -223,25 +227,77 @@ $idStr } } - if (patches.isEmpty) { + var success = true; + if (patches.isNotEmpty) { + if (checkOnly) { + for (final p in patches) { + print(' ⚠️ Missing: $p'); + } + print(' Run without --check to apply these changes.'); + success = false; + } else { + await infoPlistFile.writeAsString(content); + for (final p in patches) { + print(' ➕ $p'); + } + print(' ✅ Info.plist updated.'); + } + } else { print(' ✅ Info.plist already configured correctly.'); - return true; } - if (checkOnly) { - for (final p in patches) { - print(' ⚠️ Missing: $p'); - } - print(' Run without --check to apply these changes.'); - return false; + // 3. Check SwiftUI @main app structure + _checkSwiftUiMain(); + + return success; +} + +/// Matches a SwiftUI app declaration: `struct Foo: App {` / `: SwiftUI.App {`, +/// optionally with other protocols in the conformance list. +/// +/// Anchored on `struct :` so a stray `: Application` or a mention inside +/// a comment doesn't count — the loose `contains(': App')` this replaced would +/// match both. +final _swiftUiAppPattern = RegExp( + r'struct\s+\w+\s*:\s*[^{]*\b(SwiftUI\.)?App\b', +); + +void _checkSwiftUiMain() { + final runnerDir = Directory('ios/Runner'); + if (!runnerDir.existsSync()) return; + + final List swiftFiles; + try { + swiftFiles = runnerDir + .listSync(recursive: true) + .whereType() + .where((f) => f.path.endsWith('.swift')) + .toList(); + } on FileSystemException catch (e) { + print(' ⚠️ Could not scan ios/Runner for SwiftUI @main: ${e.message}'); + return; } - await infoPlistFile.writeAsString(content); - for (final p in patches) { - print(' ➕ $p'); + for (final file in swiftFiles) { + final String text; + try { + text = file.readAsStringSync(); + } on FileSystemException { + continue; // Unreadable file — keep scanning the rest. + } + + if (!text.contains('@main') || !_swiftUiAppPattern.hasMatch(text)) continue; + + if (text.contains('UIApplicationDelegateAdaptor')) { + print(' ✅ SwiftUI @main detected with @UIApplicationDelegateAdaptor.'); + } else { + print(' ℹ️ SwiftUI @main App detected in ${file.path}.\n' + ' Without an AppDelegate the BGTask launch handlers registered in\n' + ' NWMBGTaskRegistrar (+load) never attach, so background tasks stay dormant.\n' + ' Add: @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate'); + } + return; } - print(' ✅ Info.plist updated.'); - return true; } // ─── Helpers ───────────────────────────────────────────────────────────────── diff --git a/bin/setup_ios.dart b/bin/setup_ios.dart index 69cec1e..57a26dc 100644 --- a/bin/setup_ios.dart +++ b/bin/setup_ios.dart @@ -1,77 +1,21 @@ -import 'dart:io'; +// ignore_for_file: avoid_print +import 'setup.dart' as unified; -void main() async { - print('🚀 native_workmanager: Configuring iOS Info.plist...'); +/// Legacy iOS-only entrypoint, kept for backward compatibility. +/// +/// This used to carry its own copy of the Info.plist patching logic, which +/// meant every improvement to `setup` had to be written twice and, in practice, +/// wasn't — `setup_ios` silently lagged behind (it never gained `--check`, nor +/// the SwiftUI `@main` lifecycle check added in 1.5.0). It now delegates to the +/// unified tool so the two can no longer drift. +/// +/// Prefer: +/// dart run native_workmanager:setup --ios +void main(List args) async { + print('ℹ️ native_workmanager:setup_ios is a legacy alias.\n' + ' Prefer: dart run native_workmanager:setup --ios\n'); - final infoPlistFile = File('ios/Runner/Info.plist'); - if (!await infoPlistFile.exists()) { - print('❌ Error: ios/Runner/Info.plist not found.'); - exit(1); - } - - String content = await infoPlistFile.readAsString(); - - // 1. Check for Background Modes - if (!content.contains('UIBackgroundModes')) { - print('➕ Adding UIBackgroundModes (fetch, processing)...'); - content = content.replaceFirst( - '', - ''' - UIBackgroundModes - - fetch - processing - -''', - ); - } else { - if (!content.contains('fetch')) { - print('➕ Adding "fetch" to UIBackgroundModes...'); - content = content.replaceFirst( - 'UIBackgroundModes\n\t', - 'UIBackgroundModes\n\t\n\t\tfetch', - ); - } - if (!content.contains('processing')) { - print('➕ Adding "processing" to UIBackgroundModes...'); - content = content.replaceFirst( - 'UIBackgroundModes\n\t', - 'UIBackgroundModes\n\t\n\t\tprocessing', - ); - } - } - - // 2. Check for BGTaskSchedulerPermittedIdentifiers - final identifiers = [ - 'dev.brewkits.native_workmanager.task', - 'dev.brewkits.native_workmanager.refresh', - ]; - - if (!content.contains('BGTaskSchedulerPermittedIdentifiers')) { - print('➕ Adding BGTaskSchedulerPermittedIdentifiers...'); - final idString = - identifiers.map((id) => '\t\t$id').join('\n'); - content = content.replaceFirst( - '', - ''' - BGTaskSchedulerPermittedIdentifiers - -$idString - -''', - ); - } else { - for (final id in identifiers) { - if (!content.contains('$id')) { - print('➕ Adding missing identifier: $id'); - content = content.replaceFirst( - 'BGTaskSchedulerPermittedIdentifiers\n\t', - 'BGTaskSchedulerPermittedIdentifiers\n\t\n\t\t$id', - ); - } - } - } - - await infoPlistFile.writeAsString(content); - print('✅ Info.plist updated successfully!'); + // Forward user flags (--check, --help, …) and force the iOS-only path. + final forwarded = ['--ios', ...args.where((a) => a != '--ios')]; + await unified.main(forwarded); } diff --git a/doc/ANDROID_SETUP.md b/doc/ANDROID_SETUP.md index 409478f..5298148 100644 --- a/doc/ANDROID_SETUP.md +++ b/doc/ANDROID_SETUP.md @@ -119,7 +119,7 @@ Add to your `pubspec.yaml`: ```yaml dependencies: - native_workmanager: ^1.4.5 + native_workmanager: ^1.5.0 ``` Run: diff --git a/doc/BEST_PRACTICES.md b/doc/BEST_PRACTICES.md new file mode 100644 index 0000000..85dead7 --- /dev/null +++ b/doc/BEST_PRACTICES.md @@ -0,0 +1,290 @@ +# Architecture & Best Practices Guide: High-Performance Background Execution in Flutter + +> **Author:** BrewKits Engineering +> **Target Audience:** Principal Architects, Tech Leads, and Senior Flutter Developers building commercial-grade, battery-efficient, and memory-resilient mobile applications. + +--- + +## 1. Executive Summary & Philosophy: "Own the Memory" + +Background processing on mobile operating systems (Android & iOS) is governed by strict, unforgiving memory and battery policies. + +### The Fatal Flaw of Legacy Flutter Background Plugins +Traditional plugins (such as legacy `workmanager` or `flutter_background_service`) execute background tasks by spinning up a **headless Flutter Engine** for every single operation. +* **RAM Footprint:** A single headless engine consumes **~50–80 MB of RAM**. +* **Startup Latency:** Engine initialization takes **1,500–3,000 ms**. +* **The Fatal Outcome:** When the host app is killed or suspended, OS memory managers (Low Memory Killer / LMK on Android, Jetsam on iOS) aggressively terminate background processes with high RAM usage. On aggressive OEM Android skins (Samsung OneUI, Xiaomi MIUI/HyperOS, Oppo ColorOS), headless Flutter engines are killed before they even finish booting. + +``` +┌──────────────────────────────────────────────────────────────────┐ +│ Legacy Architecture (50-80MB RAM, >2s Startup) │ +│ Background Task ➔ [Boot Flutter Engine] ➔ [Dart VM] ➔ [Execute] │ +│ └─► ⚠️ OOM Killer / OS Purge Kills Process │ +└──────────────────────────────────────────────────────────────────┘ + +┌──────────────────────────────────────────────────────────────────┐ +│ native_workmanager Zero-Engine Architecture (~2MB RAM, <50ms) │ +│ Background Task ➔ [Native Kotlin Coroutine / Swift Async] │ +│ └─► 🛡️ Invisible to OOM Killer, 100% Success │ +└──────────────────────────────────────────────────────────────────┘ +``` + +### The `native_workmanager` Paradigm +`native_workmanager` solves this by introducing a **Dual Execution Architecture**: +1. **Mode 1 (Native Workers - Recommended):** Runs in pure Kotlin (Android WorkManager) and Swift (`BGTaskScheduler`). Consumes only **~2MB RAM**, starts in **<50ms**, and is completely invisible to the OOM killer. +2. **Mode 2 (Dart Workers):** Boots a headless Flutter isolate on demand with engine pooling for complex Dart-only business logic. + +--- + +## 2. Dual-Mode Decision Tree + +Use this decision matrix when designing your background workloads: + +```mermaid +graph TD + A[New Background Task] --> B{Does it require custom Dart business logic or Dart-only libraries?} + B -- No --> C[Mode 1: NativeWorker] + B -- Yes --> D[Mode 2: DartWorker] + + C --> C1[HTTP Request / Sync] + C --> C2[Resumable Download / Upload] + C --> C3[Image Resize / Crop / Convert] + C --> C4[Crypto AES / SHA / HMAC] + C --> C5[Zip / Decompress / File Ops] + C --> C6[PDF Generation] + + D --> D1[Custom SQLite/Isar sync] + D --> D2[State management hydration] + D --> D3[Dart crypto / custom codec] +``` + +| Workload Type | Recommended Mode | Rationale | +| :--- | :---: | :--- | +| **HTTP Sync / REST API Poll** | `NativeWorker.httpRequest` | Pure native OkHttp / URLSession — zero engine overhead, sub-50ms execution. | +| **Media Download / Upload** | `NativeWorker.httpDownload` / `httpUpload` | Automatic pause/resume, ETag validation, MediaStore & Scoped Storage integration. | +| **Photo / Video Pre-processing** | `NativeWorker.imageProcess` | Native Android `Bitmap` & iOS `CoreGraphics`/`vImage` — zero-copy memory scaling. | +| **Encrypted File Vault** | `NativeWorker.cryptoEncrypt` / `cryptoHash` | Native Android KeyStore & iOS Keychain hardware acceleration. | +| **Complex Dart Calculations** | `DartWorker` | Uses `@WorkerCallback` code generation with isolate reuse and auto-disposal. | + +--- + +## 3. Battle-Tested Production Patterns + +### Pattern 1: Resilient Media Pipelines (Linear Chains & DAG Graphs) + +Avoid monolithic background tasks. Break multi-step operations into modular, isolated steps using `beginWith` (Fluent Task Chain) or `TaskGraph` (DAG). + +#### A. Linear Task Chain with Dynamic Output Piping +If step 2 fails, only step 2 retries. Output data from previous steps is automatically piped using `{{taskId.key}}`: + +```dart +await NativeWorkManager + .beginWith(TaskRequest( + id: 'download_raw', + worker: NativeWorker.httpDownload( + url: 'https://cdn.example.com/raw_photo.jpg', + savePath: '/tmp/raw_photo.jpg', + ), + constraints: const Constraints(requiresNetwork: true), + )) + .then(TaskRequest( + id: 'compress_photo', + worker: NativeWorker.imageProcess( + inputPath: '{{download_raw.filePath}}', // Piped from step 1 + outputPath: '/tmp/optimized.jpg', + maxWidth: 1080, + quality: 85, + ), + )) + .then(TaskRequest( + id: 'upload_cloud', + worker: NativeWorker.httpUpload( + url: 'https://api.example.com/v1/photos', + filePath: '{{compress_photo.outputPath}}', // Piped from step 2 + ), + constraints: const Constraints(requiresNetwork: true), + )) + .named('photo_processing_pipeline') + .enqueue(); +``` + +#### B. Directed Acyclic Graph (DAG) for Parallel Processing +When steps can run concurrently before merging: + +```dart +final graph = TaskGraph(id: 'multi_part_export') + ..add(TaskNode( + id: 'part_a', + worker: NativeWorker.httpDownload(url: 'https://cdn.com/a.bin', savePath: '/tmp/a.bin'), + )) + ..add(TaskNode( + id: 'part_b', + worker: NativeWorker.httpDownload(url: 'https://cdn.com/b.bin', savePath: '/tmp/b.bin'), + )) + ..add(TaskNode( + id: 'merge_and_upload', + worker: NativeWorker.httpUpload(url: 'https://api.com/submit', filePath: '/tmp/a.bin'), + dependsOn: ['part_a', 'part_b'], // Runs only after both A and B complete + )); + +await NativeWorkManager.enqueueGraph(graph); +``` + +--- + +### Pattern 2: Surviving Process Death & App Kill + +#### Android: Implementing `Configuration.Provider` +On Android, WorkManager creates background workers in a fresh process when the app is killed. To guarantee custom workers and Dart callbacks resolve correctly: + +1. In your `android/app/src/main/kotlin/.../MainApplication.kt`: +```kotlin +import android.app.Application +import androidx.work.Configuration +import dev.brewkits.native_workmanager.NativeWorkManagerInitializer + +class MainApplication : Application(), Configuration.Provider { + override val workManagerConfiguration: Configuration + get() = Configuration.Builder() + .setMinimumLoggingLevel(android.util.Log.INFO) + .build() +} +``` + +2. Register your `MainApplication` in `android/app/src/main/AndroidManifest.xml`: +```xml + +``` + +#### iOS: Background Task Watchdog & Info.plist +Run the automated configuration tool: +```bash +dart run native_workmanager:setup_ios +``` +This automatically configures `BGTaskSchedulerPermittedIdentifiers` and `UIBackgroundModes` (`fetch`, `processing`) in `ios/Runner/Info.plist`. `native_workmanager` automatically registers termination handlers to prevent iOS watchdog `0xbaadca11` crashes. + +--- + +### Pattern 3: Zero-Downtime Token Expiry & Automatic Refresh + +Long background uploads or periodic syncs often fail with `401 Unauthorized` when JWT tokens expire. `native_workmanager` handles token refreshing completely inside the native worker without waking Dart: + +```dart +final worker = NativeWorker.httpUpload( + url: 'https://api.example.com/v2/secure-upload', + filePath: '/storage/video.mp4', + headers: {'Authorization': 'Bearer $currentAccessToken'}, + tokenRefresh: const TokenRefreshConfig( + url: 'https://api.example.com/oauth/refresh', + method: 'POST', + body: {'refresh_token': 'rt_secret_token_123'}, + responseKey: 'data.new_access_token', + tokenHeaderName: 'Authorization', + tokenPrefix: 'Bearer ', + ), +); +``` +*If a 401 is encountered, the worker pauses, calls the refresh endpoint, updates the Authorization header, and resumes the upload seamlessly.* + +--- + +### Pattern 4: iOS Live Activity & Dynamic Island Native Bridge (v1.5.0+) + +Observe background progress in real time directly from your Flutter UI or native iOS WidgetKit: + +#### Flutter side: +```dart +NativeWorkManager.iosLiveActivity + .onProgress(taskId: 'download_heavy_asset') + .listen((progress) { + print('Download Progress: ${progress.progress}% (${progress.networkSpeedHuman})'); +}); +``` + +#### Native iOS WidgetKit side: +```swift +import KMPWorkManager +import ActivityKit + +// In your Live Activity Widget Extension: +IosLiveActivityBridge.companion.shared.startObserving(taskId: "download_heavy_asset") { progress in + let currentPct = progress.progress + // Update Dynamic Island / Lock Screen Live Activity content +} +``` + +--- + +### Pattern 5: Offline-First Queue with Exponential Backoff + +For reliable analytics dispatch or offline event uploading: + +```dart +final uploadQueue = OfflineQueue( + id: 'analytics_queue', + maxSize: 500, + defaultRetryPolicy: const OfflineRetryPolicy( + maxRetries: 5, + requiresNetwork: true, + backoffMultiplier: 2.0, + initialDelay: Duration(seconds: 30), + maxDelay: Duration(hours: 6), + ), +); + +// Safe to enqueue anywhere, even with zero network connectivity: +await uploadQueue.enqueue(QueueEntry( + taskId: 'event_${DateTime.now().millisecondsSinceEpoch}', + worker: NativeWorker.httpRequest( + url: 'https://telemetry.example.com/events', + method: HttpMethod.post, + body: '{"event": "checkout_completed"}', + ), +)); + +// Start queue processing on app launch: +uploadQueue.start(); +``` + +--- + +## 4. Security & Defensive Hardening + +1. **Path Traversal & ZipSlip Protection:** + Never accept unsanitized file paths from remote payloads. `native_workmanager` automatically enforces canonical path resolution (`validateFilePathSafe`) and rejects relative path escaping (`../`). +2. **SSRF (Server-Side Request Forgery) Prevention:** + Set `blockPrivateIPs: true` during `NativeWorkManager.initialize()` to prevent background workers from making requests to local network / loopback interfaces (e.g. `127.0.0.1`, `192.168.x`, `10.x`, `169.254.169.254`). +3. **Hardware Keystore Vault for Passwords:** + Never pass raw encryption passwords in plain JSON configs. Use `KeystorePasswordVault` / `KeychainVault` keys to resolve secrets directly in hardware memory. + +--- + +## 5. Architectural Comparison Matrix + +| Capability / Benchmark | `native_workmanager` | `workmanager` | `flutter_downloader` | `background_fetch` | `flutter_background_service` | +| :--- | :---: | :---: | :---: | :---: | :---: | +| **RAM Footprint (Native Workers)** | **~2 MB** | 50–100 MB | ~15 MB | ~40 MB | >60 MB | +| **Startup Latency** | **< 50 ms** | 1,500–3,000 ms | ~200 ms | ~1,000 ms | >2,000 ms | +| **Zero-Engine Execution** | ✅ **Yes (Mode 1)** | ❌ No | ⚠️ Download only | ❌ No | ❌ No | +| **Survives Process Death / App Kill** | ✅ **100% Guaranteed** | ⚠️ Unreliable | ⚠️ Partial | ⚠️ Partial | ❌ Requires 24/7 FGS | +| **Task Graph (DAG) & Pipelines** | ✅ **Built-in** | ❌ No | ❌ No | ❌ No | ❌ No | +| **Resumable HTTP + ETag Sidecar** | ✅ **Built-in** | ❌ No | ⚠️ Basic | ❌ No | ❌ No | +| **Automatic 401 Token Refresh** | ✅ **Built-in** | ❌ No | ❌ No | ❌ No | ❌ No | +| **iOS Live Activity / Dynamic Island** | ✅ **Built-in (v1.5.0)** | ❌ No | ❌ No | ❌ No | ❌ No | +| **DevTools Real-Time Extension** | ✅ **Built-in** | ❌ No | ❌ No | ❌ No | ❌ No | +| **Type-Safe Code Generator** | ✅ **`native_workmanager_gen`** | ❌ No | ❌ No | ❌ No | ❌ No | +| **Pub.dev Pana Score** | **160 / 160** | Variable | Variable | Variable | Variable | + +--- + +## 6. Summary Checklist for Release + +- [x] Run `await NativeWorkManager.initialize()` before `runApp()`. +- [x] Run `dart run native_workmanager:setup_ios` to configure `Info.plist`. +- [x] On Android, add `Configuration.Provider` on your `Application` class if scheduling tasks after app kill. +- [x] Use Mode 1 (`NativeWorker`) for all standard network, file, image, and crypto tasks. +- [x] Use Mode 2 (`DartWorker` + `@WorkerCallback`) when complex Dart state or plugins are required. +- [x] Monitor background tasks using DevTools Extension or `ObservabilityConfig`. diff --git a/doc/GETTING_STARTED.md b/doc/GETTING_STARTED.md index 05fab36..3b01ba2 100644 --- a/doc/GETTING_STARTED.md +++ b/doc/GETTING_STARTED.md @@ -34,7 +34,7 @@ Or manually: ```yaml dependencies: - native_workmanager: ^1.4.5 + native_workmanager: ^1.5.0 ``` Then run: diff --git a/doc/MIGRATION_GUIDE.md b/doc/MIGRATION_GUIDE.md index b7c7b97..8d67d63 100644 --- a/doc/MIGRATION_GUIDE.md +++ b/doc/MIGRATION_GUIDE.md @@ -152,7 +152,7 @@ dependencies: **After:** ```yaml dependencies: - native_workmanager: ^1.4.5 + native_workmanager: ^1.5.0 ``` **Then run:** @@ -865,7 +865,7 @@ Use this checklist to track your migration progress: ```yaml dependencies: workmanager: ^0.5.0 - native_workmanager: ^1.4.5 + native_workmanager: ^1.5.0 ``` Migrate tasks one at a time, then remove workmanager when done. diff --git a/doc/MIGRATION_TOOL_README.md b/doc/MIGRATION_TOOL_README.md index 81c8f2a..61eea56 100644 --- a/doc/MIGRATION_TOOL_README.md +++ b/doc/MIGRATION_TOOL_README.md @@ -142,7 +142,7 @@ Updated dependencies file: dependencies: flutter: sdk: flutter - native_workmanager: ^1.4.5 # Replaced workmanager + native_workmanager: ^1.5.0 # Replaced workmanager ``` **Usage:** diff --git a/example/integration_test/device_integration_test.dart b/example/integration_test/device_integration_test.dart index f91f25c..69e8cf6 100644 --- a/example/integration_test/device_integration_test.dart +++ b/example/integration_test/device_integration_test.dart @@ -3272,4 +3272,163 @@ void main() { }, ); }); + + // ── v1.5.0: iOS Live Activity progress filter ─────────────────────────────── + // + // NativeWorkManager.iosLiveActivity.onProgress(taskId:) is a taskId-scoped + // filter over the same progress EventChannel NativeWorkManager.progress uses. + // These run on iOS only: on other platforms the API contractually returns an + // already-closed stream (asserted in the last test here, and on the host in + // test/unit/ios_live_activity_bridge_test.dart). + group('v1_5_0 – iOS Live Activity progress filter', () { + testWidgets( + 'v1_5_0: iosLiveActivity.onProgress(taskId:) receives only that task', + (tester) async { + if (!Platform.isIOS) { + markTestSkipped( + 'iOS-only: onProgress returns an empty stream ' + 'on ${Platform.operatingSystem}', + ); + return; + } + + final targetId = _id('la_target'); + final otherId = _id('la_other'); + + final targetProgress = []; + final leakedTaskIds = {}; + + final sub = NativeWorkManager.iosLiveActivity + .onProgress(taskId: targetId) + .listen((p) { + targetProgress.add(p.progress); + if (p.taskId != targetId) leakedTaskIds.add(p.taskId); + }); + + final targetFuture = _waitEvent( + targetId, + timeout: const Duration(seconds: 60), + ); + final otherFuture = _waitEvent( + otherId, + timeout: const Duration(seconds: 60), + ); + + // Two concurrent downloads: only `targetId` may reach the subscription. + await NativeWorkManager.enqueue( + taskId: targetId, + trigger: const TaskTrigger.oneTime(), + worker: HttpDownloadWorker( + url: 'https://jsonplaceholder.typicode.com/posts', + savePath: '${tmpDir.path}/la_target.json', + ), + constraints: const Constraints(requiresNetwork: true), + ); + await NativeWorkManager.enqueue( + taskId: otherId, + trigger: const TaskTrigger.oneTime(), + worker: HttpDownloadWorker( + url: 'https://jsonplaceholder.typicode.com/comments', + savePath: '${tmpDir.path}/la_other.json', + ), + constraints: const Constraints(requiresNetwork: true), + ); + + final targetEvent = await targetFuture; + await otherFuture; + await sub.cancel(); + + expect( + targetEvent?.success, + isTrue, + reason: 'target download must succeed', + ); + expect( + leakedTaskIds, + isEmpty, + reason: + 'v1_5_0: onProgress(taskId: $targetId) must not deliver ' + 'progress for other tasks — leaked: $leakedTaskIds', + ); + for (final v in targetProgress) { + expect(v, inInclusiveRange(0, 100)); + } + }, + ); + + testWidgets( + 'v1_5_0: iosLiveActivity.onProgress mirrors NativeWorkManager.progress ' + 'for the same task', + (tester) async { + if (!Platform.isIOS) { + markTestSkipped('iOS-only'); + return; + } + + final id = _id('la_mirror'); + final viaBridge = []; + final viaGlobal = []; + + final bridgeSub = NativeWorkManager.iosLiveActivity + .onProgress(taskId: id) + .listen((p) => viaBridge.add(p.progress)); + final globalSub = NativeWorkManager.progress.listen((p) { + if (p.taskId == id) viaGlobal.add(p.progress); + }); + + final future = _waitEvent(id, timeout: const Duration(seconds: 60)); + + await NativeWorkManager.enqueue( + taskId: id, + trigger: const TaskTrigger.oneTime(), + worker: HttpDownloadWorker( + url: 'https://jsonplaceholder.typicode.com/photos', + savePath: '${tmpDir.path}/la_mirror.json', + ), + constraints: const Constraints(requiresNetwork: true), + ); + + final event = await future; + await bridgeSub.cancel(); + await globalSub.cancel(); + + expect(event?.success, isTrue); + expect( + viaBridge, + equals(viaGlobal), + reason: + 'v1_5_0: the filtered stream must carry exactly the same ' + 'progress events the global stream carries for this task — ' + 'a dropped or duplicated event means the filter is rewriting ' + 'the stream rather than narrowing it', + ); + }, + ); + + testWidgets( + 'v1_5_0: onProgress returns a closed stream on non-iOS platforms', + (tester) async { + if (Platform.isIOS) { + markTestSkipped('non-iOS contract check'); + return; + } + + var done = false; + final received = []; + final sub = NativeWorkManager.iosLiveActivity + .onProgress(taskId: _id('la_android')) + .listen(received.add, onDone: () => done = true); + + await Future.delayed(const Duration(milliseconds: 200)); + await sub.cancel(); + + expect( + done, + isTrue, + reason: 'v1_5_0: non-iOS onProgress must close immediately', + ); + expect(received, isEmpty); + }, + ); + }); } diff --git a/example/integration_test/initialization_test.dart b/example/integration_test/initialization_test.dart index ef999a3..40d8db5 100644 --- a/example/integration_test/initialization_test.dart +++ b/example/integration_test/initialization_test.dart @@ -187,6 +187,7 @@ void main() { callbackId: 'echo', input: {}, // no 'key' → callback returns false ), + constraints: const Constraints(maxRetries: 0), trigger: const TaskTrigger.oneTime(), ); diff --git a/example/integration_test/stress_and_system_test.dart b/example/integration_test/stress_and_system_test.dart index b5a47e1..0df86ba 100644 --- a/example/integration_test/stress_and_system_test.dart +++ b/example/integration_test/stress_and_system_test.dart @@ -496,5 +496,58 @@ void main() { }, timeout: const Timeout(Duration(minutes: 4)), ); + + testWidgets( + 'v1.5.0 Stress: Concurrent batch burst with LiveActivity observer isolation', + (tester) async { + if (_isFlakyOnSimulator) { + print( + 'Skipping on simulator/emulator due to OS background scheduling constraints', + ); + return; + } + + final tracker = TaskEventTracker(); + tracker.start(); + + const burstSize = 10; + final requests = []; + final taskIds = []; + final futures = >[]; + + for (int i = 0; i < burstSize; i++) { + final id = _id('burst_$i'); + taskIds.add(id); + requests.add( + EnqueueRequest( + taskId: id, + trigger: const TaskTrigger.oneTime(), + worker: NativeWorker.httpRequest( + url: 'https://httpbin.org/get', + method: HttpMethod.get, + ), + constraints: const Constraints(requiresNetwork: true), + ), + ); + futures.add(tracker.waitFor(id)); + } + + final sw = Stopwatch()..start(); + final handlers = await NativeWorkManager.enqueueAll(requests); + expect(handlers, hasLength(burstSize)); + + final results = await Future.wait(futures); + sw.stop(); + tracker.stop(); + + print( + 'Burst $burstSize tasks completed in ${sw.elapsedMilliseconds}ms', + ); + for (final r in results) { + expect(r.success, isTrue); + } + }, + timeout: const Timeout(Duration(minutes: 4)), + ); }); } diff --git a/example/lib/pages/progress_tracking_demo_page.dart b/example/lib/pages/progress_tracking_demo_page.dart index 8526856..0d1df67 100644 --- a/example/lib/pages/progress_tracking_demo_page.dart +++ b/example/lib/pages/progress_tracking_demo_page.dart @@ -1,5 +1,9 @@ +import 'dart:async'; +import 'dart:io' show Platform; + import 'package:flutter/material.dart'; import 'package:native_workmanager/native_workmanager.dart'; +import 'package:path_provider/path_provider.dart'; class ProgressTrackingDemoPage extends StatefulWidget { const ProgressTrackingDemoPage({super.key}); @@ -13,16 +17,31 @@ class _ProgressTrackingDemoPageState extends State { TaskHandler? _handler; bool _isDownloading = false; + // v1.5.0: a taskId-scoped view of the same progress stream, the shape a real + // Live Activity would subscribe to. iOS-only by contract — on other platforms + // onProgress() returns an already-closed stream. + StreamSubscription? _liveActivitySub; + int? _liveActivityProgress; + + @override + void dispose() { + _liveActivitySub?.cancel(); + super.dispose(); + } + Future _startDownload() async { setState(() => _isDownloading = true); try { + final dir = await getApplicationDocumentsDirectory(); + final savePath = '${dir.path}/demo_file.bin'; + // 1. Enqueue and get the handler final handler = await NativeWorkManager.enqueue( taskId: 'demo-download-${DateTime.now().millisecondsSinceEpoch}', worker: NativeWorker.httpDownload( url: 'https://httpbin.org/bytes/1024000', // 1MB random bytes - savePath: 'demo_file.bin', + savePath: savePath, ), ); @@ -32,6 +51,16 @@ class _ProgressTrackingDemoPageState extends State { // TaskProgressCard subscribes to handler.progress internally; // no manual listener needed here. + // 2b. v1.5.0 — the Live Activity route: one task's slice of the progress + // stream. This is what you would forward to ActivityKit. + await _liveActivitySub?.cancel(); + _liveActivityProgress = null; + _liveActivitySub = NativeWorkManager.iosLiveActivity + .onProgress(taskId: handler.taskId) + .listen((p) { + if (mounted) setState(() => _liveActivityProgress = p.progress); + }); + // 3. Wait for final result final result = await handler.result; @@ -104,7 +133,47 @@ class _ProgressTrackingDemoPageState extends State { ), padding: const EdgeInsets.all(20), ), - const SizedBox(height: 24), + const SizedBox(height: 16), + Card( + color: theme.colorScheme.surfaceContainerHighest.withValues( + alpha: 0.5, + ), + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Row( + children: [ + const Icon(Icons.apple, size: 28, color: Colors.grey), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'iOS Live Activity & Dynamic Island (v1.5.0)', + style: theme.textTheme.labelLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 4), + Text( + NativeWorkManager.iosLiveActivity.isSupported + ? 'Subscribed via NativeWorkManager.iosLiveActivity' + '.onProgress(taskId: …) — forward this to ' + 'ActivityKit in your own Swift code.' + '${_liveActivityProgress != null ? '\nLast value: $_liveActivityProgress%' : ''}' + : 'iOS-only. On ${Platform.operatingSystem} ' + 'onProgress() returns an empty stream — use ' + 'NativeWorkManager.progress instead.', + style: theme.textTheme.bodySmall, + ), + ], + ), + ), + ], + ), + ), + ), + const SizedBox(height: 16), Text( 'The widget above is the new TaskProgressCard which is built-in to the library. ' 'It handles stream subscription and formatting automatically.', diff --git a/example/pubspec.lock b/example/pubspec.lock index 0e5f1da..58ea773 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -261,7 +261,7 @@ packages: path: ".." relative: true source: path - version: "1.4.5" + version: "1.5.0" objective_c: dependency: transitive description: diff --git a/example/pubspec.yaml b/example/pubspec.yaml index 20494ef..e34028e 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -1,6 +1,6 @@ name: native_workmanager_example description: "Demonstrates how to use the native_workmanager plugin." -version: 1.3.0+1 +version: 1.5.0+1 # The following line prevents the package from being accidentally published to # pub.dev using `flutter pub publish`. This is preferred for private packages. publish_to: 'none' # Remove this line if you wish to publish to pub.dev diff --git a/extension/devtools/pubspec.yaml b/extension/devtools/pubspec.yaml index 09984ed..11d5300 100644 --- a/extension/devtools/pubspec.yaml +++ b/extension/devtools/pubspec.yaml @@ -1,7 +1,7 @@ name: native_workmanager_devtools_extension description: DevTools extension for native_workmanager. publish_to: 'none' -version: 1.3.0 +version: 1.5.0 environment: sdk: '>=3.2.0 <4.0.0' diff --git a/ios/Frameworks/KMPWorkManager.xcframework/Info.plist b/ios/Frameworks/KMPWorkManager.xcframework/Info.plist index 783a65f..0f2d9d0 100644 --- a/ios/Frameworks/KMPWorkManager.xcframework/Info.plist +++ b/ios/Frameworks/KMPWorkManager.xcframework/Info.plist @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:0ae8184004b57929a8d56438755677f2d60496f62092ea4de02dcbdabd70e11d +oid sha256:2d321909b9fbb755be68e935845b23bfccc49183bd87b03b7748ea420b512611 size 1226 diff --git a/ios/Frameworks/KMPWorkManager.xcframework/ios-arm64/KMPWorkManager.framework/Headers/KMPWorkManager.h b/ios/Frameworks/KMPWorkManager.xcframework/ios-arm64/KMPWorkManager.framework/Headers/KMPWorkManager.h index 2147ce9..73a08bc 100644 --- a/ios/Frameworks/KMPWorkManager.xcframework/ios-arm64/KMPWorkManager.framework/Headers/KMPWorkManager.h +++ b/ios/Frameworks/KMPWorkManager.xcframework/ios-arm64/KMPWorkManager.framework/Headers/KMPWorkManager.h @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:3da4384a4558a41cbd8a2146f57e3a830a68874bbe0ba06ad774786ceb5d33f1 -size 298879 +oid sha256:08b952dc986d4f9343b7535d34df3f7d2c85baa1937cb42d561c97e65ebcbba1 +size 267320 diff --git a/ios/Frameworks/KMPWorkManager.xcframework/ios-arm64/KMPWorkManager.framework/KMPWorkManager b/ios/Frameworks/KMPWorkManager.xcframework/ios-arm64/KMPWorkManager.framework/KMPWorkManager index 38c1ae1..d113267 100644 --- a/ios/Frameworks/KMPWorkManager.xcframework/ios-arm64/KMPWorkManager.framework/KMPWorkManager +++ b/ios/Frameworks/KMPWorkManager.xcframework/ios-arm64/KMPWorkManager.framework/KMPWorkManager @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9d204d309d38d0d7b1dcbf75a6f35b4e753abd717b3a464f2b79a862080a1500 -size 18329448 +oid sha256:9a169f8ebdd6eec06d93cf187171d759820aed2eac37e410b5b690fb51d7cf14 +size 17395416 diff --git a/ios/Frameworks/KMPWorkManager.xcframework/ios-arm64_x86_64-simulator/KMPWorkManager.framework/Headers/KMPWorkManager.h b/ios/Frameworks/KMPWorkManager.xcframework/ios-arm64_x86_64-simulator/KMPWorkManager.framework/Headers/KMPWorkManager.h index 2147ce9..73a08bc 100644 --- a/ios/Frameworks/KMPWorkManager.xcframework/ios-arm64_x86_64-simulator/KMPWorkManager.framework/Headers/KMPWorkManager.h +++ b/ios/Frameworks/KMPWorkManager.xcframework/ios-arm64_x86_64-simulator/KMPWorkManager.framework/Headers/KMPWorkManager.h @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:3da4384a4558a41cbd8a2146f57e3a830a68874bbe0ba06ad774786ceb5d33f1 -size 298879 +oid sha256:08b952dc986d4f9343b7535d34df3f7d2c85baa1937cb42d561c97e65ebcbba1 +size 267320 diff --git a/ios/Frameworks/KMPWorkManager.xcframework/ios-arm64_x86_64-simulator/KMPWorkManager.framework/KMPWorkManager b/ios/Frameworks/KMPWorkManager.xcframework/ios-arm64_x86_64-simulator/KMPWorkManager.framework/KMPWorkManager index 0bb4e85..a886a64 100644 --- a/ios/Frameworks/KMPWorkManager.xcframework/ios-arm64_x86_64-simulator/KMPWorkManager.framework/KMPWorkManager +++ b/ios/Frameworks/KMPWorkManager.xcframework/ios-arm64_x86_64-simulator/KMPWorkManager.framework/KMPWorkManager @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e98470658ea136afa53aad016ea63206929281b9bed3b5d69e7cb491b2a79189 -size 34973680 +oid sha256:43776a23463471b030c583b264decc2f95109817d0e24507aa195059c2ecdeb3 +size 33202368 diff --git a/ios/native_workmanager.podspec b/ios/native_workmanager.podspec index 0298570..bc8ee01 100644 --- a/ios/native_workmanager.podspec +++ b/ios/native_workmanager.podspec @@ -4,7 +4,7 @@ # Pod::Spec.new do |s| s.name = 'native_workmanager' - s.version = '1.4.5' + s.version = '1.5.0' s.summary = 'Background task manager for Flutter using platform-native APIs.' s.description = <<-DESC Native WorkManager is a Flutter plugin that provides native background task scheduling @@ -41,22 +41,19 @@ Features: } s.swift_version = '5.0' - # KMP WorkManager Framework (kmpworkmanager v3.2.0) + # KMP WorkManager Framework (kmpworkmanager v3.3.1) # Downloaded from GitHub Releases to stay under the pub.dev 100 MB package limit. - # RELEASE STEP: attach the rebuilt 3.2.0 KMPWorkManager.xcframework.zip to the v1.4.5 - # release (the framework changed 3.1.0 -> 3.2.0 — upstream fix for Play Store FGS - # permission rejection (#64) + iOS FileCompressionWorker real-ZIP rewrite via - # platform.zlib, replacing the previous uncompressed-copy stub — so the asset must be - # re-uploaded; earlier releases reused the v1.4.0 asset). The git-tracked copy under - # ios/Frameworks/ is already 3.2.0 for local/CI builds; this download only fires for - # pub.dev installs where .pubignore strips ios/Frameworks/. + # RELEASE STEP: attach the rebuilt 3.3.1 KMPWorkManager.xcframework.zip to the v1.5.0 + # release. The git-tracked copy under ios/Frameworks/ is already 3.3.1 for local/CI + # builds; this download only fires for pub.dev installs where .pubignore strips + # ios/Frameworks/. s.prepare_command = <<-CMD set -e if [ ! -d "Frameworks/KMPWorkManager.xcframework" ]; then - echo "Downloading KMPWorkManager.xcframework v3.2.0..." + echo "Downloading KMPWorkManager.xcframework v3.3.1..." mkdir -p Frameworks curl -L --retry 3 -o /tmp/KMPWorkManager.xcframework.zip \ - "https://github.com/brewkits/native_workmanager/releases/download/v1.4.5/KMPWorkManager.xcframework.zip" + "https://github.com/brewkits/native_workmanager/releases/download/v1.5.0/KMPWorkManager.xcframework.zip" rm -rf /tmp/kmpwm_extract unzip -o /tmp/KMPWorkManager.xcframework.zip -d /tmp/kmpwm_extract # Release zip may be flat or wrapped in a Frameworks/ dir - handle both. diff --git a/ios/native_workmanager/Package.swift b/ios/native_workmanager/Package.swift index 124bf13..f2f8644 100644 --- a/ios/native_workmanager/Package.swift +++ b/ios/native_workmanager/Package.swift @@ -23,7 +23,7 @@ let package = Package( // No third-party dependencies. Uses Apple Archive for ZIP operations. ], targets: [ - // KMPWorkManager (kmpworkmanager v3.2.0) as a REMOTE binary target. + // KMPWorkManager (kmpworkmanager v3.3.1) as a REMOTE binary target. // // Must be remote, not a local `path:`, for Swift Package Manager: on a // pub.dev install `.pubignore` strips `ios/Frameworks/`, and SPM has no @@ -42,15 +42,12 @@ let package = Package( // release asset: it invalidates this checksum. Keep this URL and the // podspec's `prepare_command` URL pointing at the same asset. // - // RELEASE STEP (bumped 3.1.0 -> 3.2.0, upstream fix for Play Store FGS - // permission rejection + iOS FileCompressionWorker real-ZIP rewrite): - // attach the rebuilt KMPWorkManager.xcframework.zip to the v1.4.5 release — - // the framework changed, so a fresh asset must be uploaded (earlier releases - // reused the v1.4.0 asset since the framework was unchanged 1.4.0..1.4.4). + // RELEASE STEP (bumped to 3.3.1): + // attach the rebuilt KMPWorkManager.xcframework.zip to the v1.5.0 release. .binaryTarget( name: "KMPWorkManager", - url: "https://github.com/brewkits/native_workmanager/releases/download/v1.4.5/KMPWorkManager.xcframework.zip", - checksum: "17931c8d3bdc41823b8a54a2e3774ab9eeca527140f628e9bd5701aeb8f4a3c4" + url: "https://github.com/brewkits/native_workmanager/releases/download/v1.5.0/KMPWorkManager.xcframework.zip", + checksum: "c66d6e07a686cc78439e05f93ff5940a6f942d8a35d37459e4cd0b9224f7115a" ), // Issue #36: ObjC target that registers BGTask launch handlers in +load, // before the app finishes launching. Required because on the Flutter 3.38+ diff --git a/ios/native_workmanager/Sources/native_workmanager/KMPBridge.swift b/ios/native_workmanager/Sources/native_workmanager/KMPBridge.swift index 73b2d3a..3ffe69a 100644 --- a/ios/native_workmanager/Sources/native_workmanager/KMPBridge.swift +++ b/ios/native_workmanager/Sources/native_workmanager/KMPBridge.swift @@ -40,7 +40,7 @@ public class KMPBridge { ) isInitialized = true - NativeLogger.d("KMPBridge: Initialized with NativeTaskScheduler from kmpworkmanager v3.2.0") + NativeLogger.d("KMPBridge: Initialized with NativeTaskScheduler from kmpworkmanager v3.3.1") } public func reinitialize(diskSpaceBufferMB: Int) { diff --git a/ios/native_workmanager/Sources/native_workmanager/engine/FlutterEngineManager.swift b/ios/native_workmanager/Sources/native_workmanager/engine/FlutterEngineManager.swift index afb1b71..81a2c19 100644 --- a/ios/native_workmanager/Sources/native_workmanager/engine/FlutterEngineManager.swift +++ b/ios/native_workmanager/Sources/native_workmanager/engine/FlutterEngineManager.swift @@ -409,24 +409,47 @@ class FlutterEngineManager { throw FlutterEngineError.engineNotInitialized } - return try await withCheckedThrowingContinuation { continuation in - DispatchQueue.main.async { - let args: [String: Any?] = [ - "callbackHandle": callbackHandle, - "input": input, - "timeoutMs": timeoutMs - ] - channel.invokeMethod("executeCallback", arguments: args) { result in - if let error = result as? FlutterError { - NativeLogger.e("FlutterEngineManager: Callback error: \(error.message ?? "unknown")") - continuation.resume(returning: false) - } else if let success = result as? Bool { - continuation.resume(returning: success) - } else { - continuation.resume(returning: true) + // The continuation must be resumed exactly once, and two paths race to + // do it: the Flutter method-channel reply, and cancellation. + // + // _executeDartCallbackInternal runs this inside a withThrowingTaskGroup + // alongside a Task.sleep timeout. When the timeout wins, the group + // throws and cancels this child — but a bare withCheckedThrowingContinuation + // cannot observe cancellation, and in exactly that scenario (hung Dart + // isolate) the channel reply never arrives. The continuation is then + // never resumed: Swift logs "SWIFT TASK CONTINUATION MISUSE: continuation + // was leaked" and this child task stays suspended forever, retaining the + // channel and its captured context. + // + // ContinuationBox makes the resume idempotent and order-independent, and + // withTaskCancellationHandler gives cancellation a way to settle it. + let box = _ContinuationBox() + + return try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + // If cancellation already fired, the box resumes immediately. + guard box.install(continuation) else { return } + + DispatchQueue.main.async { + let args: [String: Any?] = [ + "callbackHandle": callbackHandle, + "input": input, + "timeoutMs": timeoutMs + ] + channel.invokeMethod("executeCallback", arguments: args) { result in + if let error = result as? FlutterError { + NativeLogger.e("FlutterEngineManager: Callback error: \(error.message ?? "unknown")") + box.finish(.success(false)) + } else if let success = result as? Bool { + box.finish(.success(success)) + } else { + box.finish(.success(true)) + } } } } + } onCancel: { + box.finish(.failure(CancellationError())) } } @@ -522,3 +545,49 @@ enum FlutterEngineError: LocalizedError { } } } + +/// Single-resume guard for a `CheckedContinuation` that can be settled either by +/// an asynchronous callback or by task cancellation, in either order. +/// +/// `finish` is idempotent: the first caller wins and every later call is a no-op, +/// so the continuation is resumed exactly once. If cancellation settles the box +/// before the continuation is installed, the result is held in `pending` and +/// delivered by `install`. +private final class _ContinuationBox: @unchecked Sendable { + private let lock = NSLock() + private var continuation: CheckedContinuation? + private var pending: Result? + private var done = false + + /// Returns `false` when the box was already settled — the caller must not + /// use the continuation further, it has been resumed here. + func install(_ continuation: CheckedContinuation) -> Bool { + lock.lock() + if done { + let result = pending ?? .failure(CancellationError()) + pending = nil + lock.unlock() + continuation.resume(with: result) + return false + } + self.continuation = continuation + lock.unlock() + return true + } + + func finish(_ result: Result) { + lock.lock() + if done { + lock.unlock() + return + } + done = true + let continuation = self.continuation + self.continuation = nil + if continuation == nil { + pending = result + } + lock.unlock() + continuation?.resume(with: result) + } +} diff --git a/lib/native_workmanager.dart b/lib/native_workmanager.dart index 248adbe..6e86e5a 100644 --- a/lib/native_workmanager.dart +++ b/lib/native_workmanager.dart @@ -39,6 +39,7 @@ export 'src/foreground_notification_config.dart'; export 'src/task_id.dart'; export 'src/enqueue_request.dart'; export 'src/events.dart'; +export 'src/ios_live_activity_bridge.dart'; export 'src/native_work_manager.dart'; export 'src/observability.dart'; export 'src/offline_queue.dart'; diff --git a/lib/src/ios_live_activity_bridge.dart b/lib/src/ios_live_activity_bridge.dart new file mode 100644 index 0000000..d9c934b --- /dev/null +++ b/lib/src/ios_live_activity_bridge.dart @@ -0,0 +1,80 @@ +import 'dart:async'; +import 'package:flutter/foundation.dart'; +import 'events.dart'; +import 'platform_interface.dart'; + +/// Dart-side helper for feeding background task progress into an iOS +/// Live Activity / Dynamic Island. +/// +/// ### What this actually is +/// +/// This is a thin convenience filter over the plugin's existing progress +/// EventChannel — the same stream [NativeWorkManager.progress] exposes — scoped +/// to a single `taskId` so a Live Activity can subscribe to just the task it +/// renders. It does **not** talk to the KMP `IosLiveActivityBridge` in +/// `KMPWorkManager.xcframework`, and it does **not** call ActivityKit: starting, +/// updating and ending the `Activity` stays your app's job, because +/// the `ActivityAttributes` type lives in your target, not in this plugin. +/// +/// Use this when the Flutter side owns the progress and pushes it to a Live +/// Activity through your own platform channel or an app-side Swift helper. +/// +/// ### Dart usage +/// ```dart +/// NativeWorkManager.iosLiveActivity +/// .onProgress(taskId: 'download_video_1') +/// .listen((progress) { +/// // Forward to your own ActivityKit code. +/// print('Progress: ${progress.progress}%'); +/// }); +/// ``` +/// +/// ### Pure-Swift alternative +/// +/// If the progress never needs to reach Dart, skip this class entirely and +/// observe the KMP bridge directly from your iOS target — it runs even when no +/// Flutter engine is attached, which is the better fit for a killed-app +/// background download: +/// ```swift +/// import KMPWorkManager +/// +/// // Kotlin/Native exposes the singleton through the Companion object, so it is +/// // `.companion.shared` — a bare `IosLiveActivityBridge.shared` does not compile. +/// let bridge = IosLiveActivityBridge.companion.shared +/// +/// bridge.startObserving(taskId: "download_video_1") { progress in +/// // Update Activity(contentState: ...) +/// } +/// // Later: +/// bridge.stopObserving(taskId: "download_video_1") +/// ``` +class IosLiveActivityBridge { + /// Const constructor for [IosLiveActivityBridge]. + const IosLiveActivityBridge(); + + /// Whether Live Activities are available on the current platform (iOS only). + /// + /// When this is `false`, [onProgress] returns an already-closed stream. + bool get isSupported => defaultTargetPlatform == TargetPlatform.iOS; + + /// Returns a stream of [TaskProgress] updates for the specified [taskId]. + /// + /// If [taskId] is omitted or null, returns all background task progress + /// updates — identical to [NativeWorkManager.progress]. + /// + /// **On non-iOS platforms this returns an empty, already-closed stream** — + /// listeners get `onDone` immediately and never receive an event. This class + /// is an iOS Live Activity helper; for cross-platform progress use + /// [NativeWorkManager.progress] (or [TaskHandler.progress]) instead, which + /// works on every platform. + Stream onProgress({String? taskId}) { + if (!isSupported) { + return const Stream.empty(); + } + final stream = NativeWorkManagerPlatform.instance.progress; + if (taskId == null) { + return stream; + } + return stream.where((p) => p.taskId == taskId); + } +} diff --git a/lib/src/native_work_manager.dart b/lib/src/native_work_manager.dart index 977f058..c21f391 100644 --- a/lib/src/native_work_manager.dart +++ b/lib/src/native_work_manager.dart @@ -19,6 +19,7 @@ import 'task_graph.dart'; import 'task_handler.dart'; import 'task_trigger.dart'; +import 'ios_live_activity_bridge.dart'; import 'worker.dart'; /// Main entry point for scheduling native background tasks. @@ -2034,6 +2035,14 @@ class NativeWorkManager { return NativeWorkManagerPlatform.instance.progress; } + /// Dart-side helper for feeding task progress into an iOS Live Activity / + /// Dynamic Island. + /// + /// A `taskId`-scoped filter over [progress]; it does not call ActivityKit and + /// does not wrap the KMP `IosLiveActivityBridge`. Returns an empty stream on + /// non-iOS platforms — see [IosLiveActivityBridge] for the full contract. + static const IosLiveActivityBridge iosLiveActivity = IosLiveActivityBridge(); + // ═══════════════════════════════════════════════════════════════════════════ // DART WORKER REGISTRATION // ═══════════════════════════════════════════════════════════════════════════ diff --git a/lib/src/offline_queue.dart b/lib/src/offline_queue.dart index c89104f..cdba038 100644 --- a/lib/src/offline_queue.dart +++ b/lib/src/offline_queue.dart @@ -167,8 +167,9 @@ class QueueEntry { /// [OfflineRetryPolicy.maxRetries] times. /// - After all retries are exhausted the task is moved to a **dead-letter** /// state (accessible via [deadLetterCount]). -/// - Calling [enqueue] when the queue is full (> [maxSize]) throws a -/// [StateError]. +/// - Calling [enqueue] when the queue is already at [maxSize] **drops the entry +/// silently** — it returns normally without enqueuing and without throwing. +/// Check [pendingCount] against [maxSize] first if you need to know. /// /// ## Limitations /// @@ -191,7 +192,10 @@ class OfflineQueue { /// Unique queue identifier. final String id; - /// Maximum number of pending entries. [enqueue] throws [StateError] if full. + /// Maximum number of pending entries. + /// + /// [enqueue] drops the entry silently once this many are pending — it does + /// not throw. Compare [pendingCount] against this first if you need to know. final int maxSize; /// Default retry policy for entries that do not specify their own. @@ -326,17 +330,34 @@ class OfflineQueue { // enqueue itself failed — treat as task failure } - // Task failed — retry or dead-letter + // Task failed — retry or dead-letter. + // + // Re-resolve this slot's position by identity. `cancel()` is synchronous + // and mutates `_pending` directly, so it can run while we were awaiting + // `_awaitEvent` above (up to an hour) or the retry backoff. Index 0 is + // therefore not guaranteed to still be this slot — or to exist at all: + // - queue emptied by cancel → `_pending[0] = …` threw RangeError + // - head replaced by another → that entry was silently overwritten + // The success path above already used identity (`_pending.remove(slot)`); + // these two branches now match it. + final index = _pending.indexOf(slot); + if (index == -1) { + // Cancelled while in flight — do not retry it and do not dead-letter it. + _processing = false; + _scheduleNext(); + return; + } + if (slot.attempt < policy.maxRetries) { // Update attempt counter in-place - _pending[0] = _QueueSlot( + _pending[index] = _QueueSlot( entry: entry, policy: policy, attempt: slot.attempt + 1, ); } else { // Exhausted retries → dead-letter - _pending.removeAt(0); + _pending.removeAt(index); _deadLetter.add(slot); } diff --git a/lib/src/task_graph.dart b/lib/src/task_graph.dart index 4633af8..b072e28 100644 --- a/lib/src/task_graph.dart +++ b/lib/src/task_graph.dart @@ -255,12 +255,14 @@ class GraphResult { /// Exposes a [result] future that resolves when the entire graph finishes /// (all nodes complete or any node fails). class GraphExecution { - GraphExecution._(this.graphId, this._result); + /// Creates a [GraphExecution] handle for the given [graphId] and [result] future. + const GraphExecution(this.graphId, Future result) + : _result = result; - /// Internal constructor for testing. - @visibleForTesting + /// Internal constructor for backward compatibility. + @Deprecated('Use GraphExecution(graphId, result) instead') factory GraphExecution.internal(String graphId, Future result) => - GraphExecution._(graphId, result); + GraphExecution(graphId, result); final String graphId; final Future _result; @@ -358,18 +360,36 @@ class _GraphExecutor { // All dependencies satisfied? final ready = node.dependsOn.every(_completed.contains); if (ready) { - _scheduleNode(node); + // Intentionally not awaited: this runs from a synchronous event + // callback and ready nodes must be scheduled concurrently, not + // serialised. _scheduleNode handles its own failures internally and + // reports them through the graph's completer. + unawaited(_scheduleNode(node)); } } } + /// Delay applied to each graph node on iOS. + /// + /// **Platform workaround, not domain logic.** BGTaskScheduler drops or defers + /// submissions made back-to-back within the same run loop turn, so each node + /// is nudged onto the next second. It lives here rather than in the iOS + /// bridge only because the bridge schedules through KMP, which has no hook + /// for a per-submission stagger; pushing it down is tracked in ROADMAP. + /// + /// Android has no such constraint and pays no delay. + static const Duration _iosNodeSubmissionStagger = Duration(seconds: 1); + + /// Trigger for a graph node, carrying [_iosNodeSubmissionStagger] on iOS. + static TaskTrigger _nodeTrigger() => + defaultTargetPlatform == TargetPlatform.iOS + ? TaskTrigger.oneTime(_iosNodeSubmissionStagger) + : TaskTrigger.oneTime(); + Future _scheduleNode(TaskNode node) async { _inFlight.add(node.id); try { - // iOS BGTaskScheduler needs a short delay to reliably queue/launch. - final trigger = defaultTargetPlatform == TargetPlatform.iOS - ? TaskTrigger.oneTime(const Duration(seconds: 1)) - : TaskTrigger.oneTime(); + final trigger = _nodeTrigger(); await NativeWorkManager.enqueue( taskId: '${_graph.id}__${node.id}', @@ -449,5 +469,5 @@ Future enqueueTaskGraph(TaskGraph graph) async { final executor = _GraphExecutor(graph); final resultFuture = executor.execute(isAlreadyEnqueued: true); - return GraphExecution._(graph.id, resultFuture); + return GraphExecution(graph.id, resultFuture); } diff --git a/lib/src/testing/fake_work_manager.dart b/lib/src/testing/fake_work_manager.dart index ff13822..cc081da 100644 --- a/lib/src/testing/fake_work_manager.dart +++ b/lib/src/testing/fake_work_manager.dart @@ -270,7 +270,7 @@ class FakeWorkManager implements IWorkManager { // In a fake, we don't actually run the DAG logic unless requested. // For now just return a handle that never completes automatically. - return GraphExecution.internal(graph.id, Completer().future); + return GraphExecution(graph.id, Completer().future); } @override diff --git a/native_workmanager_gen/CHANGELOG.md b/native_workmanager_gen/CHANGELOG.md index ef3f2dc..d57903b 100644 --- a/native_workmanager_gen/CHANGELOG.md +++ b/native_workmanager_gen/CHANGELOG.md @@ -4,6 +4,14 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). +## [1.5.0] - 2026-08-23 + +- Version bump synchronized with `native_workmanager` 1.5.0 (`taskId`-scoped progress filter for + iOS Live Activities, SwiftUI `@main` setup detection, kmpworkmanager 3.3.1 upgrade, Pub score + 160/160 fix). No codegen changes. + +--- + ## [1.4.5] - 2026-08-06 - Version bump synchronized with `native_workmanager` 1.4.5. No codegen changes — the diff --git a/native_workmanager_gen/pubspec.yaml b/native_workmanager_gen/pubspec.yaml index f9a11d0..1601084 100644 --- a/native_workmanager_gen/pubspec.yaml +++ b/native_workmanager_gen/pubspec.yaml @@ -1,5 +1,5 @@ name: native_workmanager_gen -version: 1.4.5 +version: 1.5.0 description: > Code generator for native_workmanager. Generates type-safe DartWorker callback IDs and worker registry from diff --git a/pubspec.yaml b/pubspec.yaml index bfd30f0..037cbf8 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: native_workmanager description: "Background task scheduling for Flutter — 25+ native workers (HTTP, image, crypto, file), task chains, zero Flutter Engine overhead." -version: 1.4.5 +version: 1.5.0 homepage: https://github.com/brewkits/native_workmanager repository: https://github.com/brewkits/native_workmanager issue_tracker: https://github.com/brewkits/native_workmanager/issues diff --git a/scripts/run_all_tests.sh b/scripts/run_all_tests.sh index 49996be..9348463 100755 --- a/scripts/run_all_tests.sh +++ b/scripts/run_all_tests.sh @@ -25,7 +25,7 @@ if [ $? -eq 0 ]; then echo -e "${GREEN}Security Tests Passed${NC}"; else echo -e # 4. Performance Tests echo -e "\n${BLUE}[4/6] Running Performance Tests...${NC}" -flutter test test/performance/scheduling_performance_test.dart --reporter expanded +flutter test test/performance/ --reporter expanded if [ $? -eq 0 ]; then echo -e "${GREEN}Performance Tests Passed${NC}"; else echo -e "${RED}Performance Tests Failed${NC}"; exit 1; fi # 5. Device Integration Tests (Requires connected device/emulator) diff --git a/scripts/test_podspec_extraction.sh b/scripts/test_podspec_extraction.sh index 2533273..6c62fda 100755 --- a/scripts/test_podspec_extraction.sh +++ b/scripts/test_podspec_extraction.sh @@ -1,39 +1,39 @@ #!/bin/bash set -e -echo "=== Kiểm tra Logic Giải Nén của Podspec ===" +echo "=== Verifying Podspec Extraction Logic ===" -# 1. Tạo môi trường giả lập framework +# 1. Create mock framework environment rm -rf /tmp/mock_fw /tmp/mock_fw_nested mkdir -p /tmp/mock_fw/KMPWorkManager.xcframework/Headers touch /tmp/mock_fw/KMPWorkManager.xcframework/Headers/Mock.h -# 2. Giả lập tạo Zip Phẳng (Flat Zip) +# 2. Simulate flat zip creation cd /tmp/mock_fw zip -rq /tmp/flat_release.zip KMPWorkManager.xcframework -# 3. Giả lập tạo Zip Lồng (Nested Zip - giống bản 1.3.0 trên Github) +# 3. Simulate nested zip creation (wrapped in Frameworks/) mkdir -p /tmp/mock_fw_nested/Frameworks cp -r /tmp/mock_fw/KMPWorkManager.xcframework /tmp/mock_fw_nested/Frameworks/ cd /tmp/mock_fw_nested zip -rq /tmp/nested_release.zip Frameworks/ -echo "[✓] Đã tạo thành công 2 file zip giả lập (Flat và Nested)." +echo "[✓] Successfully created 2 mock release zips (Flat and Nested)." echo "" -# 4. Hàm thực thi y hệt logic trong podspec +# 4. Extraction test function matching podspec prepare_command test_extraction() { local zip_path=$1 local test_name=$2 - echo "--- Đang chạy test: $test_name ---" + echo "--- Running test: $test_name ---" - # Dọn dẹp trước khi chạy + # Clean up test workspace rm -rf /tmp/test_workspace mkdir -p /tmp/test_workspace/Frameworks cd /tmp/test_workspace - # ---- ĐOẠN LOGIC SAO CHÉP TỪ PODSPEC BẮT ĐẦU ---- + # ---- LOGIC COPIED FROM PODSPEC PREPARE_COMMAND ---- rm -rf /tmp/kmpwm_extract unzip -oq "$zip_path" -d /tmp/kmpwm_extract # Release zip may be flat or wrapped in a Frameworks/ dir - handle both. @@ -41,21 +41,21 @@ test_extraction() { rm -rf Frameworks/KMPWorkManager.xcframework mv "$SRC" Frameworks/KMPWorkManager.xcframework rm -rf /tmp/kmpwm_extract - # ---- ĐOẠN LOGIC SAO CHÉP TỪ PODSPEC KẾT THÚC ---- + # ---- END LOGIC FROM PODSPEC ---- - # Kiểm tra kết quả + # Verify extraction structure if [ -d "Frameworks/KMPWorkManager.xcframework" ] && [ ! -d "Frameworks/Frameworks" ]; then - echo "[✓] THÀNH CÔNG: Kết quả trích xuất chuẩn xác ở 1 lớp Frameworks/KMPWorkManager.xcframework" + echo "[✓] SUCCESS: Correct single-layer extraction at Frameworks/KMPWorkManager.xcframework" else - echo "[x] THẤT BẠI: Cấu trúc thư mục bị sai." + echo "[x] FAILURE: Incorrect directory structure." ls -R Frameworks exit 1 fi echo "" } -# 5. Chạy test cho cả 2 trường hợp -test_extraction "/tmp/flat_release.zip" "FILE ZIP PHẲNG (Zip không bọc Frameworks)" -test_extraction "/tmp/nested_release.zip" "FILE ZIP LỒNG (Zip bọc sẵn Frameworks/ - Giống Github Release 1.3.0)" +# 5. Run test for both cases +test_extraction "/tmp/flat_release.zip" "FLAT ZIP FILE (Root-level XCFramework)" +test_extraction "/tmp/nested_release.zip" "NESTED ZIP FILE (Wrapped inside Frameworks/)" -echo "=== TẤT CẢ TEST ĐỀU PASSED! ===" +echo "=== ALL PODSPEC EXTRACTION TESTS PASSED! ===" diff --git a/test/performance/pipeline_and_concurrency_stress_test.dart b/test/performance/pipeline_and_concurrency_stress_test.dart new file mode 100644 index 0000000..e9d2e96 --- /dev/null +++ b/test/performance/pipeline_and_concurrency_stress_test.dart @@ -0,0 +1,320 @@ +import 'dart:async'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:native_workmanager/native_workmanager.dart'; +import 'package:native_workmanager/src/method_channel.dart'; +import 'package:native_workmanager/src/platform_interface.dart'; + +class _BenchmarkPlatform extends MethodChannelNativeWorkManager { + final eventsCtrl = StreamController.broadcast(); + final progressCtrl = StreamController.broadcast(); + + int totalEnqueued = 0; + + @override + Stream get events => eventsCtrl.stream; + + @override + Stream get progress => progressCtrl.stream; + + @override + Future initialize({ + int? callbackHandle, + bool debugMode = false, + int maxConcurrentTasks = 4, + int diskSpaceBufferMB = 20, + int cleanupAfterDays = 30, + bool enforceHttps = false, + bool blockPrivateIPs = false, + bool registerPlugins = false, + }) async {} + + @override + Future enqueue({ + required String taskId, + required TaskTrigger trigger, + required Worker worker, + Constraints constraints = const Constraints(), + ExistingTaskPolicy existingPolicy = ExistingTaskPolicy.replace, + String? tag, + }) async { + totalEnqueued++; + return ScheduleResult.accepted; + } + + @override + Future enqueueGraph(Map graphMap) async { + return graphMap['id'] as String? ?? 'graph'; + } + + @override + Future enqueueChain(Map chainData) async { + return ScheduleResult.accepted; + } + + Future dispose() async { + await eventsCtrl.close(); + await progressCtrl.close(); + } +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late _BenchmarkPlatform platform; + + setUp(() async { + platform = _BenchmarkPlatform(); + NativeWorkManagerPlatform.instance = platform; + await NativeWorkManager.initialize(); + }); + + tearDown(() async { + await platform.dispose(); + }); + + group('High-Throughput Batch Enqueue Performance', () { + test('enqueueAll 5,000 tasks throughput and latency benchmark', () async { + const taskCount = 5000; + final requests = List.generate( + taskCount, + (i) => EnqueueRequest( + taskId: 'bench_task_$i', + trigger: const TaskTrigger.oneTime(), + worker: NativeWorker.httpRequest( + url: 'https://api.example.com/item/$i', + headers: {'X-Batch-Id': 'batch_1', 'Authorization': 'Bearer test'}, + ), + constraints: const Constraints( + requiresNetwork: true, + requiresCharging: false, + ), + ), + ); + + final sw = Stopwatch()..start(); + final handlers = await NativeWorkManager.enqueueAll(requests); + sw.stop(); + + final elapsedMs = sw.elapsedMilliseconds; + final usPerTask = (sw.elapsedMicroseconds / taskCount); + + print('⚡ [Batch Enqueue] $taskCount tasks enqueued in ${elapsedMs}ms ' + '(${usPerTask.toStringAsFixed(2)} µs/task)'); + + expect(handlers, hasLength(taskCount)); + expect(platform.totalEnqueued, taskCount); + // Ensure high efficiency: < 0.5ms (500 µs) per task in Dart runtime + expect(usPerTask, lessThan(500.0)); + }); + }); + + group('Complex DAG Graph Topology & Validation Stress', () { + test('Build, validate, and serialize a 300-node diamond DAG', () async { + final graph = TaskGraph(id: 'stress_dag_300'); + final sw = Stopwatch()..start(); + + // Root layer (10 nodes) + for (int i = 0; i < 10; i++) { + graph.add(TaskNode( + id: 'root_$i', + worker: NativeWorker.httpDownload( + url: 'https://cdn.example.com/part_$i.bin', + savePath: '/tmp/part_$i.bin', + ), + )); + } + + // Middle layers (diamond dependencies) + for (int level = 1; level <= 28; level++) { + for (int i = 0; i < 10; i++) { + final prevLevel = level - 1; + final prevNode1 = prevLevel == 0 ? 'root_$i' : 'node_${prevLevel}_$i'; + final prevNode2 = prevLevel == 0 + ? 'root_${(i + 1) % 10}' + : 'node_${prevLevel}_${(i + 1) % 10}'; + + graph.add(TaskNode( + id: 'node_${level}_$i', + worker: NativeWorker.hashFile( + filePath: '/tmp/part_${level}_$i.bin', + algorithm: HashAlgorithm.sha256, + ), + dependsOn: [prevNode1, prevNode2], + )); + } + } + + // Sink layer (1 node depending on all 10 previous nodes) + graph.add(TaskNode( + id: 'sink_final', + worker: NativeWorker.httpUpload( + url: 'https://api.example.com/upload-summary', + filePath: '/tmp/summary.bin', + ), + dependsOn: List.generate(10, (i) => 'node_28_$i'), + )); + + // Validate topology and acyclic integrity + graph.validate(); + final map = graph.toMap(); + sw.stop(); + + print('⚡ [DAG Stress] 300-node diamond graph built, validated, and ' + 'serialized in ${sw.elapsedMilliseconds}ms'); + + expect(graph.nodes, hasLength(291)); + expect(map['nodes'], hasLength(291)); + expect(sw.elapsedMilliseconds, lessThan(200)); + }); + }); + + group('Deep Linear & Parallel Chain Builder Stress', () { + test('Build and serialize a 500-step task chain', () async { + final initial = TaskRequest( + id: 'chain_step_0', + worker: NativeWorker.httpRequest(url: 'https://example.com/0'), + ); + + final sw = Stopwatch()..start(); + var builder = NativeWorkManager.beginWith(initial); + + for (int i = 1; i < 500; i++) { + if (i % 5 == 0) { + // Parallel fork step + builder = builder.thenAll([ + TaskRequest( + id: 'chain_parallel_${i}_a', + worker: + NativeWorker.httpRequest(url: 'https://example.com/${i}a'), + ), + TaskRequest( + id: 'chain_parallel_${i}_b', + worker: + NativeWorker.httpRequest(url: 'https://example.com/${i}b'), + ), + ]); + } else { + // Sequential step + builder = builder.then(TaskRequest( + id: 'chain_step_$i', + worker: NativeWorker.httpRequest(url: 'https://example.com/$i'), + )); + } + } + + final result = await builder.enqueue(); + sw.stop(); + + print('⚡ [Chain Stress] 500-step hybrid chain built and enqueued in ' + '${sw.elapsedMilliseconds}ms'); + + expect(result, ScheduleResult.accepted); + expect(builder.steps, hasLength(500)); + expect(sw.elapsedMilliseconds, lessThan(150)); + }); + }); + + group('High-Frequency Stream Flooding & Event Filtering Stress', () { + test('Process 20,000 stream events with 10 concurrent subscribers', + () async { + const eventCount = 20000; + const subscriberCount = 10; + final receivedCounts = List.filled(subscriberCount, 0); + + final subs = >[]; + for (int s = 0; s < subscriberCount; s++) { + final subIndex = s; + subs.add(NativeWorkManager.events.listen((e) { + if (e.taskId.startsWith('flood_task_')) { + receivedCounts[subIndex]++; + } + })); + } + + final sw = Stopwatch()..start(); + for (int i = 0; i < eventCount; i++) { + platform.eventsCtrl.add(TaskEvent( + taskId: 'flood_task_$i', + success: i % 2 == 0, + isStarted: false, + timestamp: DateTime.now(), + resultData: {'index': i}, + )); + } + + await Future.delayed(const Duration(milliseconds: 150)); + sw.stop(); + + for (final sub in subs) { + await sub.cancel(); + } + + print( + '⚡ [Stream Flooding] Dispatched $eventCount events to $subscriberCount ' + 'subscribers (${eventCount * subscriberCount} deliveries) in ' + '${sw.elapsedMilliseconds}ms'); + + for (int s = 0; s < subscriberCount; s++) { + expect(receivedCounts[s], eventCount); + } + }); + }); + + group('Concurrent OfflineQueue Enqueue & Drain Stress', () { + test('1,000 concurrent callers enqueueing into OfflineQueue', () async { + final queue = OfflineQueue(id: 'stress_queue_1000', maxSize: 2000); + final sw = Stopwatch()..start(); + + final futures = >[]; + for (int i = 0; i < 1000; i++) { + futures.add(queue.enqueue(QueueEntry( + taskId: 'stress_item_$i', + worker: NativeWorker.httpRequest(url: 'https://example.com/$i'), + tag: 'batch_${i % 10}', + ))); + } + + await Future.wait(futures); + sw.stop(); + + print('⚡ [OfflineQueue Concurrency] 1,000 async concurrent enqueues ' + 'completed in ${sw.elapsedMilliseconds}ms'); + + expect(queue.pendingCount, 1000); + + // Cancel a whole tag concurrently + queue.cancel(tag: 'batch_0'); // 100 items + expect(queue.pendingCount, 900); + }); + }); + + group('Large Payload Worker Serialization Benchmark', () { + test('Serialize workers with 10,000 custom header pairs & large query maps', + () async { + final largeHeaders = {}; + for (int i = 0; i < 5000; i++) { + largeHeaders['X-Custom-Header-$i'] = + 'Value-Payload-$i-Random-Hash-Data'; + } + + final sw = Stopwatch()..start(); + final worker = NativeWorker.httpRequest( + url: 'https://api.enterprise.com/v2/bulk-data-sync', + method: HttpMethod.post, + headers: largeHeaders, + body: + '{"records": ${List.generate(1000, (i) => '{"id":$i,"active":true}')}}', + ); + + final map = worker.toMap(); + sw.stop(); + + print('⚡ [Payload Benchmark] 5,000-header + 1,000-record JSON worker ' + 'serialized in ${sw.elapsedMilliseconds}ms'); + + expect(map['headers'], hasLength(5000)); + expect(sw.elapsedMilliseconds, lessThan(100)); + }); + }); +} diff --git a/test/unit/cancellation_rethrow_invariant_test.dart b/test/unit/cancellation_rethrow_invariant_test.dart index f431633..98463c2 100644 --- a/test/unit/cancellation_rethrow_invariant_test.dart +++ b/test/unit/cancellation_rethrow_invariant_test.dart @@ -3,107 +3,246 @@ import 'dart:io'; import 'package:flutter_test/flutter_test.dart'; /// Architecture-invariant guard for the v1.4.1 fix (CancellationException -/// swallowed by generic exception handling in Android workers). +/// swallowed by generic exception handling in Android coroutine paths). /// -/// Kotlin's `CancellationException` **is-a** `Exception`, so a generic -/// `catch (e: Exception)` in a worker's `doWork()` swallows cooperative -/// cancellation and converts it into a normal `WorkerResult.Failure` — for +/// Kotlin's `CancellationException` **is-a** `Exception` +/// (`kotlinx.coroutines.CancellationException` → `java.util.concurrent. +/// CancellationException` → `IllegalStateException` → `RuntimeException` → +/// `Exception`), so a generic `catch (e: Exception)` around a suspension point +/// swallows cooperative cancellation and turns it into a normal failure — for /// `HttpDownloadWorker` that even carried `shouldRetry = true`, so a task the -/// user explicitly cancelled could reschedule itself. The fix is a -/// `catch (e: CancellationException) { throw e }` placed *before* the generic -/// catch in every worker whose vulnerable scope contains a real suspension -/// point. +/// user explicitly cancelled could reschedule itself. /// -/// This test fails if that rethrow is removed from any of the workers it was -/// added to — a fast, deterministic regression guard that runs in the standard -/// `flutter test` gate (the real worker unit tests are `@Ignore`d because they -/// need an Android runtime, so they cannot guard this). It reads the Kotlin -/// source directly; paths are relative to the package root where `flutter test` -/// runs. +/// ## Why this test was rewritten (v1.5.0) +/// +/// The first version matched the rethrow regex against the **whole file**. That +/// is too coarse: `HttpUploadWorker.kt` contains two suspend functions, and a +/// single rethrow in `doWork()` made the file pass while the sibling +/// `handleRawBodyUpload()` had no guard at all. The guard built to catch this +/// exact bug could not see it. +/// +/// This version parses each `suspend fun` body by brace depth and checks the +/// invariant **per function**. A function is either guarded, or listed in +/// [_exemptions] with a human-written reason. +/// +/// Suspension-point detection is deliberately **not** automated: a heuristic +/// that scans for `withContext`/`await` counts the function's own enclosing +/// `withContext` and produces false alarms. Whether a guarded region can +/// actually observe a cancellation is a judgement call, so it lives in an +/// exemption reason a reviewer can read and challenge. void main() { - const workersDir = - 'android/src/main/kotlin/dev/brewkits/native_workmanager/workers'; - - // The workers whose doWork()/setForeground scope wraps a genuine suspension - // point (network I/O awaited via child coroutines, delay(), setForeground()) - // inside a generic catch. Each MUST rethrow CancellationException first. - const workersRequiringRethrow = [ - 'DbCleanupWorker', - 'FileCompressionWorker', - 'FileDecompressionWorker', - 'FileSystemWorker', - 'ForegroundNativeWorker', - 'HttpDownloadWorker', - 'HttpRequestWorker', - 'HttpSyncWorker', - 'HttpUploadWorker', - 'ImageProcessWorker', - 'ParallelHttpDownloadWorker', + // Scoped to the paths where WorkManager/coroutine cancellation is real: the + // worker execution bodies and the Flutter engine host. MethodChannel handlers + // in NativeWorkmanagerPlugin+*.kt run on the platform thread servicing a call + // and are a different concern. + const scannedDirs = [ + 'android/src/main/kotlin/dev/brewkits/native_workmanager/workers', + 'android/src/main/kotlin/dev/brewkits/native_workmanager/engine', ]; - group('v1.4.1: CancellationException is rethrown before generic catch', () { - for (final worker in workersRequiringRethrow) { - test('$worker rethrows CancellationException', () { - final file = File('$workersDir/$worker.kt'); - expect( - file.existsSync(), - isTrue, - reason: 'Expected worker source at ${file.path}. If a worker was ' - 'renamed or removed, update this invariant list.', - ); + /// `File#function` → why a generic catch there cannot swallow a real + /// cancellation. Verified by reading the guarded region. + const exemptions = { + 'CryptoWorker.kt#doWork': + 'guarded regions are CPU-bound crypto inside the enclosing ' + 'withContext — no suspension point inside any try block', + 'PdfWorker.kt#doWork': + 'guarded regions are blocking PdfRenderer/file I/O — no suspension ' + 'point inside any try block', + 'MoveToSharedStorageWorker.kt#doWork': + 'guarded regions are blocking MediaStore/ContentResolver calls — no ' + 'suspension point inside any try block', + 'WebSocketWorker.kt#doWork': + 'uses try/finally around the OkHttp WebSocket listener; cancellation ' + 'is handled by the finally block, not converted to a result', + 'FlutterEngineManager.kt#ensureEngineInitialized': + 'guarded region is FlutterLoader/engine construction on the main ' + 'thread — blocking, no suspension point inside the try', + 'FlutterEngineManager.kt#dispose': + 'teardown path; swallowing here is deliberate so a failed dispose ' + 'cannot mask the original result', + 'ParallelHttpUploadWorker.kt#doWork': + 'the only generic catch is `catch (_: Exception)` around ' + 'JSONObject(input).optString("__taskId") — pure parsing. The HTTP ' + 'work has no outer generic catch, so cancellation propagates to ' + 'BaseKmpWorker; uploadSingleFile() is a non-suspend blocking fun', + 'ChainResultCapturingWorker.kt#doWork': + 'guarded region is blocking ChainStore/SQLite persistence of a ' + 'finished step — no suspension point inside the try', + 'ForegroundNativeWorker.kt#emitToBus': + 'best-effort telemetry emit; a failure here must not mask the task ' + 'result, and the caller doWork() carries its own rethrow', + 'ForegroundNativeWorker.kt#getForegroundInfo': + 'guarded region is Color.parseColor on a user-supplied hex string — ' + 'pure parsing, no suspension point', + }; - final source = file.readAsStringSync(); + final rethrowPattern = RegExp( + r'catch\s*\(\s*(\w+)\s*:\s*(kotlinx\.coroutines\.)?' + r'CancellationException\s*\)\s*\{\s*throw\s+\1', + ); + final genericCatchPattern = + RegExp(r'catch\s*\(\s*\w+\s*:\s*(java\.lang\.)?Exception\s*\)'); + + group('v1.4.1/v1.5.0: CancellationException rethrow, checked per function', + () { + late Map functionBodies; // 'File.kt#name' -> body source + + setUpAll(() { + functionBodies = {}; + for (final dir in scannedDirs) { + final d = Directory(dir); + expect(d.existsSync(), isTrue, reason: 'scanned dir missing: $dir'); + for (final f in d + .listSync(recursive: true) + .whereType() + .where((f) => f.path.endsWith('.kt'))) { + final name = f.uri.pathSegments.last; + final src = _strip(f.readAsStringSync()); + functionBodies.addAll(_suspendFunctionBodies(src, name)); + } + } + expect(functionBodies, isNotEmpty, + reason: 'parser found no suspend functions — it is broken'); + }); - // Must catch CancellationException... - final catchesCancellation = - RegExp(r'catch\s*\(\s*\w+\s*:\s*(kotlinx\.coroutines\.)?' - r'CancellationException\s*\)') - .hasMatch(source); - expect( - catchesCancellation, - isTrue, - reason: '$worker must catch CancellationException before its generic ' - '`catch (e: Exception)` (v1.4.1 fix). It is-a Exception and is ' - 'otherwise swallowed into a WorkerResult.Failure, discarding ' - 'cooperative cancellation.', - ); + test('every suspend fun with a generic catch rethrows or is exempt', () { + final offenders = []; - // ...and rethrow it (not swallow it). - final rethrows = RegExp( - r'catch\s*\(\s*(\w+)\s*:\s*(kotlinx\.coroutines\.)?' - r'CancellationException\s*\)\s*\{\s*throw\s+\1', - ).hasMatch(source); - expect( - rethrows, - isTrue, - reason: '$worker catches CancellationException but must rethrow it ' - '(`catch (e: CancellationException) { throw e }`), not handle it ' - 'like a normal failure.', - ); + functionBodies.forEach((key, body) { + if (!genericCatchPattern.hasMatch(body)) return; + if (rethrowPattern.hasMatch(body)) return; + if (exemptions.containsKey(key)) return; + offenders.add(key); }); - } - test( - 'the invariant list matches the workers directory (no new worker ' - 'silently skips the check)', () { - // Not every worker needs the rethrow — some have no local catch and - // correctly rely on BaseKmpWorker, and WebSocketWorker uses try/finally. - // This test just makes sure the directory is discoverable so the list - // above can be kept honest during review; it does not force every file - // into the list. - final dir = Directory(workersDir); - expect(dir.existsSync(), isTrue, reason: 'workers dir not found'); - final workerFiles = dir - .listSync() - .whereType() - .where((f) => f.path.endsWith('.kt')) - .toList(); expect( - workerFiles.length, - greaterThanOrEqualTo(workersRequiringRethrow.length), - reason: 'Fewer worker files than the invariant list expects — a worker ' - 'may have been removed; reconcile the list.', + offenders, + isEmpty, + reason: 'These suspend functions wrap a generic `catch (e: Exception)` ' + 'with no `catch (e: CancellationException) { throw e }` before it, ' + 'and are not exempted:\n' + '${offenders.map((o) => ' - $o').join('\n')}\n\n' + 'Either add the rethrow, or add an entry to `exemptions` in this ' + 'test explaining why the guarded region cannot observe a ' + 'cancellation.', ); }); + + test('the two HttpUploadWorker suspend functions are checked separately', + () { + // Regression guard for the flaw this rewrite fixes: the old file-scoped + // regex passed on HttpUploadWorker because doWork() had a rethrow, hiding + // that handleRawBodyUpload() had none. + expect(functionBodies.keys, contains('HttpUploadWorker.kt#doWork')); + expect(functionBodies.keys, + contains('HttpUploadWorker.kt#handleRawBodyUpload')); + for (final k in [ + 'HttpUploadWorker.kt#doWork', + 'HttpUploadWorker.kt#handleRawBodyUpload', + ]) { + expect(rethrowPattern.hasMatch(functionBodies[k]!), isTrue, + reason: '$k must carry its own rethrow'); + } + }); + + test('parser regression: ParallelHttpUploadWorker#doWork reads as clean', + () { + // The earlier ad-hoc scan mis-flagged this because it delimited function + // bodies by "next `suspend fun`", so doWork() ran to EOF and absorbed the + // catch inside the NON-suspend `uploadSingleFile()`. Brace matching must + // stop at doWork()'s real closing brace. + final body = functionBodies['ParallelHttpUploadWorker.kt#doWork']; + expect(body, isNotNull); + expect( + body!.contains('uploadSingleFile('), + isTrue, + reason: 'doWork should still contain the call site', + ); + expect( + RegExp(r'private fun uploadSingleFile').hasMatch(body), + isFalse, + reason: 'brace matching leaked past doWork() into uploadSingleFile — ' + 'the parser is broken and every verdict it produces is suspect', + ); + }); + + test('no stale exemptions', () { + final unknown = + exemptions.keys.where((k) => !functionBodies.containsKey(k)).toList(); + expect(unknown, isEmpty, + reason: 'exemptions reference functions that no longer exist ' + '(renamed or removed): $unknown'); + }); }); } + +/// Removes comments and string literals so brace matching is not thrown off by +/// `${...}` templates or braces inside strings. +String _strip(String src) { + final out = StringBuffer(); + var i = 0; + while (i < src.length) { + final rest = src.length - i; + if (rest >= 2 && src[i] == '/' && src[i + 1] == '/') { + while (i < src.length && src[i] != '\n') { + i++; + } + continue; + } + if (rest >= 2 && src[i] == '/' && src[i + 1] == '*') { + i += 2; + while (i + 1 < src.length && !(src[i] == '*' && src[i + 1] == '/')) { + i++; + } + i += 2; + continue; + } + if (rest >= 3 && src.startsWith('"""', i)) { + i += 3; + while (i + 2 < src.length && !src.startsWith('"""', i)) { + i++; + } + i += 3; + out.write('""'); + continue; + } + if (src[i] == '"') { + i++; + while (i < src.length && src[i] != '"') { + if (src[i] == r'\') i++; + i++; + } + i++; + out.write('""'); + continue; + } + out.write(src[i]); + i++; + } + return out.toString(); +} + +/// Maps `#` to the function's body source, delimited by +/// brace depth from the declaration's opening `{`. +Map _suspendFunctionBodies(String src, String fileName) { + final result = {}; + final decl = RegExp(r'\bsuspend\s+fun\s+(\w+)\s*\('); + for (final m in decl.allMatches(src)) { + final name = m.group(1)!; + final open = src.indexOf('{', m.end); + if (open == -1) continue; + var depth = 0; + var i = open; + for (; i < src.length; i++) { + if (src[i] == '{') depth++; + if (src[i] == '}') { + depth--; + if (depth == 0) break; + } + } + if (depth != 0) continue; // unbalanced — skip rather than guess + result['$fileName#$name'] = src.substring(open, i + 1); + } + return result; +} diff --git a/test/unit/extended_coverage_test.dart b/test/unit/extended_coverage_test.dart new file mode 100644 index 0000000..aa59d87 --- /dev/null +++ b/test/unit/extended_coverage_test.dart @@ -0,0 +1,894 @@ +import 'dart:async'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:native_workmanager/native_workmanager.dart'; +import 'package:native_workmanager/src/method_channel.dart'; +import 'package:native_workmanager/src/platform_interface.dart'; +import 'package:native_workmanager/testing.dart'; + +class _FakeClientPlatform extends MethodChannelNativeWorkManager { + final eventsController = StreamController.broadcast(); + final progressController = StreamController.broadcast(); + + final List cancelledTasks = []; + final List cancelledTags = []; + bool cancelAllCalled = false; + final List pausedTasks = []; + final List resumedTasks = []; + + @override + Stream get events => eventsController.stream; + + @override + Stream get progress => progressController.stream; + + @override + Future initialize({ + int? callbackHandle, + bool debugMode = false, + int maxConcurrentTasks = 4, + int diskSpaceBufferMB = 20, + int cleanupAfterDays = 30, + bool enforceHttps = false, + bool blockPrivateIPs = false, + bool registerPlugins = false, + }) async {} + + @override + Future enqueueGraph(Map graphMap) async { + return 'graph_id'; + } + + @override + Future enqueue({ + required String taskId, + required TaskTrigger trigger, + required Worker worker, + Constraints constraints = const Constraints(), + ExistingTaskPolicy existingPolicy = ExistingTaskPolicy.replace, + String? tag, + }) async { + return ScheduleResult.accepted; + } + + @override + Future> getRunningProgress() async => { + 't1': {'taskId': 't1', 'progress': 50}, + }; + + @override + Future cancel({required String taskId}) async { + cancelledTasks.add(taskId); + } + + @override + Future cancelByTag({required String tag}) async { + cancelledTags.add(tag); + } + + @override + Future cancelAll() async { + cancelAllCalled = true; + } + + @override + Future pauseTask({required String taskId}) async { + pausedTasks.add(taskId); + } + + @override + Future resumeTask({required String taskId}) async { + resumedTasks.add(taskId); + } + + @override + Future getTaskStatus({required String taskId}) async => + TaskStatus.running; + + @override + Future getTaskRecord({required String taskId}) async => + TaskRecord( + taskId: taskId, + status: 'running', + workerClassName: 'HttpRequestWorker', + createdAt: DateTime.now(), + updatedAt: DateTime.now(), + ); + + @override + Future> getTasksByTag({required String tag}) async => + ['task_for_$tag']; + + @override + Future> getAllTags() async => ['tag1', 'tag2']; + + @override + Future> allTasks() async => [ + TaskRecord( + taskId: 't_all', + status: 'completed', + workerClassName: 'HttpRequestWorker', + createdAt: DateTime.now(), + updatedAt: DateTime.now(), + ), + ]; + + Future dispose() async { + await eventsController.close(); + await progressController.close(); + } +} + +class _TestLogger implements WorkManagerLogger { + final List starts = []; + final List completions = []; + final List failures = []; + + @override + void onTaskStart(String taskId, String workerType) { + starts.add('$taskId:$workerType'); + } + + @override + void onTaskComplete(TaskEvent event) { + completions.add(event.taskId); + } + + @override + void onTaskFail(TaskEvent event) { + failures.add(event.taskId); + } +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('TaskId Extension Type', () { + test('valid and invalid states', () { + const valid = TaskId('sync-task-1'); + expect(valid.value, 'sync-task-1'); + expect(valid.isValid, isTrue); + expect(valid.length, 11); + expect(valid.startsWith('sync'), isTrue); + + const empty = TaskId(''); + expect(empty.value, ''); + expect(empty.isValid, isFalse); + }); + + test('implements String seamlessly', () { + const id = TaskId('my-id'); + String asString = id; + expect(asString, 'my-id'); + expect('$id', 'my-id'); + }); + }); + + group('WorkerCallback Annotation', () { + test('constructs with id and inputType', () { + const callback = WorkerCallback('worker_1', inputType: Map); + expect(callback.id, 'worker_1'); + expect(callback.inputType, Map); + + const noType = WorkerCallback('worker_2'); + expect(noType.id, 'worker_2'); + expect(noType.inputType, isNull); + }); + }); + + group('AuthConfig', () { + test('default Bearer template', () { + const auth = AuthConfig(accessToken: 'secret_123'); + expect(auth.accessToken, 'secret_123'); + expect(auth.headerTemplate, 'Bearer {accessToken}'); + expect(auth.resolvedHeader, 'Bearer secret_123'); + }); + + test('custom header template', () { + const auth = AuthConfig( + accessToken: 'api_key_xyz', + headerTemplate: 'ApiKey {accessToken}', + ); + expect(auth.resolvedHeader, 'ApiKey api_key_xyz'); + }); + }); + + group('TokenRefreshConfig', () { + test('default configuration values', () { + const config = TokenRefreshConfig(url: 'https://api.example.com/refresh'); + expect(config.url, 'https://api.example.com/refresh'); + expect(config.method, 'POST'); + expect(config.headers, isEmpty); + expect(config.body, isEmpty); + expect(config.responseKey, 'access_token'); + expect(config.tokenHeaderName, 'Authorization'); + expect(config.tokenPrefix, 'Bearer '); + expect(config.toString(), contains('https://api.example.com/refresh')); + + final map = config.toMap(); + expect(map['url'], 'https://api.example.com/refresh'); + expect(map['method'], 'POST'); + expect(map['responseKey'], 'access_token'); + expect(map['tokenPrefix'], 'Bearer '); + }); + + test('custom configuration and map serialization', () { + const config = TokenRefreshConfig( + url: 'https://auth.example.com/token', + method: 'PUT', + headers: {'X-Custom': '1'}, + body: {'refresh': 'abc'}, + responseKey: 'data.token', + tokenHeaderName: 'X-Auth-Token', + tokenPrefix: 'Token ', + ); + + final map = config.toMap(); + expect(map['method'], 'PUT'); + expect(map['headers'], {'X-Custom': '1'}); + expect(map['body'], {'refresh': 'abc'}); + expect(map['responseKey'], 'data.token'); + expect(map['tokenHeaderName'], 'X-Auth-Token'); + expect(map['tokenPrefix'], 'Token '); + }); + }); + + group('ForegroundNotificationConfig', () { + test('default values, equals, hashCode, toString', () { + const config1 = ForegroundNotificationConfig( + title: 'Title', + body: 'Body', + ); + const config2 = ForegroundNotificationConfig( + title: 'Title', + body: 'Body', + ); + const config3 = ForegroundNotificationConfig( + title: 'Other', + body: 'Body', + ); + + expect(config1, equals(config2)); + expect(config1.hashCode, equals(config2.hashCode)); + expect(config1, isNot(equals(config3))); + expect(config1.toString(), contains('ForegroundNotificationConfig')); + expect(config1.showCancelButton, isTrue); + expect(config1.cancelText, 'Cancel'); + + final map = config1.toMap(); + expect(map['title'], 'Title'); + expect(map['body'], 'Body'); + expect(map['showCancelButton'], isTrue); + expect(map['cancelText'], 'Cancel'); + + final fromMap = ForegroundNotificationConfig.fromMap(map); + expect(fromMap, equals(config1)); + }); + + test('fromMap with partial / custom values', () { + final fromEmpty = ForegroundNotificationConfig.fromMap(const {}); + expect(fromEmpty.title, 'Background Task'); + expect(fromEmpty.body, 'Running...'); + expect(fromEmpty.showCancelButton, isTrue); + expect(fromEmpty.cancelText, 'Cancel'); + + final custom = ForegroundNotificationConfig.fromMap({ + 'title': 'Download', + 'body': 'In progress', + 'iconName': 'ic_download', + 'colorHex': '#00FF00', + 'showCancelButton': false, + 'cancelText': 'Stop', + }); + expect(custom.iconName, 'ic_download'); + expect(custom.colorHex, '#00FF00'); + expect(custom.showCancelButton, isFalse); + expect(custom.cancelText, 'Stop'); + }); + }); + + group('CustomNativeWorker', () { + test('valid instantiation and toMap', () { + final worker = CustomNativeWorker( + className: 'com.example.workers.MyCustomWorker', + input: {'key': 'val'}, + ); + expect(worker.workerClassName, 'com.example.workers.MyCustomWorker'); + final map = worker.toMap(); + expect(map['workerType'], 'custom'); + expect(map['className'], 'com.example.workers.MyCustomWorker'); + expect(map['input'], '{"key":"val"}'); + + final noInput = CustomNativeWorker(className: 'SimpleWorker'); + expect(noInput.toMap()['input'], isNull); + }); + + test('throws on invalid class name format', () { + expect( + () => CustomNativeWorker(className: ''), + throwsA(isA()), + ); + expect( + () => CustomNativeWorker(className: '123StartsWithDigit'), + throwsA(isA()), + ); + expect( + () => CustomNativeWorker(className: 'Worker With Spaces'), + throwsA(isA()), + ); + expect( + () => CustomNativeWorker(className: 'Worker;injection'), + throwsA(isA()), + ); + expect( + () => CustomNativeWorker(className: 'A' * 300), + throwsA(isA()), + ); + }); + }); + + group('TaskHandler Extensions', () { + test('networkSpeedHuman formatting', () { + const pNull = TaskProgress(taskId: 't', progress: 50); + expect(pNull.networkSpeedHuman, 'n/a'); + + const pBytes = + TaskProgress(taskId: 't', progress: 50, networkSpeed: 512.0); + expect(pBytes.networkSpeedHuman, '512.0 B/s'); + + const pKb = + TaskProgress(taskId: 't', progress: 50, networkSpeed: 1024 * 5.5); + expect(pKb.networkSpeedHuman, '5.5 KB/s'); + + const pMb = TaskProgress( + taskId: 't', progress: 50, networkSpeed: 1024 * 1024 * 3.25); + expect(pMb.networkSpeedHuman, '3.3 MB/s'); + }); + + test('timeRemainingHuman formatting', () { + const pNull = TaskProgress(taskId: 't', progress: 50); + expect(pNull.timeRemainingHuman, 'unknown'); + + const pSec = TaskProgress( + taskId: 't', progress: 50, timeRemaining: Duration(seconds: 45)); + expect(pSec.timeRemainingHuman, '45s'); + + const pMin = TaskProgress( + taskId: 't', + progress: 50, + timeRemaining: Duration(minutes: 5, seconds: 12)); + expect(pMin.timeRemainingHuman, '5m 12s'); + + const pHour = TaskProgress( + taskId: 't', + progress: 50, + timeRemaining: Duration(hours: 2, minutes: 15)); + expect(pHour.timeRemainingHuman, '2h 15m'); + }); + }); + + group('FakeWorkManager Comprehensive Coverage', () { + late FakeWorkManager wm; + + setUp(() { + wm = FakeWorkManager(); + }); + + tearDown(() { + wm.dispose(); + }); + + test('FakeChainRecord formatting', () { + final task1 = TaskRequest( + id: 't1', + worker: NativeWorker.httpRequest(url: 'https://example.com'), + ); + final task2 = TaskRequest( + id: 't2', + worker: NativeWorker.httpRequest(url: 'https://example.com'), + ); + final record = FakeChainRecord( + firstTask: task1, + steps: [ + [task1], + [task2] + ], + ); + expect(record.allTasks, [task1, task2]); + expect(record.toString(), 'FakeChainRecord(t1 → t2)'); + }); + + test('getRunningProgress returns empty map', () async { + final progress = await wm.getRunningProgress(); + expect(progress, isEmpty); + }); + + test('enqueueGraph records root nodes', () async { + final graph = TaskGraph(id: 'test_dag'); + graph.add(TaskNode( + id: 'node_1', + worker: NativeWorker.httpRequest(url: 'https://example.com'), + )); + final execution = await wm.enqueueGraph(graph); + expect(execution.graphId, 'test_dag'); + expect(wm.enqueued, hasLength(1)); + expect(wm.enqueued.first.taskId, 'node_1'); + }); + + test('pause, resume, cancel, cancelByTag, cancelAll', () async { + await wm.pause(taskId: 't_pause'); + expect(wm.paused, contains('t_pause')); + + await wm.resume(taskId: 't_pause'); + expect(wm.resumed, contains('t_pause')); + + await wm.cancel(taskId: 't_cancel'); + expect(wm.cancelled, contains('t_cancel')); + + await wm.cancelByTag(tag: 'tag_a'); + expect(wm.cancelledTags, contains('tag_a')); + + await wm.cancelAll(); + expect(wm.cancelAllCalled, isTrue); + }); + + test('task status, records, tags, allTasks queries', () async { + final worker = NativeWorker.httpRequest(url: 'https://example.com'); + await wm.enqueue( + taskId: 'task_query', + trigger: const TaskTrigger.oneTime(), + worker: worker, + tag: 'query_tag', + ); + + wm.taskStatuses['task_query'] = TaskStatus.running; + final status = await wm.getTaskStatus(taskId: 'task_query'); + expect(status, TaskStatus.running); + + wm.allTasksResult = [ + TaskRecord( + taskId: 'task_query', + status: 'running', + workerClassName: 'HttpRequestWorker', + createdAt: DateTime.now(), + updatedAt: DateTime.now(), + ) + ]; + final record = await wm.getTaskRecord(taskId: 'task_query'); + expect(record?.taskId, 'task_query'); + + wm.tasksByTag['query_tag'] = ['task_query']; + final tasksWithTag = await wm.getTasksByTag(tag: 'query_tag'); + expect(tasksWithTag, contains('task_query')); + + wm.allTagsResult = ['query_tag']; + final tags = await wm.getAllTags(); + expect(tags, contains('query_tag')); + + final all = await wm.allTasks(); + expect(all, isNotEmpty); + }); + + test('emitEvent and emitProgress dispatch to streams', () async { + final events = []; + final progresses = []; + + final sub1 = wm.events.listen(events.add); + final sub2 = wm.progress.listen(progresses.add); + + wm.emitProgress(const TaskProgress(taskId: 't1', progress: 50)); + wm.emitEvent(TaskEvent( + taskId: 't1', + success: true, + timestamp: DateTime.now(), + )); + + await Future.delayed(const Duration(milliseconds: 50)); + expect(progresses, hasLength(1)); + expect(events, hasLength(1)); + + await sub1.cancel(); + await sub2.cancel(); + }); + + test('reset clears internal state and recreates streams', () async { + await wm.enqueue( + taskId: 't_reset', + trigger: const TaskTrigger.oneTime(), + worker: NativeWorker.httpRequest(url: 'https://example.com'), + ); + expect(wm.enqueued, hasLength(1)); + + wm.reset(); + expect(wm.enqueued, isEmpty); + expect(wm.cancelled, isEmpty); + expect(wm.cancelledTags, isEmpty); + expect(wm.cancelAllCalled, isFalse); + }); + }); + + group('Observability & WorkManagerLogger', () { + test('ObservabilityConfig.fromLogger routes callbacks', () { + final logger = _TestLogger(); + final config = ObservabilityConfig.fromLogger(logger); + final dispatcher = ObservabilityDispatcher(config); + + dispatcher.dispatchEvent(TaskEvent( + taskId: 'task_obs_1', + success: false, + isStarted: true, + workerType: 'HttpDownloadWorker', + timestamp: DateTime.now(), + )); + expect(logger.starts, contains('task_obs_1:HttpDownloadWorker')); + + dispatcher.dispatchEvent(TaskEvent( + taskId: 'task_obs_1', + success: true, + isStarted: false, + timestamp: DateTime.now(), + )); + expect(logger.completions, contains('task_obs_1')); + + dispatcher.dispatchEvent(TaskEvent( + taskId: 'task_obs_2', + success: false, + isStarted: false, + timestamp: DateTime.now(), + )); + expect(logger.failures, contains('task_obs_2')); + }); + + test('ObservabilityDispatcher catches exceptions in user callbacks safely', + () { + final config = ObservabilityConfig( + onTaskStart: (taskId, workerType) => throw Exception('crash in start'), + onTaskComplete: (event) => throw Exception('crash in complete'), + onTaskFail: (event) => throw Exception('crash in fail'), + onProgress: (progress) => throw Exception('crash in progress'), + ); + final dispatcher = ObservabilityDispatcher(config); + + expect( + () => dispatcher.dispatchEvent(TaskEvent( + taskId: 't', + success: false, + isStarted: true, + timestamp: DateTime.now(), + )), + returnsNormally, + ); + + expect( + () => dispatcher.dispatchEvent(TaskEvent( + taskId: 't', + success: true, + isStarted: false, + timestamp: DateTime.now(), + )), + returnsNormally, + ); + + expect( + () => dispatcher.dispatchEvent(TaskEvent( + taskId: 't', + success: false, + isStarted: false, + timestamp: DateTime.now(), + )), + returnsNormally, + ); + + expect( + () => dispatcher + .dispatchProgress(const TaskProgress(taskId: 't', progress: 20)), + returnsNormally, + ); + }); + + test('registerDevToolsExtensions registers without error', () { + expect(registerDevToolsExtensions, returnsNormally); + }); + }); + + group('NativeWorkManagerClient Delegation', () { + late _FakeClientPlatform platform; + + setUp(() async { + platform = _FakeClientPlatform(); + NativeWorkManagerPlatform.instance = platform; + await NativeWorkManager.initialize(); + }); + + tearDown(() async { + await platform.dispose(); + }); + + test('delegates all methods to NativeWorkManager', () async { + const client = NativeWorkManagerClient(); + expect(client.events, isA>()); + expect(client.progress, isA>()); + + final runningProgress = await client.getRunningProgress(); + expect(runningProgress, contains('t1')); + + final handler = await client.enqueue( + taskId: 't_client', + trigger: const TaskTrigger.oneTime(), + worker: NativeWorker.httpRequest(url: 'https://example.com'), + ); + expect(handler.taskId, 't_client'); + + final handlers = await client.enqueueAll([ + EnqueueRequest( + taskId: 't_client_batch', + trigger: const TaskTrigger.oneTime(), + worker: NativeWorker.httpRequest(url: 'https://example.com'), + ) + ]); + expect(handlers, hasLength(1)); + + final chain = client.beginWith(TaskRequest( + id: 't_chain', + worker: NativeWorker.httpRequest(url: 'https://example.com'), + )); + expect(chain, isA()); + + final graph = TaskGraph(id: 'graph_client'); + graph.add(TaskNode( + id: 'n1', + worker: NativeWorker.httpRequest(url: 'https://example.com'), + )); + final graphExec = await client.enqueueGraph(graph); + expect(graphExec.graphId, 'graph_client'); + + await client.cancel(taskId: 't_cancel'); + expect(platform.cancelledTasks, contains('t_cancel')); + + await client.cancelByTag(tag: 'tag_cancel'); + expect(platform.cancelledTags, contains('tag_cancel')); + + await client.cancelAll(); + expect(platform.cancelAllCalled, isTrue); + + await client.pause(taskId: 't_pause'); + expect(platform.pausedTasks, contains('t_pause')); + + await client.resume(taskId: 't_pause'); + expect(platform.resumedTasks, contains('t_pause')); + + final status = await client.getTaskStatus(taskId: 't_client'); + expect(status, TaskStatus.running); + + final record = await client.getTaskRecord(taskId: 't_client'); + expect(record?.taskId, 't_client'); + + final byTag = await client.getTasksByTag(tag: 'my_tag'); + expect(byTag, contains('task_for_my_tag')); + + final tags = await client.getAllTags(); + expect(tags, contains('tag1')); + + final all = await client.allTasks(); + expect(all, hasLength(1)); + + expect(() => client.dispose(), returnsNormally); + }); + }); + + group('TaskHandler Full Lifecycle', () { + late _FakeClientPlatform platform; + + setUp(() async { + platform = _FakeClientPlatform(); + NativeWorkManagerPlatform.instance = platform; + await NativeWorkManager.initialize(); + }); + + tearDown(() async { + await platform.dispose(); + }); + + test('TaskHandler properties, progress, events, result and actions', + () async { + const handler = TaskHandler( + taskId: 't_handler', + scheduleResult: ScheduleResult.accepted, + ); + expect(handler.taskId, 't_handler'); + expect(handler.scheduleResult, ScheduleResult.accepted); + + final progressList = []; + final eventsList = []; + + final sub1 = handler.progress.listen(progressList.add); + final sub2 = handler.events.listen(eventsList.add); + + platform.progressController + .add(const TaskProgress(taskId: 't_handler', progress: 40)); + platform.progressController + .add(const TaskProgress(taskId: 'other_task', progress: 80)); + + platform.eventsController.add(TaskEvent( + taskId: 't_handler', + success: false, + isStarted: true, + timestamp: DateTime.now(), + )); + + final resultFuture = handler.result; + + platform.eventsController.add(TaskEvent( + taskId: 't_handler', + success: true, + isStarted: false, + timestamp: DateTime.now(), + )); + + final completedEvent = await resultFuture; + expect(completedEvent.success, isTrue); + + await Future.delayed(const Duration(milliseconds: 20)); + expect(progressList, hasLength(1)); + expect(progressList.first.progress, 40); + expect(eventsList, hasLength(2)); + + await sub1.cancel(); + await sub2.cancel(); + + await handler.cancel(); + expect(platform.cancelledTasks, contains('t_handler')); + + final status = await handler.getStatus(); + expect(status, TaskStatus.running); + }); + }); + + group('NativeWorkManagerPlatform Default Implementations', () { + late _DefaultPlatform platform; + + setUp(() { + platform = _DefaultPlatform(); + }); + + test('all default methods throw UnimplementedError', () { + expect( + () => platform.initialize(), + throwsA(isA()), + ); + expect( + () => platform.enqueue( + taskId: 't', + trigger: const TaskTrigger.oneTime(), + worker: NativeWorker.httpRequest(url: 'https://example.com'), + constraints: const Constraints(), + existingPolicy: ExistingTaskPolicy.replace, + ), + throwsA(isA()), + ); + expect( + () => platform.cancelByTag(tag: 'tag'), + throwsA(isA()), + ); + expect( + () => platform.getTasksByTag(tag: 'tag'), + throwsA(isA()), + ); + expect( + () => platform.getAllTags(), + throwsA(isA()), + ); + expect( + () => platform.cancel(taskId: 't'), + throwsA(isA()), + ); + expect( + () => platform.cancelAll(), + throwsA(isA()), + ); + expect( + () => platform.getTaskStatus(taskId: 't'), + throwsA(isA()), + ); + expect( + () => platform.getTaskRecord(taskId: 't'), + throwsA(isA()), + ); + expect( + () => platform.getTasksByStatus(status: TaskStatus.running), + throwsA(isA()), + ); + expect( + () => platform.enqueueChain(const {}), + throwsA(isA()), + ); + expect( + () => platform.events, + throwsA(isA()), + ); + expect( + () => platform.progress, + throwsA(isA()), + ); + expect( + () => platform.systemErrors, + throwsA(isA()), + ); + expect( + () => platform.pauseTask(taskId: 't'), + throwsA(isA()), + ); + expect( + () => platform.resumeTask(taskId: 't'), + throwsA(isA()), + ); + expect( + () => platform.allTasks(), + throwsA(isA()), + ); + expect( + () => platform.getServerFilename(url: 'https://example.com'), + throwsA(isA()), + ); + expect( + () => platform.getRunningProgress(), + throwsA(isA()), + ); + expect( + () => platform.openFile('/tmp/file.txt'), + throwsA(isA()), + ); + expect( + () => platform.setMaxConcurrentPerHost(2), + throwsA(isA()), + ); + expect( + () => platform.registerRemoteTrigger( + source: RemoteTriggerSource.fcm, + rule: RemoteTriggerRule( + payloadKey: 'type', + workerMappings: { + 'sync': NativeWorker.httpRequest(url: 'https://example.com'), + }, + ), + ), + throwsA(isA()), + ); + expect( + () => platform.enqueueGraph(const {}), + throwsA(isA()), + ); + expect( + () => platform.offlineQueueEnqueue('q1', const {}), + throwsA(isA()), + ); + expect( + () => platform.registerMiddleware(const {}), + throwsA(isA()), + ); + expect( + () => platform.setCallbackExecutor((id, input) async => true), + throwsA(isA()), + ); + expect( + () => platform.getMetrics(), + throwsA(isA()), + ); + expect( + () => platform.syncOfflineQueue(), + throwsA(isA()), + ); + expect( + () => platform.reportTestEvent( + TaskEvent(taskId: 't', success: true, timestamp: DateTime.now())), + throwsA(isA()), + ); + expect( + () => platform + .reportTestProgress(const TaskProgress(taskId: 't', progress: 50)), + throwsA(isA()), + ); + }); + }); +} + +class _DefaultPlatform extends NativeWorkManagerPlatform {} diff --git a/test/unit/ios_live_activity_bridge_test.dart b/test/unit/ios_live_activity_bridge_test.dart new file mode 100644 index 0000000..6b1302b --- /dev/null +++ b/test/unit/ios_live_activity_bridge_test.dart @@ -0,0 +1,154 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:native_workmanager/native_workmanager.dart'; +import 'package:native_workmanager/src/method_channel.dart'; +import 'package:native_workmanager/src/platform_interface.dart'; + +/// Fake platform that lets a test push progress events onto the same stream +/// [IosLiveActivityBridge.onProgress] reads from. +class _FakeProgressPlatform extends MethodChannelNativeWorkManager { + final controller = StreamController.broadcast(); + + @override + Stream get progress => controller.stream; + + @override + Future initialize({ + int? callbackHandle, + bool debugMode = false, + int maxConcurrentTasks = 4, + int diskSpaceBufferMB = 20, + int cleanupAfterDays = 30, + bool enforceHttps = false, + bool blockPrivateIPs = false, + bool registerPlugins = false, + }) async { + // No-op: avoid MissingPluginException on the host machine. + } + + @override + void reportTestProgress(TaskProgress progress) => controller.add(progress); + + Future dispose() => controller.close(); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('IosLiveActivityBridge', () { + late _FakeProgressPlatform platform; + + setUp(() { + platform = _FakeProgressPlatform(); + NativeWorkManagerPlatform.instance = platform; + }); + + tearDown(() async { + debugDefaultTargetPlatformOverride = null; + await platform.dispose(); + }); + + test('NativeWorkManager.iosLiveActivity is an IosLiveActivityBridge', () { + expect(NativeWorkManager.iosLiveActivity, isA()); + }); + + test('isSupported tracks the current platform', () { + const bridge = IosLiveActivityBridge(); + + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + expect(bridge.isSupported, isTrue); + + debugDefaultTargetPlatformOverride = TargetPlatform.android; + expect(bridge.isSupported, isFalse); + }); + + // The load-bearing test: this FAILS if the taskId filter in + // `onProgress` stops discriminating (e.g. `.where((_) => true)`). + test('onProgress(taskId:) delivers only that task\'s progress', () async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + const bridge = IosLiveActivityBridge(); + + final received = []; + final sub = bridge.onProgress(taskId: 'task_A').listen(received.add); + addTearDown(sub.cancel); + + platform + ..reportTestProgress(const TaskProgress(taskId: 'task_A', progress: 10)) + ..reportTestProgress(const TaskProgress(taskId: 'task_B', progress: 99)) + ..reportTestProgress( + const TaskProgress(taskId: 'task_A', progress: 55)); + + await pumpEventQueue(); + + expect(received.map((p) => p.taskId), everyElement('task_A'), + reason: 'task_B progress must not leak into a task_A subscription'); + expect(received.map((p) => p.progress), [10, 55]); + }); + + test('onProgress() with no taskId passes every task through', () async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + const bridge = IosLiveActivityBridge(); + + final received = []; + final sub = bridge.onProgress().listen(received.add); + addTearDown(sub.cancel); + + platform + ..reportTestProgress(const TaskProgress(taskId: 'task_A', progress: 10)) + ..reportTestProgress( + const TaskProgress(taskId: 'task_B', progress: 20)); + + await pumpEventQueue(); + + expect(received.map((p) => p.taskId), ['task_A', 'task_B']); + }); + + test('two concurrent taskId subscriptions each get their own task', + () async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + const bridge = IosLiveActivityBridge(); + + final a = []; + final b = []; + final subA = + bridge.onProgress(taskId: 'task_A').listen((p) => a.add(p.progress)); + final subB = + bridge.onProgress(taskId: 'task_B').listen((p) => b.add(p.progress)); + addTearDown(subA.cancel); + addTearDown(subB.cancel); + + platform + ..reportTestProgress(const TaskProgress(taskId: 'task_A', progress: 1)) + ..reportTestProgress(const TaskProgress(taskId: 'task_B', progress: 2)); + + await pumpEventQueue(); + + expect(a, [1]); + expect(b, [2]); + }); + + // Documents the non-iOS contract: an already-closed stream, so listeners + // get onDone and never an event. + test('onProgress returns a closed, empty stream on non-iOS', () async { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + const bridge = IosLiveActivityBridge(); + + var done = false; + final received = []; + final sub = bridge + .onProgress(taskId: 'task_A') + .listen(received.add, onDone: () => done = true); + addTearDown(sub.cancel); + + platform.reportTestProgress( + const TaskProgress(taskId: 'task_A', progress: 10)); + + await pumpEventQueue(); + + expect(received, isEmpty); + expect(done, isTrue); + }); + }); +} diff --git a/test/unit/offline_queue_cancel_race_test.dart b/test/unit/offline_queue_cancel_race_test.dart new file mode 100644 index 0000000..822a0a8 --- /dev/null +++ b/test/unit/offline_queue_cancel_race_test.dart @@ -0,0 +1,209 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:native_workmanager/native_workmanager.dart'; +import 'package:native_workmanager/src/method_channel.dart'; +import 'package:native_workmanager/src/platform_interface.dart'; + +/// Regression tests for the `OfflineQueue` cancel-during-flight race. +/// +/// `_processHead()` captures `slot = _pending.first`, then awaits the task's +/// completion event for up to an hour. `cancel()` is synchronous and mutates +/// `_pending` directly, so it can land inside that window. The failure path +/// then wrote back through a **positional** index (`_pending[0] = …` / +/// `_pending.removeAt(0)`) on the assumption that index 0 was still the slot it +/// started with — while the success path at the top of the same method already +/// used the correct identity-based `_pending.remove(slot)`. +/// +/// Two observable consequences, both covered below: +/// 1. queue emptied by cancel → `RangeError` writing to `_pending[0]` +/// 2. head replaced by another → that other entry is silently overwritten +class _FakeQueuePlatform extends MethodChannelNativeWorkManager { + final eventsController = StreamController.broadcast(); + final enqueued = []; + final cancelled = []; + + @override + Stream get events => eventsController.stream; + + @override + Future initialize({ + int? callbackHandle, + bool debugMode = false, + int maxConcurrentTasks = 4, + int diskSpaceBufferMB = 20, + int cleanupAfterDays = 30, + bool enforceHttps = false, + bool blockPrivateIPs = false, + bool registerPlugins = false, + }) async {} + + @override + Future enqueue({ + required String taskId, + required TaskTrigger trigger, + required Worker worker, + required Constraints constraints, + required ExistingTaskPolicy existingPolicy, + String? tag, + }) async { + enqueued.add(taskId); + return ScheduleResult.accepted; + } + + @override + Future cancel({String? taskId, String? tag}) async { + if (taskId != null) cancelled.add(taskId); + return true; + } + + void failTask(String nativeTaskId) { + eventsController.add(TaskEvent( + taskId: nativeTaskId, + success: false, + message: 'simulated failure', + timestamp: DateTime.now(), + )); + } + + Future dispose() => eventsController.close(); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late _FakeQueuePlatform platform; + + setUp(() async { + platform = _FakeQueuePlatform(); + NativeWorkManagerPlatform.instance = platform; + NativeWorkManager.resetInitializedState(); + await NativeWorkManager.initialize(); + }); + + tearDown(() async { + await platform.dispose(); + }); + + QueueEntry entryFor(String id) => QueueEntry( + taskId: id, + worker: NativeWorker.httpRequest(url: 'https://example.com/$id'), + // maxRetries > 0 so a failure takes the retry branch (the buggy write), + // not the dead-letter branch. + retryPolicy: const OfflineRetryPolicy( + maxRetries: 3, + initialDelay: Duration.zero, + requiresNetwork: false, + ), + ); + + group('OfflineQueue: cancel during an in-flight task', () { + test( + 'cancelling the in-flight task must not overwrite the next queued entry', + () async { + final queue = OfflineQueue(id: 'q1'); + await queue.enqueue(entryFor('taskA')); + await queue.enqueue(entryFor('taskB')); + + queue.start(); + await pumpEventQueue(); + expect(platform.enqueued, contains('q1__taskA__0'), + reason: 'taskA should be the in-flight head'); + expect(queue.pendingCount, 2); + + // Cancel the in-flight head while _processHead is awaiting its event. + queue.cancel(taskId: 'taskA'); + expect(queue.pendingCount, 1, reason: 'only taskB should remain'); + + // Now the awaited event arrives as a failure → retry branch runs. + platform.failTask('q1__taskA__0'); + await pumpEventQueue(); + + // The retry slot for taskA must NOT clobber taskB at index 0. + expect( + queue.pendingCount, + 1, + reason: 'taskB must still be queued after taskA was cancelled ' + 'mid-flight — a positional _pending[0] write would replace it', + ); + expect( + platform.enqueued.any((t) => t.contains('taskB')), + isTrue, + reason: 'taskB must still get scheduled; if the retry slot for the ' + 'cancelled taskA overwrote it, taskB is silently lost forever', + ); + expect( + platform.enqueued.any((t) => t == 'q1__taskA__1'), + isFalse, + reason: 'a cancelled task must not be retried', + ); + }); + + test('cancelling every entry mid-flight must not throw RangeError', + () async { + final queue = OfflineQueue(id: 'q2'); + await queue.enqueue(entryFor('solo')); + + queue.start(); + await pumpEventQueue(); + expect(platform.enqueued, contains('q2__solo__0')); + + // Empties _pending while _processHead is awaiting. + queue.cancel(taskId: 'solo'); + expect(queue.pendingCount, 0); + + Object? caught; + await runZonedGuardedAsync(() async { + platform.failTask('q2__solo__0'); + await pumpEventQueue(); + }, (e, _) => caught = e); + + expect( + caught, + isNull, + reason: 'writing to _pending[0] on an emptied queue throws ' + 'RangeError (index): Valid value range is empty: 0', + ); + expect(queue.pendingCount, 0); + expect(queue.deadLetterCount, 0, + reason: 'a cancelled task must not be dead-lettered either'); + }); + + test('normal retry path still works when nothing is cancelled', () async { + final queue = OfflineQueue(id: 'q3'); + await queue.enqueue(entryFor('keep')); + + queue.start(); + await pumpEventQueue(); + expect(platform.enqueued, contains('q3__keep__0')); + + platform.failTask('q3__keep__0'); + await pumpEventQueue(); + + expect(queue.pendingCount, 1, + reason: 'the entry stays queued with an incremented attempt'); + expect(platform.enqueued, contains('q3__keep__1'), + reason: 'attempt 1 must be scheduled — the retry path must not ' + 'regress while fixing the cancel race'); + }); + }); +} + +/// Runs [body] capturing async errors that escape into the zone. +Future runZonedGuardedAsync( + Future Function() body, + void Function(Object, StackTrace) onError, +) async { + final done = Completer(); + runZonedGuarded(() async { + try { + await body(); + } finally { + if (!done.isCompleted) done.complete(); + } + }, (e, s) { + onError(e, s); + if (!done.isCompleted) done.complete(); + }); + await done.future; +} diff --git a/test/unit/offline_queue_test.dart b/test/unit/offline_queue_test.dart index 94b0e8f..a7475d8 100644 --- a/test/unit/offline_queue_test.dart +++ b/test/unit/offline_queue_test.dart @@ -1,7 +1,61 @@ +import 'dart:async'; import 'package:flutter_test/flutter_test.dart'; import 'package:native_workmanager/native_workmanager.dart'; +import 'package:native_workmanager/src/method_channel.dart'; +import 'package:native_workmanager/src/platform_interface.dart'; + +class _FakeQueuePlatform extends MethodChannelNativeWorkManager { + final eventsController = StreamController.broadcast(); + final progressController = StreamController.broadcast(); + + final List enqueuedIds = []; + final List cancelledIds = []; + + @override + Stream get events => eventsController.stream; + + @override + Stream get progress => progressController.stream; + + @override + Future initialize({ + int? callbackHandle, + bool debugMode = false, + int maxConcurrentTasks = 4, + int diskSpaceBufferMB = 20, + int cleanupAfterDays = 30, + bool enforceHttps = false, + bool blockPrivateIPs = false, + bool registerPlugins = false, + }) async {} + + @override + Future enqueue({ + required String taskId, + required TaskTrigger trigger, + required Worker worker, + Constraints constraints = const Constraints(), + ExistingTaskPolicy existingPolicy = ExistingTaskPolicy.replace, + String? tag, + }) async { + enqueuedIds.add(taskId); + return ScheduleResult.accepted; + } + + @override + Future cancel({required String taskId}) async { + cancelledIds.add(taskId); + } + + Future dispose() async { + await eventsController.close(); + await progressController.close(); + } +} void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + group('OfflineRetryPolicy', () { test('default values', () { const policy = OfflineRetryPolicy(); @@ -106,11 +160,20 @@ void main() { group('OfflineQueue', () { late OfflineQueue queue; + late _FakeQueuePlatform platform; - setUp(() { + setUp(() async { + platform = _FakeQueuePlatform(); + NativeWorkManagerPlatform.instance = platform; + await NativeWorkManager.initialize(); queue = OfflineQueue(id: 'test-queue', maxSize: 3); }); + tearDown(() async { + queue.stop(); + await platform.dispose(); + }); + test('initial state', () { expect(queue.id, 'test-queue'); expect(queue.maxSize, 3); @@ -173,5 +236,75 @@ void main() { queue.clearDeadLetter(); expect(queue.deadLetterCount, 0); }); + + test('processes head task and completes on success event', () async { + final worker = NativeWorker.httpRequest(url: 'https://example.com'); + await queue.enqueue(QueueEntry( + taskId: 'success_task', + worker: worker, + retryPolicy: const OfflineRetryPolicy( + maxRetries: 1, + initialDelay: Duration.zero, + ), + )); + + queue.start(); + await Future.delayed(const Duration(milliseconds: 20)); + + expect(platform.enqueuedIds, contains('test-queue__success_task__0')); + + platform.eventsController.add(TaskEvent( + taskId: 'test-queue__success_task__0', + success: true, + isStarted: false, + timestamp: DateTime.now(), + )); + + await Future.delayed(const Duration(milliseconds: 50)); + expect(queue.pendingCount, 0); + expect(queue.deadLetterCount, 0); + }); + + test('retries on failure and moves to dead letter when exhausted', + () async { + final worker = NativeWorker.httpRequest(url: 'https://example.com'); + await queue.enqueue(QueueEntry( + taskId: 'fail_task', + worker: worker, + retryPolicy: const OfflineRetryPolicy( + maxRetries: 1, + initialDelay: Duration.zero, + ), + )); + + queue.start(); + await Future.delayed(const Duration(milliseconds: 20)); + + // Attempt 0 fails + platform.eventsController.add(TaskEvent( + taskId: 'test-queue__fail_task__0', + success: false, + isStarted: false, + timestamp: DateTime.now(), + )); + + await Future.delayed(const Duration(milliseconds: 50)); + expect(platform.enqueuedIds, contains('test-queue__fail_task__1')); + + // Attempt 1 fails (exhausts maxRetries: 1) + platform.eventsController.add(TaskEvent( + taskId: 'test-queue__fail_task__1', + success: false, + isStarted: false, + timestamp: DateTime.now(), + )); + + await Future.delayed(const Duration(milliseconds: 50)); + expect(queue.pendingCount, 0); + expect(queue.deadLetterCount, 1); + + queue.clearDeadLetter(); + expect(queue.deadLetterCount, 0); + }); }); } diff --git a/test/unit/setup_tool_test.dart b/test/unit/setup_tool_test.dart index 0bc93a1..0b4b1ac 100644 --- a/test/unit/setup_tool_test.dart +++ b/test/unit/setup_tool_test.dart @@ -58,5 +58,190 @@ void main() { // Plugin root has no android/app dir, tool skips gracefully expect(out, isNotEmpty); }); + + test('setup tool detects SwiftUI @main App structure gracefully', () async { + final tempDir = Directory.systemTemp.createTempSync('nwm_swiftui_test_'); + try { + final iosRunner = Directory('${tempDir.path}/ios/Runner') + ..createSync(recursive: true); + File('${tempDir.path}/pubspec.yaml') + .writeAsStringSync('name: test_app\n'); + File('${iosRunner.path}/Info.plist') + .writeAsStringSync(''' + + + + +'''); + File('${iosRunner.path}/App.swift').writeAsStringSync(''' +import SwiftUI + +@main +struct MyApp: App { + var body: some Scene { + WindowGroup { + ContentView() + } + } +} +'''); + final currentDir = Directory.current.path; + final setupScript = '$currentDir/bin/setup.dart'; + final result = await Process.run( + 'dart', + ['run', setupScript, '--ios', '--check'], + workingDirectory: tempDir.path, + ); + final out = result.stdout as String; + expect(out, contains('SwiftUI @main App detected')); + expect(out, contains('@UIApplicationDelegateAdaptor')); + } finally { + tempDir.deleteSync(recursive: true); + } + }); + + test('setup tool reports OK when @UIApplicationDelegateAdaptor is present', + () async { + final tempDir = Directory.systemTemp.createTempSync('nwm_swiftui_ok_'); + try { + _writeSwiftUiProject(tempDir, withAdaptor: true); + final result = await Process.run( + 'dart', + [ + 'run', + '${Directory.current.path}/bin/setup.dart', + '--ios', + '--check' + ], + workingDirectory: tempDir.path, + ); + final out = result.stdout as String; + expect( + out, + contains( + 'SwiftUI @main detected with @UIApplicationDelegateAdaptor')); + expect(out, isNot(contains('SwiftUI @main App detected in'))); + } finally { + tempDir.deleteSync(recursive: true); + } + }); + + test('SwiftUI check still runs when ios/Runner/Info.plist is absent', + () async { + final tempDir = Directory.systemTemp.createTempSync('nwm_no_plist_'); + try { + _writeSwiftUiProject(tempDir, withAdaptor: false, withPlist: false); + final result = await Process.run( + 'dart', + [ + 'run', + '${Directory.current.path}/bin/setup.dart', + '--ios', + '--check' + ], + workingDirectory: tempDir.path, + ); + final out = result.stdout as String; + expect(out, contains('No ios/Runner/Info.plist found')); + expect(out, contains('SwiftUI @main App detected'), + reason: 'the SwiftUI check is independent of the plist and must ' + 'not be skipped when the plist is missing'); + } finally { + tempDir.deleteSync(recursive: true); + } + }); + + test('legacy setup_ios entrypoint delegates and keeps SwiftUI parity', + () async { + final tempDir = Directory.systemTemp.createTempSync('nwm_setup_ios_'); + try { + _writeSwiftUiProject(tempDir, withAdaptor: false); + final result = await Process.run( + 'dart', + [ + 'run', + '${Directory.current.path}/bin/setup_ios.dart', + '--check', + ], + workingDirectory: tempDir.path, + ); + final out = result.stdout as String; + expect(out, contains('legacy alias')); + expect(out, contains('SwiftUI @main App detected'), + reason: 'setup_ios must not lag behind setup — it delegates now'); + } finally { + tempDir.deleteSync(recursive: true); + } + }); + + test('a non-SwiftUI AppDelegate project triggers no SwiftUI notice', + () async { + final tempDir = Directory.systemTemp.createTempSync('nwm_uikit_'); + try { + final runner = Directory('${tempDir.path}/ios/Runner') + ..createSync(recursive: true); + File('${tempDir.path}/pubspec.yaml') + .writeAsStringSync('name: test_app\n'); + File('${runner.path}/Info.plist').writeAsStringSync(_emptyPlist); + File('${runner.path}/AppDelegate.swift').writeAsStringSync(''' +import Flutter +import UIKit + +@main +@objc class AppDelegate: FlutterAppDelegate { +} +'''); + final result = await Process.run( + 'dart', + [ + 'run', + '${Directory.current.path}/bin/setup.dart', + '--ios', + '--check' + ], + workingDirectory: tempDir.path, + ); + final out = result.stdout as String; + expect(out, isNot(contains('SwiftUI @main')), + reason: 'a UIKit @main AppDelegate is not a SwiftUI App'); + } finally { + tempDir.deleteSync(recursive: true); + } + }); }); } + +const _emptyPlist = ''' + + + + +'''; + +void _writeSwiftUiProject( + Directory root, { + required bool withAdaptor, + bool withPlist = true, +}) { + final runner = Directory('${root.path}/ios/Runner') + ..createSync(recursive: true); + File('${root.path}/pubspec.yaml').writeAsStringSync('name: test_app\n'); + if (withPlist) { + File('${runner.path}/Info.plist').writeAsStringSync(_emptyPlist); + } + final adaptor = withAdaptor + ? ' @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate\n' + : ''; + File('${runner.path}/App.swift').writeAsStringSync(''' +import SwiftUI + +@main +struct MyApp: App { +$adaptor var body: some Scene { + WindowGroup { + ContentView() + } + } +} +'''); +}