Skip to content

Commit 6dca708

Browse files
feat: release v1.2.0 (#11)
- Android cold-start DartWorker persistence: callbackHandle persisted to SharedPreferences and restored when WorkManager restarts the process after app kill. FlutterLoader initialized before lookupCallbackInformation() to prevent UnsatisfiedLinkError on cold process start. - iOS cold-start: callbackHandle persisted to UserDefaults and restored in hasCallbackHandle/ensureEngineInitialized. - Remote trigger direct commands (enqueue_task, enqueue_chain, enqueue_graph, offline_queue_enqueue) executed without waking Flutter. - HMAC SHA-256 signature verification for remote triggers. - Global middleware API: HeaderMiddleware, LoggingMiddleware, RemoteConfigMiddleware. - Task Graphs (DAG) on Android with DFS cycle detection. - DevTools extension real-time event streaming via developer.postEvent. - native_workmanager_gen code generation enhancements. - Replace bare Log.d/e with NativeLogger in FlutterEngineManager and Initialize. - Fix version strings: android/build.gradle and podspec 2.4.0 → 1.2.0. - Remove stray doc/internal/test.kt.
1 parent 4fd0b64 commit 6dca708

72 files changed

Lines changed: 3919 additions & 1358 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.

‎.gitignore‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,12 @@ migrate_working_dir/
1919
*_OLD*
2020
*_BACKUP*
2121

22+
# Gemini
23+
GEMINI.md
24+
2225
# Claude Code
2326
.claude/
27+
CLAUDE.md
2428

2529
# IntelliJ related
2630
*.iml
@@ -63,3 +67,5 @@ SPM_ANALYSIS.md
6367
# (dart pub uses a custom gitignore parser that may differ from git's behaviour)
6468
!ios/native_workmanager/Package.resolved
6569
!ios/native_workmanager/Sources/native_workmanager/utils/HttpSecurityHelper.swift
70+
\ndoc/internal/
71+
benchmark/

‎CHANGELOG.md‎

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

88
---
99

10+
## [1.2.0] - 2026-04-17
11+
12+
### Added
13+
- **Android cold-start `DartWorker` persistence**: `DartWorker` tasks now execute reliably after app kill. The `callbackHandle` is persisted to `SharedPreferences` (Android) and `UserDefaults` (iOS) during `initialize()` and automatically restored when WorkManager restarts the process. Requires host app to implement `Configuration.Provider` — see `doc/ANDROID_SETUP.md`.
14+
- **Advanced Remote Trigger**: Support for direct commands in push payloads (`native_wm` key). Execute tasks, chains (`enqueue_chain`), graphs, and offline queues without waking Flutter. Both Android and iOS support executing task chains completely in the background.
15+
- **HMAC Security**: Robust HMAC SHA-256 signature verification for remote triggers (supporting nested objects) to prevent unauthorized task execution.
16+
- **Real-time Observability**: DevTools extension now supports real-time event streaming via `developer.postEvent`.
17+
- **Global Middleware API**: Global interceptors for task configuration (Headers, RemoteConfig, Logging).
18+
- **Code Generation Enhancements**: `native_workmanager_gen` now generates type-safe enqueue wrappers and automatic worker registries from `@WorkerCallback` annotations.
19+
- **Task Graphs (DAG)**: Support for complex non-linear task dependencies on Android.
20+
1021
## [1.1.2] - 2026-04-14
1122

1223
### Added
@@ -865,7 +876,7 @@ Built on [kmpworkmanager v2.3.9](https://github.com/pablichjenkov/kmpworkmanager
865876

866877
---
867878

868-
**Latest Version:** 1.1.2
879+
**Latest Version:** 1.2.0
869880
**Status:** Production Ready - Stable release for all production apps
870881
**KMP Parity:** 100% (kmpworkmanager v2.3.9)
871882
**Platforms:** Android | iOS

‎README.md‎

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
<a href="https://github.com/brewkits/native_workmanager/actions"><img src="https://github.com/brewkits/native_workmanager/workflows/ci/badge.svg" alt="CI"></a>
1616
<a href="https://opensource.org/licenses/MIT"><img src="https://img.shields.io/badge/License-MIT-blue.svg" alt="MIT"></a>
1717
<img src="https://img.shields.io/badge/Android-8.0%2B-brightgreen.svg" alt="Android 8.0+">
18-
<img src="https://img.shields.io/badge/iOS-14.0%2B-lightgrey.svg" alt="iOS 14.0+">
18+
<img src="https://img.shields.io/badge/iOS-13.0%2B-lightgrey.svg" alt="iOS 13.0+">
1919
</p>
2020

2121
---
@@ -46,7 +46,7 @@ No boilerplate. No native code to write. No `AndroidManifest.xml` changes. Each
4646

4747
```yaml
4848
dependencies:
49-
native_workmanager: ^1.1.2
49+
native_workmanager: ^1.2.0
5050
```
5151
5252
**2. Initialize once in `main()`:**
@@ -93,6 +93,7 @@ The dominant `workmanager` plugin spins up a **full Flutter Engine per backgroun
9393
| Task chains (A→B→C) | ❌ | ✅ (persist across reboots) |
9494
| Per-task progress stream | ❌ | ✅ |
9595
| Survives device reboot | ✅ | ✅ |
96+
| Remote Trigger (Push) | ❌ | ✅ (FCM/APNs + HMAC Security) |
9697
| Custom Dart workers | ✅ | ✅ (opt-in via `DartWorker`) |
9798

9899
> **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.
@@ -206,6 +207,19 @@ await NativeWorkManager.enqueue(
206207

207208
Dart workers boot a headless Flutter isolate (~50 MB, 1–2 s cold start). The isolate is cached for 5 minutes so back-to-back tasks pay the boot cost only once. For HTTP and file tasks, use native workers instead.
208209

210+
> **Android killed-app support** — When Android kills your app and WorkManager later fires a `DartWorker`, the process restarts without Flutter. The plugin automatically restores the `callbackHandle` from `SharedPreferences`, but your `Application` class must implement `Configuration.Provider` so WorkManager uses the plugin's `WorkerFactory`. One-time setup — see **[Android Setup Guide](doc/ANDROID_SETUP.md)**.
211+
212+
### Code generation for DartWorker
213+
214+
The companion [`native_workmanager_gen`](https://pub.dev/packages/native_workmanager_gen) package generates type-safe callback IDs and a worker registry from `@WorkerCallback` annotations, eliminating string-based registration and magic IDs:
215+
216+
```dart
217+
@WorkerCallback('health-sync')
218+
Future<bool> syncHealthData(Map<String, dynamic>? input) async { ... }
219+
220+
// Generated: WorkerCallbacks.healthSync, auto-registered in WorkerRegistry
221+
```
222+
209223
---
210224

211225
## Platform Support
@@ -345,14 +359,15 @@ NativeWorkManager.events.listen((event) {
345359

346360
## Documentation
347361

348-
| Guide | |
362+
| Guide | Description |
349363
|---|---|
350364
| [Getting Started](doc/GETTING_STARTED.md) | Full setup walkthrough |
351365
| [API Reference](doc/API_REFERENCE.md) | All public types and methods |
352-
| [Migration from workmanager](doc/MIGRATION_GUIDE.md) | Switch in under 10 minutes |
366+
| [Android Setup Guide](doc/ANDROID_SETUP.md) | DartWorker killed-app persistence |
353367
| [iOS Setup Guide](doc/IOS_SETUP_GUIDE.md) | BGTaskScheduler details |
354-
| [Architecture](doc/ARCHITECTURE_ANALYSIS.md) | How zero-engine execution works |
368+
| [Migration from workmanager](doc/MIGRATION_GUIDE.md) | Switch in under 10 minutes |
355369
| [Security](doc/SECURITY.md) | SSRF, path traversal, data redaction |
370+
| [native_workmanager_gen](https://pub.dev/packages/native_workmanager_gen) | Code generator for type-safe DartWorker callbacks |
356371

357372
---
358373

‎ROADMAP.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ Our mission is to provide the most robust, efficient, and secure background exec
2222
- [ ] **Offline Queue Pattern:** Built-in pattern for queuing tasks while offline with automatic file/database-backed retry.
2323
- [ ] **Middleware / Decorator API:** Intercept task execution for custom logging, analytics, or global headers.
2424

25-
[Read the detailed Phase 2 Architectural Proposals →](doc/PHASE_2_PROPOSALS.md)
25+
[Phase 2 Architectural Proposals available in internal documentation]
2626

2727
---
2828

‎android/build.gradle‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
group = "dev.brewkits.native_workmanager"
2-
version = "1.1.2"
2+
version = "1.2.0"
33

44
apply plugin: "com.android.library"
55
apply plugin: "kotlin-android"

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

Lines changed: 10 additions & 110 deletions
Original file line numberDiff line numberDiff line change
@@ -26,66 +26,17 @@ internal fun NativeWorkmanagerPlugin.handleEnqueueChain(call: MethodCall, result
2626
@Suppress("UNCHECKED_CAST")
2727
val steps = call.argument<List<List<Map<String, Any?>>>>("steps") ?: emptyList()
2828

29-
if (steps.isEmpty() || steps[0].isEmpty()) {
30-
return@launch result.error("CHAIN_ERROR", "Chain must have at least one task", null)
31-
}
32-
33-
val chainId = "${chainName}_${java.util.UUID.randomUUID()}"
34-
val workManager = WorkManager.getInstance(context)
35-
val allTaskIds = mutableListOf<String>()
36-
37-
// Build OneTimeWorkRequest for each step tagged with its task ID.
38-
val stepWorkRequests: List<List<OneTimeWorkRequest>> = steps.mapIndexed { stepIndex, parallelTasks ->
39-
@Suppress("UNCHECKED_CAST")
40-
(parallelTasks as List<Map<String, Any?>>).map { taskData ->
41-
val taskId = taskData["id"] as? String ?: java.util.UUID.randomUUID().toString()
42-
allTaskIds.add(taskId)
43-
44-
val workerClassName = taskData["workerClassName"] as? String ?: ""
45-
@Suppress("UNCHECKED_CAST")
46-
val workerConfig = taskData["workerConfig"] as? Map<String, Any?>
47-
48-
// Persist each step to ChainStore for resume and Dart visibility.
49-
withContext(Dispatchers.IO) {
50-
chainStore.addChainStep(chainId, stepIndex, taskId, "pending")
51-
52-
// Also persist to TaskStore so allTasks() surfaces chain nodes
53-
taskStore.upsert(
54-
taskId = taskId,
55-
tag = chainName,
56-
status = "pending",
57-
workerClassName = workerClassName,
58-
workerConfig = if (workerConfig != null) sanitizeConfig(toJson(workerConfig)) else null
59-
)
60-
}
61-
buildChainStepRequest(taskId, taskData)
29+
dev.brewkits.native_workmanager.utils.ChainHelper.enqueueChain(
30+
context = context,
31+
taskStore = taskStore,
32+
chainStore = chainStore,
33+
chainName = chainName,
34+
steps = steps,
35+
onObserveTaskId = { taskId ->
36+
taskStatuses[taskId] = "pending"
37+
observeChainStepCompletion(taskId, chainId = null) // We pass null because ChainHelper already handled ChainStore persistence
6238
}
63-
}
64-
65-
// Persist chain header BEFORE enqueuing (so resume can find it even if killed immediately).
66-
withContext(Dispatchers.IO) {
67-
chainStore.upsertChain(
68-
chainId = chainId,
69-
chainName = chainName,
70-
totalSteps = steps.size,
71-
status = "running"
72-
)
73-
}
74-
75-
// Enqueue as a WorkManager chain.
76-
var continuation = workManager.beginWith(stepWorkRequests[0])
77-
for (i in 1 until stepWorkRequests.size) {
78-
continuation = continuation.then(stepWorkRequests[i])
79-
}
80-
continuation.enqueue()
81-
82-
NativeLogger.d("✅ Chain scheduled: $chainName/$chainId (${steps.size} steps), IDs: $allTaskIds")
83-
84-
// Observe each chain step by its task-ID tag and emit events on completion.
85-
for (taskId in allTaskIds) {
86-
taskStatuses[taskId] = "pending"
87-
observeChainStepCompletion(taskId, chainId = chainId)
88-
}
39+
)
8940

9041
result.success("ACCEPTED")
9142
} catch (e: Exception) {
@@ -121,57 +72,6 @@ internal suspend fun NativeWorkmanagerPlugin.resumePendingChains() {
12172
}
12273
}
12374

124-
/**
125-
* Build a OneTimeWorkRequest for a single chain step.
126-
* The task ID is added as a WorkManager tag so we can observe by tag later.
127-
*/
128-
internal fun NativeWorkmanagerPlugin.buildChainStepRequest(taskId: String, taskData: Map<String, Any?>): OneTimeWorkRequest {
129-
val workerClassName = taskData["workerClassName"] as? String ?: ""
130-
@Suppress("UNCHECKED_CAST")
131-
val workerConfig = taskData["workerConfig"] as? Map<String, Any?>
132-
// Custom workers (NativeWorker.custom) carry user data under "input" key as a
133-
// pre-encoded JSON string. Pass that directly so the custom worker receives its
134-
// own fields — matching handleEnqueue and iOS executeWorkerSync behaviour.
135-
// Built-in workers receive the full config enriched with __taskId for progress.
136-
val inputJson: String? = when {
137-
workerConfig == null -> null
138-
workerConfig["workerType"] == "custom" -> workerConfig["input"] as? String
139-
else -> {
140-
val enrichedConfig = workerConfig.toMutableMap()
141-
if (taskId.isNotEmpty()) enrichedConfig["__taskId"] = taskId
142-
toJson(enrichedConfig)
143-
}
144-
}
145-
@Suppress("UNCHECKED_CAST")
146-
val constraintsMap = taskData["constraints"] as? Map<String, Any?>
147-
val constraints = parseConstraints(constraintsMap)
148-
149-
val dataBuilder = Data.Builder().putString("workerClassName", workerClassName)
150-
if (inputJson != null) dataBuilder.putString("inputJson", inputJson)
151-
152-
val networkType = when {
153-
constraints.requiresUnmeteredNetwork -> NetworkType.UNMETERED
154-
constraints.requiresNetwork -> NetworkType.CONNECTED
155-
else -> NetworkType.NOT_REQUIRED
156-
}
157-
val wmConstraintsBuilder = androidx.work.Constraints.Builder()
158-
.setRequiredNetworkType(networkType)
159-
.setRequiresCharging(constraints.requiresCharging)
160-
val sysConstraints = constraints.systemConstraints ?: emptySet()
161-
if (sysConstraints.contains(SystemConstraint.DEVICE_IDLE)) wmConstraintsBuilder.setRequiresDeviceIdle(true)
162-
if (sysConstraints.contains(SystemConstraint.REQUIRE_BATTERY_NOT_LOW)) wmConstraintsBuilder.setRequiresBatteryNotLow(true)
163-
164-
val workerClass = if (constraints.isHeavyTask) KmpHeavyWorker::class.java else KmpWorker::class.java
165-
return OneTimeWorkRequest.Builder(workerClass)
166-
.setConstraints(wmConstraintsBuilder.build())
167-
.setInputData(dataBuilder.build())
168-
.addTag(NativeTaskScheduler.TAG_KMP_TASK)
169-
.addTag("worker-$workerClassName")
170-
.addTag(taskId) // Critical: allows observeChainStepCompletion to find this work
171-
.addTag(workerClassName)
172-
.build()
173-
}
174-
17575
/**
17676
* Observe a single chain step by its task-ID tag and emit an event when it reaches a terminal state.
17777
* Uses getWorkInfosByTagFlow since chain steps are NOT unique work.

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

Lines changed: 3 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import dev.brewkits.kmpworkmanager.background.data.NativeTaskScheduler
1414
import dev.brewkits.kmpworkmanager.background.domain.*
1515
import dev.brewkits.native_workmanager.notification.DownloadNotificationManager
1616
import dev.brewkits.native_workmanager.store.TaskStore.Companion.sanitizeConfig
17+
import dev.brewkits.native_workmanager.utils.MappingUtils.toJson
1718
import dev.brewkits.native_workmanager.workers.HttpDownloadWorker
1819
import dev.brewkits.native_workmanager.workers.utils.HostConcurrencyManager
1920
import dev.brewkits.native_workmanager.workers.utils.KeystorePasswordVault
@@ -598,50 +599,8 @@ internal fun NativeWorkmanagerPlugin.handleGetTaskRecord(call: MethodCall, resul
598599
}
599600
}
600601

601-
internal fun NativeWorkmanagerPlugin.parseConstraints(map: Map<String, Any?>?): Constraints {
602-
if (map == null) return Constraints()
603-
604-
val requiresNetwork = map["requiresNetwork"] as? Boolean ?: false
605-
val requiresUnmeteredNetwork = map["requiresUnmeteredNetwork"] as? Boolean ?: false
606-
val requiresCharging = map["requiresCharging"] as? Boolean ?: false
607-
val allowWhileIdle = map["allowWhileIdle"] as? Boolean ?: false
608-
val isHeavyTask = map["isHeavyTask"] as? Boolean ?: false
609-
val backoffDelayMs = (map["backoffDelayMs"] as? Number)?.toLong() ?: 30_000L
610-
611-
val backoffPolicy = when ((map["backoffPolicy"] as? String)?.lowercase()) {
612-
"linear" -> BackoffPolicy.LINEAR
613-
else -> BackoffPolicy.EXPONENTIAL
614-
}
615-
616-
val systemConstraintNames = map["systemConstraints"] as? List<*> ?: emptyList<Any>()
617-
val systemConstraints: MutableSet<SystemConstraint> = systemConstraintNames
618-
.filterIsInstance<String>()
619-
.mapNotNull { name ->
620-
when (name) {
621-
"allowLowStorage" -> SystemConstraint.ALLOW_LOW_STORAGE
622-
"allowLowBattery" -> SystemConstraint.ALLOW_LOW_BATTERY
623-
"requireBatteryNotLow" -> SystemConstraint.REQUIRE_BATTERY_NOT_LOW
624-
"deviceIdle" -> SystemConstraint.DEVICE_IDLE
625-
else -> null
626-
}
627-
}.toMutableSet()
628-
629-
// Merge legacy boolean flags into systemConstraints if not already covered
630-
if (map["requiresDeviceIdle"] as? Boolean == true) systemConstraints.add(SystemConstraint.DEVICE_IDLE)
631-
if (map["requiresBatteryNotLow"] as? Boolean == true) systemConstraints.add(SystemConstraint.REQUIRE_BATTERY_NOT_LOW)
632-
// requiresStorageNotLow has no direct SystemConstraint equivalent — intentionally skipped
633-
634-
return Constraints(
635-
requiresNetwork = requiresNetwork,
636-
requiresUnmeteredNetwork = requiresUnmeteredNetwork,
637-
requiresCharging = requiresCharging,
638-
allowWhileIdle = allowWhileIdle,
639-
isHeavyTask = isHeavyTask,
640-
backoffPolicy = backoffPolicy,
641-
backoffDelayMs = backoffDelayMs,
642-
systemConstraints = systemConstraints
643-
)
644-
}
602+
internal fun NativeWorkmanagerPlugin.parseConstraints(map: Map<String, Any?>?): Constraints =
603+
dev.brewkits.native_workmanager.utils.MappingUtils.parseConstraints(map)
645604

646605
/**
647606
* Schedules a OneTime task directly via WorkManager, bypassing kmpworkmanager.

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import androidx.work.WorkInfo
77
import androidx.work.WorkManager
88
import dev.brewkits.native_workmanager.engine.TaskEventBus
99
import dev.brewkits.native_workmanager.notification.DownloadNotificationManager
10+
import dev.brewkits.native_workmanager.utils.MappingUtils.toJson
1011
import dev.brewkits.native_workmanager.workers.utils.ProgressReporter
1112
import kotlinx.coroutines.*
1213
import kotlinx.coroutines.flow.collect

0 commit comments

Comments
 (0)