Skip to content

Commit 601bd91

Browse files
chore: release v1.1.1 (#8)
* chore: upgrade kmpworkmanager engine to v2.3.8 * fix(ios, android): update workers to use the new WorkerEnvironment parameter signature from kmpworkmanager 2.3.8 * chore: bump version to 1.1.1 and finalize kmpworkmanager 2.3.8 upgrade * fix(android): FlutterEngineManager.dispose() always resets isInitialized even when engine.destroy() throws FlutterJNI detaches before onTrimMemory fires; engine.destroy() throws RuntimeException which previously skipped engine=null and isInitialized=false, leaving the engine in a broken state (isInitialized=true, methodChannel=null). Any DartWorker task after this returned false immediately without executing the callback. Fix: wrap engine?.destroy() in try-catch so cleanup fields always execute. Also: accept rejectedOsPolicy for exact trigger test on Android 12+ without SCHEDULE_EXACT_ALARM permission. * chore: set version to 1.1.1 for pub.dev release * ci: fix dart format and gen package meta dependency conflict - Format lib/src/method_channel.dart, test/backoff_policy_test.dart, test/unit/v1_1_1_feature_verification_test.dart to match Flutter 3.41.6 dart formatter (lines were too long in committed state) - Override meta in gen pubspec_overrides.yaml so analyzer 12.x resolves alongside Flutter-pinned meta 1.17.0 during CI dependency resolution
1 parent ba5790d commit 601bd91

125 files changed

Lines changed: 1447 additions & 972 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/ci.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ jobs:
3232

3333
- name: Override gen dependency to local path
3434
run: |
35-
printf 'dependency_overrides:\n native_workmanager:\n path: ..\n' > native_workmanager_gen/pubspec_overrides.yaml
35+
printf 'dependency_overrides:\n native_workmanager:\n path: ..\n meta: ">=1.17.0 <3.0.0"\n' > native_workmanager_gen/pubspec_overrides.yaml
3636
3737
- name: Get dependencies (gen)
3838
working-directory: native_workmanager_gen
@@ -105,7 +105,7 @@ jobs:
105105

106106
- name: Override gen dependency to local path
107107
run: |
108-
printf 'dependency_overrides:\n native_workmanager:\n path: ..\n' > native_workmanager_gen/pubspec_overrides.yaml
108+
printf 'dependency_overrides:\n native_workmanager:\n path: ..\n meta: ">=1.17.0 <3.0.0"\n' > native_workmanager_gen/pubspec_overrides.yaml
109109
110110
- name: Get dependencies (gen)
111111
working-directory: native_workmanager_gen

CHANGELOG.md

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
---
99

10+
## [1.1.1] - 2026-04-11
11+
12+
### Added
13+
- **Token Refresh on 401**: `HttpRequestWorker` and `HttpSyncWorker` now support automatic token refresh when a 401 response is received. Configure via the new `tokenRefresh` field in worker config (or `TokenRefreshConfig` in the Dart API) with a refresh URL, optional request body, and a `responseKey` path to extract the new token (supports nested keys like `"json.access_token"`).
14+
- **Response Validation Patterns**: `HttpRequestWorker` supports `successPattern` and `failurePattern` regex fields. A 200 response is marked as failure if `failurePattern` matches or `successPattern` does not match — useful for APIs that always return HTTP 200 but embed error status in the body.
15+
- **`http_sync_test.dart`**: New integration test for `HttpSyncWorker` covering token refresh and request signing.
16+
17+
### Changed
18+
- **KMPWorkManager iOS XCFramework**: Updated simulator slice from `ios-arm64-simulator` to `ios-arm64_x86_64-simulator` to support both Apple Silicon and Intel Simulator targets.
19+
- **Upgraded to kmpworkmanager 2.3.9**: Fixes `InvalidForegroundServiceTypeException` crash on Android 16 (API 36) when using `isHeavyTask: true`. `KmpHeavyWorker` now specifies `FOREGROUND_SERVICE_TYPE_DATA_SYNC` on API 31+.
20+
- **Upgraded Core Engine to `kmpworkmanager 2.3.8`**:
21+
- Removed `enqueuePeriodicWorkDirect` workaround; periodic task scheduling is now correctly handled by the core engine.
22+
- Resolves `TaskEventBus` drop events (Android), `AlarmManager` cancellation bugs (Android), iOS queue corruption vulnerabilities, and massively improves queue performance.
23+
- Resolves SSRF URL path bypasses and exact alarm permission bugs on Android 12+.
24+
- **Refactored Workers**: Updated all built-in and example workers to support the new `WorkerEnvironment` signature required by kmpworkmanager 2.3.8.
25+
26+
### Fixed
27+
- **Android: `FlutterEngineManager` dispose broken under memory pressure**`engine.destroy()` threw `RuntimeException` when called from `onTrimMemory`, leaving the engine in a broken state (`isInitialized=true`, `methodChannel=null`). Any `DartWorker` task scheduled after this silently failed. Fix: wrap `engine.destroy()` in try-catch so cleanup always runs.
28+
- **Android: `pause` method not routed** — calling `pauseByTag()`, `pauseAll()`, or any pause operation on Android threw `MissingPluginException`. The `"pause"` case was missing from the method channel switch statement.
29+
1030
## [1.1.0] - 2026-04-05
1131

1232
### Added
@@ -52,7 +72,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
5272
- **Documentation**: Updated multiple docstrings and README examples for API accuracy.
5373

5474
### Fixed (2026-04-05 — demo & example fixes)
55-
5675
- **Example: `isStarted` lifecycle event caused false "Task failed" toast** — All event listeners in the example app (`demo_scenarios_page`, `comprehensive_demo_page`, `file_system_demo_page`, `floating_metrics_overlay`, `advanced_metrics_overlay`, `bug_fix_demo_screen`) now guard with `if (event.isStarted) return` before processing completion state. Root cause: `TaskEvent.fromMap` defaults `success` to `false` when the key is absent; lifecycle events (`isStarted: true`) don't carry a `success` key, so every task start was incorrectly shown as a failure.
5776

5877
- **Example: `_demoPhotoBackup()` chain always failed** — Replaced `List.filled(1024, 0)` dummy bytes (invalid JPEG) with a 5-step chain: `HttpDownloadWorker` fetches a real JPEG from `httpbin.org/image/jpeg` before `ImageProcessWorker` runs.
@@ -818,7 +837,7 @@ Minor difference: CryptoWorker uses AES-CBC (Android) vs AES-GCM (iOS), both AES
818837

819838
### 🙏 **Acknowledgments**
820839

821-
Built on [kmpworkmanager v2.3.0](https://github.com/pablichjenkov/kmpworkmanager) for Kotlin Multiplatform.
840+
Built on [kmpworkmanager v2.3.8](https://github.com/pablichjenkov/kmpworkmanager) for Kotlin Multiplatform.
822841

823842
---
824843

@@ -832,7 +851,7 @@ Built on [kmpworkmanager v2.3.0](https://github.com/pablichjenkov/kmpworkmanager
832851

833852
---
834853

835-
**Latest Version:** 1.0.1
854+
**Latest Version:** 1.1.1
836855
**Status:** Production Ready - Stable release for all production apps
837-
**KMP Parity:** 100% (kmpworkmanager v2.3.3)
856+
**KMP Parity:** 100% (kmpworkmanager v2.3.8)
838857
**Platforms:** Android | iOS

README.md

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525

2626
```yaml
2727
dependencies:
28-
native_workmanager: ^1.1.0
28+
native_workmanager: ^1.1.1
2929
```
3030
3131
**2. Initialize once in `main()`:**
@@ -62,7 +62,15 @@ The popular `workmanager` plugin boots a **full Flutter Engine for every backgro
6262

6363
`native_workmanager` skips the engine entirely. Workers run as pure Kotlin coroutines or Swift async tasks.
6464

65-
| Metric | workmanager (Dart-based) | native_workmanager |
65+
### Core Engine: kmpworkmanager 2.3.8
66+
67+
The latest version is powered by **kmpworkmanager 2.3.8**, which brings:
68+
- **Massive Performance:** O(1) queue complexity for iOS (40x faster enqueue/dequeue).
69+
- **Hardened Security:** Built-in SSRF protection, path traversal validation, and Zip-bomb detection.
70+
- **Enterprise Resilience:** Fixed `TaskEventBus` event drops on Android and atomic state recovery for task chains.
71+
- **Low Memory:** Optimized for devices with aggressive battery saving (Samsung, Xiaomi, etc.).
72+
73+
| Metric | workmanager (Dart-based) | native_workmanager (v1.1.1) |
6674
| :--- | :---: | :---: |
6775
| Memory per task | ~50–100 MB | **~2–5 MB** |
6876
| Task startup | 1,500–3,000 ms | **< 50 ms** |

android/build.gradle

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
group = "dev.brewkits.native_workmanager"
2-
version = "1.1.0"
2+
version = "1.1.1"
33

44
apply plugin: "com.android.library"
55
apply plugin: "kotlin-android"
@@ -33,10 +33,10 @@ android {
3333
}
3434

3535
dependencies {
36-
// KMP WorkManager - Core library (from Maven Central v2.3.7)
37-
api("dev.brewkits:kmpworkmanager:2.3.7")
36+
// KMP WorkManager - Core library (from Maven Central v2.3.8)
37+
api("dev.brewkits:kmpworkmanager:2.3.9")
3838

39-
// Android WorkManager — safe to use 2.10.0+ with kmpworkmanager 2.3.7+
39+
// Android WorkManager — safe to use 2.10.0+ with kmpworkmanager 2.3.8+
4040
// (getForegroundInfo() override added in kmpworkmanager 2.3.3)
4141
api("androidx.work:work-runtime-ktx:2.10.1")
4242

android/src/main/kotlin/dev/brewkits/native_workmanager/NativeWorkmanagerPlugin+Chain.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ internal fun NativeWorkmanagerPlugin.handleEnqueueChain(call: MethodCall, result
4949
withContext(Dispatchers.IO) {
5050
chainStore.addChainStep(chainId, stepIndex, taskId, "pending")
5151

52-
// ✅ FIX: Also persist to TaskStore so allTasks() surfaces chain nodes
52+
// Also persist to TaskStore so allTasks() surfaces chain nodes
5353
taskStore.upsert(
5454
taskId = taskId,
5555
tag = chainName,

android/src/main/kotlin/dev/brewkits/native_workmanager/NativeWorkmanagerPlugin+Enqueue.kt

Lines changed: 27 additions & 117 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,9 @@ package dev.brewkits.native_workmanager
33
import android.content.Intent
44
import androidx.core.content.FileProvider
55
import androidx.work.Data
6-
import androidx.work.ExistingPeriodicWorkPolicy
76
import androidx.work.ExistingWorkPolicy
87
import androidx.work.NetworkType
98
import androidx.work.OneTimeWorkRequest
10-
import androidx.work.PeriodicWorkRequest
119
import androidx.work.WorkManager
1210
import androidx.work.await
1311
import dev.brewkits.kmpworkmanager.background.data.KmpHeavyWorker
@@ -220,15 +218,15 @@ internal fun NativeWorkmanagerPlugin.handleEnqueue(call: MethodCall, result: Res
220218
val workerConfig = call.argument<Map<String, Any?>?>("workerConfig")
221219
// Custom workers carry a pre-encoded "input" JSON string;
222220
// built-in workers need the entire workerConfig serialised as their input.
223-
// ✅ ENHANCEMENT: Inject taskId into all worker configs for progress reporting
221+
// Inject taskId into all worker configs for progress reporting
224222
val inputJson: String? = when {
225223
workerConfig == null -> null
226224
workerConfig["workerType"] == "custom" -> workerConfig["input"] as? String
227225
else -> {
228226
// Inject taskId into worker config for progress reporting
229227
val enrichedConfig = workerConfig.toMutableMap()
230228
enrichedConfig["__taskId"] = taskId
231-
// SC-C-001: intercept password for crypto workers — replace with vault key
229+
// intercept password for crypto workers — replace with vault key
232230
// so the password is never written to the unencrypted WorkManager Room DB.
233231
if (enrichedConfig["workerType"] == "crypto") {
234232
val password = enrichedConfig["password"] as? String
@@ -249,20 +247,16 @@ internal fun NativeWorkmanagerPlugin.handleEnqueue(call: MethodCall, result: Res
249247
NativeLogger.d("Stored tag '$tag' for task '$taskId'")
250248
}
251249

252-
// C-001 FIX: constraintsMap must be declared BEFORE it is used for constraintsJson.
253-
// Previously declared at line ~316 (after the upsert block) — Kotlin forward reference
254-
// to a local variable is a compile error. Moved here so both constraintsJson
255-
// persistence (below) and parseConstraints() call (below trigger parsing) share the
256-
// same parsed value.
250+
// constraintsMap must be declared before constraintsJson so both the persistence
251+
// block below and parseConstraints() share the same parsed value.
257252
@Suppress("UNCHECKED_CAST")
258253
val constraintsMap = call.argument<Map<String, Any?>>("constraints")
259254

260255
// Store task in persistent SQLite store (IO dispatcher — SQLite must not run on Main).
261-
// H-001 FIX: Store the FULL (unsanitized) inputJson so that handleResume() can
262-
// re-enqueue with the original auth headers, cookies, and tokens intact.
263-
// toFlutterMap() does NOT include workerConfig, so sensitive fields are never sent
264-
// to the Dart layer. The Android app sandbox protects the DB file from other apps.
265-
// M-5: also persist constraints JSON so resume() can restore original constraints.
256+
// Store the FULL (unsanitized) inputJson so that handleResume() can re-enqueue with
257+
// original auth headers, cookies, and tokens intact. toFlutterMap() does NOT include
258+
// workerConfig, so sensitive fields are never sent to the Dart layer.
259+
// Also persist constraintsJson so resume() can restore original constraints.
266260
val constraintsJson = constraintsMap?.let { toJson(it) }
267261
withContext(Dispatchers.IO) {
268262
taskStore.upsert(
@@ -355,20 +349,6 @@ internal fun NativeWorkmanagerPlugin.handleEnqueue(call: MethodCall, result: Res
355349
return@launch
356350
}
357351

358-
// Fix: kmpworkmanager scheduler.enqueue() silently creates a OneTimeWorkRequest
359-
// even when given a Periodic trigger — so the task runs once then never repeats.
360-
// Bypass kmpworkmanager for Periodic tasks and enqueue PeriodicWorkRequest directly.
361-
if (trigger is TaskTrigger.Periodic) {
362-
val intervalMs = trigger.intervalMs
363-
val flexMs = trigger.flexMs
364-
NativeLogger.d("Scheduling '$taskId': Periodic(interval=${intervalMs}ms, flex=${flexMs}ms) → direct WorkManager")
365-
enqueuePeriodicWorkDirect(taskId, workerClassName, inputJson, tag, constraints, intervalMs, flexMs, policy)
366-
taskStatuses[taskId] = "pending"
367-
observeWorkCompletion(taskId, true)
368-
result.success("ACCEPTED")
369-
return@launch
370-
}
371-
372352
val isPeriodic = trigger is TaskTrigger.Periodic
373353
NativeLogger.d("Scheduling '$taskId': trigger=$triggerType, policy=$existingPolicyStr, heavy=${constraints.isHeavyTask}")
374354

@@ -419,10 +399,9 @@ internal suspend fun NativeWorkmanagerPlugin.cleanupTempFilesForTask(taskId: Str
419399
val savePath = try {
420400
org.json.JSONObject(config).optString("savePath").takeIf { it.isNotBlank() }
421401
} catch (_: Exception) { null } ?: return
422-
// L-003 FIX: ETag sidecar is now stored next to the .tmp file (tempFile.path + .etag),
423-
// which may be a sentinel "__pending__.tmp.etag" in directory mode. Delete both the
424-
// savePath-relative sentinel AND the savePath+suffix fallback to handle tasks persisted
425-
// before the L-003 fix was applied.
402+
// ETag sidecar is stored next to the .tmp file (tempFile.path + .etag), which may be a
403+
// sentinel "__pending__.tmp.etag" in directory mode. Delete both the savePath-relative
404+
// sentinel AND the savePath+suffix fallback to handle both naming conventions.
426405
val tempPath = if (savePath.endsWith("/")) savePath + "__pending__.tmp" else savePath + ".tmp"
427406
for (suffix in listOf(".tmp", ".tmp.etag")) {
428407
for (base in listOf(savePath, tempPath).distinct()) {
@@ -444,7 +423,7 @@ internal fun NativeWorkmanagerPlugin.handleCancel(call: MethodCall, result: Resu
444423
val taskId = call.argument<String>("taskId")
445424
?: return@launch result.error("INVALID_ARGS", "taskId required", null)
446425

447-
// FIX: Use cancelAllWorkByTag instead of cancelUniqueWork so that both standalone
426+
// Use cancelAllWorkByTag instead of cancelUniqueWork so that both standalone
448427
// tasks (unique work) AND chain steps (non-unique work tagged with taskId) are
449428
// correctly cancelled. All tasks are tagged with their taskId via addTag(taskId).
450429
withContext(Dispatchers.IO) {
@@ -513,7 +492,7 @@ internal fun NativeWorkmanagerPlugin.handleCancelByTag(call: MethodCall, result:
513492

514493
NativeLogger.d("Canceling ${tasksToCancel.size} tasks with tag '$tag'")
515494

516-
// FIX: cancelAllWorkByTag is async; await result before returning to Dart.
495+
// cancelAllWorkByTag is async; await result before returning to Dart.
517496
withContext(Dispatchers.IO) {
518497
WorkManager.getInstance(context).cancelAllWorkByTag(tag).await()
519498
tasksToCancel.forEach { taskId ->
@@ -595,6 +574,20 @@ internal fun NativeWorkmanagerPlugin.handleGetTaskStatus(call: MethodCall, resul
595574
}
596575
}
597576

577+
internal fun NativeWorkmanagerPlugin.handleGetTaskRecord(call: MethodCall, result: Result) {
578+
scope.launch {
579+
try {
580+
val taskId = call.argument<String>("taskId")
581+
?: return@launch result.error("INVALID_ARGS", "taskId required", null)
582+
583+
val record = withContext(Dispatchers.IO) { taskStore.getTask(taskId) }
584+
result.success(record?.let { with(taskStore) { it.toFlutterMap() } })
585+
} catch (e: Exception) {
586+
result.success(null)
587+
}
588+
}
589+
}
590+
598591
internal fun NativeWorkmanagerPlugin.parseConstraints(map: Map<String, Any?>?): Constraints {
599592
if (map == null) return Constraints()
600593

@@ -721,87 +714,4 @@ internal fun NativeWorkmanagerPlugin.enqueueOneTimeWorkDirect(
721714
NativeLogger.d("✅ OneTime '$taskId' enqueued via direct WorkManager (delay=${delayMs}ms, heavy=${constraints.isHeavyTask}, policy=$workPolicy)")
722715
}
723716

724-
/**
725-
* Schedules a Periodic task directly via WorkManager, bypassing kmpworkmanager.
726-
*
727-
* kmpworkmanager's BackgroundTaskScheduler.enqueue() creates a OneTimeWorkRequest even when
728-
* given a Periodic trigger — so the task runs once and never repeats.
729-
* This method creates a true PeriodicWorkRequest so WorkManager re-schedules it automatically.
730-
*
731-
* Note: WorkManager enforces a minimum repeat interval of 15 minutes (900,000 ms).
732-
* Shorter intervals are silently coerced up to 15 minutes by WorkManager.
733-
*/
734-
internal fun NativeWorkmanagerPlugin.enqueuePeriodicWorkDirect(
735-
taskId: String,
736-
workerClassName: String,
737-
inputJson: String?,
738-
tag: String?,
739-
constraints: Constraints,
740-
intervalMs: Long,
741-
flexMs: Long?,
742-
policy: ExistingPolicy,
743-
) {
744-
val workerClass = if (constraints.isHeavyTask) KmpHeavyWorker::class.java else KmpWorker::class.java
745-
746-
val dataBuilder = Data.Builder().putString("workerClassName", workerClassName)
747-
748-
// Apply middleware to inputJson before enqueuing (Phase 2)
749-
val effectiveInputJson = if (inputJson != null) {
750-
NativeWorkmanagerPlugin.applyMiddleware(context, workerClassName, inputJson)
751-
} else inputJson
752-
753-
if (effectiveInputJson != null) dataBuilder.putString("inputJson", effectiveInputJson)
754-
755-
val networkType = when {
756-
constraints.requiresUnmeteredNetwork -> NetworkType.UNMETERED
757-
constraints.requiresNetwork -> NetworkType.CONNECTED
758-
else -> NetworkType.NOT_REQUIRED
759-
}
760-
val wmConstraintsBuilder = androidx.work.Constraints.Builder()
761-
.setRequiredNetworkType(networkType)
762-
.setRequiresCharging(constraints.requiresCharging)
763-
val sysConstraints = constraints.systemConstraints ?: emptySet()
764-
if (sysConstraints.contains(SystemConstraint.DEVICE_IDLE)) wmConstraintsBuilder.setRequiresDeviceIdle(true)
765-
if (sysConstraints.contains(SystemConstraint.REQUIRE_BATTERY_NOT_LOW)) wmConstraintsBuilder.setRequiresBatteryNotLow(true)
766-
767-
// WorkManager minimum interval is 15 minutes; coerce silently to match WM behaviour
768-
val effectiveIntervalMs = intervalMs.coerceAtLeast(15 * 60 * 1000L)
769-
770-
val requestBuilder = if (flexMs != null && flexMs > 0) {
771-
// Flex must be ≤ interval and ≥ 5 minutes per WorkManager constraints
772-
val effectiveFlexMs = flexMs.coerceIn(5 * 60 * 1000L, effectiveIntervalMs)
773-
PeriodicWorkRequest.Builder(
774-
workerClass,
775-
effectiveIntervalMs, TimeUnit.MILLISECONDS,
776-
effectiveFlexMs, TimeUnit.MILLISECONDS
777-
)
778-
} else {
779-
PeriodicWorkRequest.Builder(workerClass, effectiveIntervalMs, TimeUnit.MILLISECONDS)
780-
}
781-
782-
requestBuilder
783-
.setConstraints(wmConstraintsBuilder.build())
784-
.setInputData(dataBuilder.build())
785-
.addTag(NativeTaskScheduler.TAG_KMP_TASK)
786-
.addTag("worker-$workerClassName")
787-
.addTag(taskId)
788-
.addTag(workerClassName)
789-
if (tag != null) requestBuilder.addTag(tag)
790-
791-
val wmBackoffPolicy = when (constraints.backoffPolicy) {
792-
BackoffPolicy.LINEAR -> androidx.work.BackoffPolicy.LINEAR
793-
else -> androidx.work.BackoffPolicy.EXPONENTIAL
794-
}
795-
requestBuilder.setBackoffCriteria(wmBackoffPolicy, constraints.backoffDelayMs, TimeUnit.MILLISECONDS)
796-
797-
// ExistingPeriodicWorkPolicy.REPLACE was deprecated in WorkManager 2.8.0;
798-
// CANCEL_AND_REENQUEUE is the correct replacement.
799-
val workPolicy = when (policy) {
800-
ExistingPolicy.REPLACE -> ExistingPeriodicWorkPolicy.CANCEL_AND_REENQUEUE
801-
else -> ExistingPeriodicWorkPolicy.KEEP
802-
}
803-
804-
WorkManager.getInstance(context).enqueueUniquePeriodicWork(taskId, workPolicy, requestBuilder.build())
805-
NativeLogger.d("✅ Periodic '$taskId' enqueued via direct WorkManager (interval=${effectiveIntervalMs}ms, flex=${flexMs}ms, policy=$workPolicy)")
806-
}
807717

0 commit comments

Comments
 (0)