Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ jobs:

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

- name: Override gen dependency to local path
run: |
printf 'dependency_overrides:\n native_workmanager:\n path: ..\n' > native_workmanager_gen/pubspec_overrides.yaml
printf 'dependency_overrides:\n native_workmanager:\n path: ..\n meta: ">=1.17.0 <3.0.0"\n' > native_workmanager_gen/pubspec_overrides.yaml
- name: Get dependencies (gen)
working-directory: native_workmanager_gen
Expand Down
27 changes: 23 additions & 4 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

---

## [1.1.1] - 2026-04-11

### Added
- **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"`).
- **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.
- **`http_sync_test.dart`**: New integration test for `HttpSyncWorker` covering token refresh and request signing.

### Changed
- **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.
- **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+.
- **Upgraded Core Engine to `kmpworkmanager 2.3.8`**:
- Removed `enqueuePeriodicWorkDirect` workaround; periodic task scheduling is now correctly handled by the core engine.
- Resolves `TaskEventBus` drop events (Android), `AlarmManager` cancellation bugs (Android), iOS queue corruption vulnerabilities, and massively improves queue performance.
- Resolves SSRF URL path bypasses and exact alarm permission bugs on Android 12+.
- **Refactored Workers**: Updated all built-in and example workers to support the new `WorkerEnvironment` signature required by kmpworkmanager 2.3.8.

### Fixed
- **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.
- **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.

## [1.1.0] - 2026-04-05

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

### Fixed (2026-04-05 — demo & example fixes)

- **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.

- **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.
Expand Down Expand Up @@ -818,7 +837,7 @@ Minor difference: CryptoWorker uses AES-CBC (Android) vs AES-GCM (iOS), both AES

### 🙏 **Acknowledgments**

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

---

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

---

**Latest Version:** 1.0.1
**Latest Version:** 1.1.1
**Status:** Production Ready - Stable release for all production apps
**KMP Parity:** 100% (kmpworkmanager v2.3.3)
**KMP Parity:** 100% (kmpworkmanager v2.3.8)
**Platforms:** Android | iOS
12 changes: 10 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@

```yaml
dependencies:
native_workmanager: ^1.1.0
native_workmanager: ^1.1.1
```
**2. Initialize once in `main()`:**
Expand Down Expand Up @@ -62,7 +62,15 @@ The popular `workmanager` plugin boots a **full Flutter Engine for every backgro

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

| Metric | workmanager (Dart-based) | native_workmanager |
### Core Engine: kmpworkmanager 2.3.8

The latest version is powered by **kmpworkmanager 2.3.8**, which brings:
- **Massive Performance:** O(1) queue complexity for iOS (40x faster enqueue/dequeue).
- **Hardened Security:** Built-in SSRF protection, path traversal validation, and Zip-bomb detection.
- **Enterprise Resilience:** Fixed `TaskEventBus` event drops on Android and atomic state recovery for task chains.
- **Low Memory:** Optimized for devices with aggressive battery saving (Samsung, Xiaomi, etc.).

| Metric | workmanager (Dart-based) | native_workmanager (v1.1.1) |
| :--- | :---: | :---: |
| Memory per task | ~50–100 MB | **~2–5 MB** |
| Task startup | 1,500–3,000 ms | **< 50 ms** |
Expand Down
8 changes: 4 additions & 4 deletions android/build.gradle
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
group = "dev.brewkits.native_workmanager"
version = "1.1.0"
version = "1.1.1"

apply plugin: "com.android.library"
apply plugin: "kotlin-android"
Expand Down Expand Up @@ -33,10 +33,10 @@ android {
}

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

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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ internal fun NativeWorkmanagerPlugin.handleEnqueueChain(call: MethodCall, result
withContext(Dispatchers.IO) {
chainStore.addChainStep(chainId, stepIndex, taskId, "pending")

// ✅ FIX: Also persist to TaskStore so allTasks() surfaces chain nodes
// Also persist to TaskStore so allTasks() surfaces chain nodes
taskStore.upsert(
taskId = taskId,
tag = chainName,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,9 @@ package dev.brewkits.native_workmanager
import android.content.Intent
import androidx.core.content.FileProvider
import androidx.work.Data
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.ExistingWorkPolicy
import androidx.work.NetworkType
import androidx.work.OneTimeWorkRequest
import androidx.work.PeriodicWorkRequest
import androidx.work.WorkManager
import androidx.work.await
import dev.brewkits.kmpworkmanager.background.data.KmpHeavyWorker
Expand Down Expand Up @@ -220,15 +218,15 @@ internal fun NativeWorkmanagerPlugin.handleEnqueue(call: MethodCall, result: Res
val workerConfig = call.argument<Map<String, Any?>?>("workerConfig")
// Custom workers carry a pre-encoded "input" JSON string;
// built-in workers need the entire workerConfig serialised as their input.
// ✅ ENHANCEMENT: Inject taskId into all worker configs for progress reporting
// Inject taskId into all worker configs for progress reporting
val inputJson: String? = when {
workerConfig == null -> null
workerConfig["workerType"] == "custom" -> workerConfig["input"] as? String
else -> {
// Inject taskId into worker config for progress reporting
val enrichedConfig = workerConfig.toMutableMap()
enrichedConfig["__taskId"] = taskId
// SC-C-001: intercept password for crypto workers — replace with vault key
// intercept password for crypto workers — replace with vault key
// so the password is never written to the unencrypted WorkManager Room DB.
if (enrichedConfig["workerType"] == "crypto") {
val password = enrichedConfig["password"] as? String
Expand All @@ -249,20 +247,16 @@ internal fun NativeWorkmanagerPlugin.handleEnqueue(call: MethodCall, result: Res
NativeLogger.d("Stored tag '$tag' for task '$taskId'")
}

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

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

// Fix: kmpworkmanager scheduler.enqueue() silently creates a OneTimeWorkRequest
// even when given a Periodic trigger — so the task runs once then never repeats.
// Bypass kmpworkmanager for Periodic tasks and enqueue PeriodicWorkRequest directly.
if (trigger is TaskTrigger.Periodic) {
val intervalMs = trigger.intervalMs
val flexMs = trigger.flexMs
NativeLogger.d("Scheduling '$taskId': Periodic(interval=${intervalMs}ms, flex=${flexMs}ms) → direct WorkManager")
enqueuePeriodicWorkDirect(taskId, workerClassName, inputJson, tag, constraints, intervalMs, flexMs, policy)
taskStatuses[taskId] = "pending"
observeWorkCompletion(taskId, true)
result.success("ACCEPTED")
return@launch
}

val isPeriodic = trigger is TaskTrigger.Periodic
NativeLogger.d("Scheduling '$taskId': trigger=$triggerType, policy=$existingPolicyStr, heavy=${constraints.isHeavyTask}")

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

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

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

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

internal fun NativeWorkmanagerPlugin.handleGetTaskRecord(call: MethodCall, result: Result) {
scope.launch {
try {
val taskId = call.argument<String>("taskId")
?: return@launch result.error("INVALID_ARGS", "taskId required", null)

val record = withContext(Dispatchers.IO) { taskStore.getTask(taskId) }
result.success(record?.let { with(taskStore) { it.toFlutterMap() } })
} catch (e: Exception) {
result.success(null)
}
}
}

internal fun NativeWorkmanagerPlugin.parseConstraints(map: Map<String, Any?>?): Constraints {
if (map == null) return Constraints()

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

/**
* Schedules a Periodic task directly via WorkManager, bypassing kmpworkmanager.
*
* kmpworkmanager's BackgroundTaskScheduler.enqueue() creates a OneTimeWorkRequest even when
* given a Periodic trigger — so the task runs once and never repeats.
* This method creates a true PeriodicWorkRequest so WorkManager re-schedules it automatically.
*
* Note: WorkManager enforces a minimum repeat interval of 15 minutes (900,000 ms).
* Shorter intervals are silently coerced up to 15 minutes by WorkManager.
*/
internal fun NativeWorkmanagerPlugin.enqueuePeriodicWorkDirect(
taskId: String,
workerClassName: String,
inputJson: String?,
tag: String?,
constraints: Constraints,
intervalMs: Long,
flexMs: Long?,
policy: ExistingPolicy,
) {
val workerClass = if (constraints.isHeavyTask) KmpHeavyWorker::class.java else KmpWorker::class.java

val dataBuilder = Data.Builder().putString("workerClassName", workerClassName)

// Apply middleware to inputJson before enqueuing (Phase 2)
val effectiveInputJson = if (inputJson != null) {
NativeWorkmanagerPlugin.applyMiddleware(context, workerClassName, inputJson)
} else inputJson

if (effectiveInputJson != null) dataBuilder.putString("inputJson", effectiveInputJson)

val networkType = when {
constraints.requiresUnmeteredNetwork -> NetworkType.UNMETERED
constraints.requiresNetwork -> NetworkType.CONNECTED
else -> NetworkType.NOT_REQUIRED
}
val wmConstraintsBuilder = androidx.work.Constraints.Builder()
.setRequiredNetworkType(networkType)
.setRequiresCharging(constraints.requiresCharging)
val sysConstraints = constraints.systemConstraints ?: emptySet()
if (sysConstraints.contains(SystemConstraint.DEVICE_IDLE)) wmConstraintsBuilder.setRequiresDeviceIdle(true)
if (sysConstraints.contains(SystemConstraint.REQUIRE_BATTERY_NOT_LOW)) wmConstraintsBuilder.setRequiresBatteryNotLow(true)

// WorkManager minimum interval is 15 minutes; coerce silently to match WM behaviour
val effectiveIntervalMs = intervalMs.coerceAtLeast(15 * 60 * 1000L)

val requestBuilder = if (flexMs != null && flexMs > 0) {
// Flex must be ≤ interval and ≥ 5 minutes per WorkManager constraints
val effectiveFlexMs = flexMs.coerceIn(5 * 60 * 1000L, effectiveIntervalMs)
PeriodicWorkRequest.Builder(
workerClass,
effectiveIntervalMs, TimeUnit.MILLISECONDS,
effectiveFlexMs, TimeUnit.MILLISECONDS
)
} else {
PeriodicWorkRequest.Builder(workerClass, effectiveIntervalMs, TimeUnit.MILLISECONDS)
}

requestBuilder
.setConstraints(wmConstraintsBuilder.build())
.setInputData(dataBuilder.build())
.addTag(NativeTaskScheduler.TAG_KMP_TASK)
.addTag("worker-$workerClassName")
.addTag(taskId)
.addTag(workerClassName)
if (tag != null) requestBuilder.addTag(tag)

val wmBackoffPolicy = when (constraints.backoffPolicy) {
BackoffPolicy.LINEAR -> androidx.work.BackoffPolicy.LINEAR
else -> androidx.work.BackoffPolicy.EXPONENTIAL
}
requestBuilder.setBackoffCriteria(wmBackoffPolicy, constraints.backoffDelayMs, TimeUnit.MILLISECONDS)

// ExistingPeriodicWorkPolicy.REPLACE was deprecated in WorkManager 2.8.0;
// CANCEL_AND_REENQUEUE is the correct replacement.
val workPolicy = when (policy) {
ExistingPolicy.REPLACE -> ExistingPeriodicWorkPolicy.CANCEL_AND_REENQUEUE
else -> ExistingPeriodicWorkPolicy.KEEP
}

WorkManager.getInstance(context).enqueueUniquePeriodicWork(taskId, workPolicy, requestBuilder.build())
NativeLogger.d("✅ Periodic '$taskId' enqueued via direct WorkManager (interval=${effectiveIntervalMs}ms, flex=${flexMs}ms, policy=$workPolicy)")
}

Loading
Loading