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
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

---

## [1.0.7] - 2026-03-04

### Fixed

- **iOS: Custom workers silently failed — input data never reached `doWork()`** (`NativeWorkmanagerPlugin.swift`, `BGTaskSchedulerManager.swift`)
- **Root cause:** `CustomNativeWorker.toMap()` encodes user input under the `"input"` key as a pre-serialised JSON string. `executeWorkerSync()` (the real iOS execution path for all foreground tasks) was passing the full `workerConfig` to `doWork()`, so workers received outer wrapper fields (`workerType`, `className`, `input`) instead of their own parameters (`inputPath`, `quality`, …). All custom-worker invocations silently returned failure since the initial implementation.
- **Fix:** Extract `workerConfig["input"] as? String` when present and pass that directly to `doWork()`; fall back to full config for built-in workers (which have no `"input"` key). Applied consistently to both the foreground path (`executeWorkerSync`) and the background path (`BGTaskSchedulerManager.executeWorker`).

### Improved

- **`doc/use-cases/07-custom-native-workers.md`** — Corrected return types throughout (`Boolean`/`Bool` → `WorkerResult`), updated Android registration hook to `configureFlutterEngine`, updated iOS AppDelegate to `@main` + `import native_workmanager`, fixed broken file reference, aligned all code examples with the actual public API.
- **`README.md`** — Added "Custom Kotlin/Swift workers (no fork)" row to feature comparison table; added full custom-worker showcase section with Kotlin, Swift, and Dart examples.
- **Demo app** — Custom Workers tab now exercises real `NativeWorker.custom()` calls against `ImageCompressWorker` instead of placeholder `DartWorker` stubs.
- **Integration tests** — Added Group 10 "Custom Native Workers" (3 tests: success path, graceful failure on missing input, unknown-class error event). Total passing tests: 32.
- **`SimpleAndroidWorkerFactory`** — Unknown worker class now logs a clear `Log.e` message pointing to `setUserFactory()` instead of silently returning `null`.

---

## [1.0.6] - 2026-02-28

### Fixed
Expand Down
46 changes: 45 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ Schedule background tasks that survive app restarts, reboots, and force-quits. N
| Constraints enforced (network, charging…) | ✅ | ✅ fixed in v1.0.5 |
| Periodic tasks that actually repeat | ✅ | ✅ fixed in v1.0.5 |
| Dart callbacks for custom logic | ✅ | ✅ |
| Custom Kotlin/Swift workers (no fork) | ❌ | ✅ |

---

Expand Down Expand Up @@ -78,7 +79,50 @@ await NativeWorkManager.enqueue(
| `cryptoDecrypt` | AES-256-GCM decrypt |
| `hashFile` | MD5, SHA-1, SHA-256, SHA-512 |

Extend with your own Kotlin/Swift workers — [guide →](doc/use-cases/07-custom-native-workers.md)
---

## Custom Native Workers

Extend with your own Kotlin or Swift workers — no forking, no MethodChannel boilerplate. Runs on native thread, zero Flutter Engine overhead.

```kotlin
// Android — implement AndroidWorker
class EncryptWorker : AndroidWorker {
override suspend fun doWork(input: String?): WorkerResult {
val path = Json.parseToJsonElement(input!!).jsonObject["path"]!!.jsonPrimitive.content
// Android Keystore, Room, TensorFlow Lite — any native API
return WorkerResult.Success()
}
}
// Register in MainActivity.kt (once):
// SimpleAndroidWorkerFactory.setUserFactory { name -> if (name == "EncryptWorker") EncryptWorker() else null }
```

```swift
// iOS — implement IosWorker
class EncryptWorker: IosWorker {
func doWork(input: String?) async throws -> WorkerResult {
// CryptoKit, Core Data, Core ML — any native API
return .success()
}
}
// Register in AppDelegate.swift (once):
// IosWorkerFactory.registerWorker(className: "EncryptWorker") { EncryptWorker() }
```

```dart
// Dart — identical call on both platforms
await NativeWorkManager.enqueue(
taskId: 'encrypt-file',
trigger: TaskTrigger.oneTime(),
worker: NativeWorker.custom(
className: 'EncryptWorker',
input: {'path': '/data/document.pdf'},
),
);
```

[Full guide →](doc/use-cases/07-custom-native-workers.md) · [Architecture →](doc/EXTENSIBILITY.md)

---

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package dev.brewkits.native_workmanager

import android.content.Context
import android.util.Log
import dev.brewkits.kmpworkmanager.background.domain.AndroidWorker
import dev.brewkits.kmpworkmanager.background.domain.AndroidWorkerFactory
import dev.brewkits.native_workmanager.workers.CryptoWorker
Expand Down Expand Up @@ -81,7 +82,11 @@ class SimpleAndroidWorkerFactory(
"ImageProcessWorker" -> ImageProcessWorker()
"CryptoWorker" -> CryptoWorker()
"FileSystemWorker" -> FileSystemWorker()
else -> null
else -> {
Log.e("SimpleAndroidWorkerFactory", "Unknown worker class: '$workerClassName'. " +
"Register it via SimpleAndroidWorkerFactory.setUserFactory() in MainActivity.")
null
}
}
}
}
102 changes: 55 additions & 47 deletions doc/use-cases/07-custom-native-workers.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,29 +34,30 @@ package com.yourapp.workers
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import dev.brewkits.kmpworkmanager.background.domain.AndroidWorker
import dev.brewkits.kmpworkmanager.background.domain.WorkerResult
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import java.io.File
import java.io.FileOutputStream

class ImageCompressWorker : AndroidWorker {
override suspend fun doWork(input: String?): Boolean {
override suspend fun doWork(input: String?): WorkerResult {
try {
// Parse JSON input
val json = Json.parseToJsonElement(input ?: "{}")
val config = json.jsonObject

val inputPath = config["inputPath"]?.jsonPrimitive?.content
?: return false
?: return WorkerResult.Failure("inputPath is required")
val outputPath = config["outputPath"]?.jsonPrimitive?.content
?: return false
?: return WorkerResult.Failure("outputPath is required")
val quality = config["quality"]?.jsonPrimitive?.content?.toIntOrNull()
?: 85

// Load image
val bitmap = BitmapFactory.decodeFile(inputPath)
?: return false
?: return WorkerResult.Failure("Failed to load image at: $inputPath")

// Compress and save
val outputFile = File(outputPath)
Expand All @@ -67,11 +68,10 @@ class ImageCompressWorker : AndroidWorker {
}

bitmap.recycle()
return true
return WorkerResult.Success()

} catch (e: Exception) {
println("ImageCompressWorker error: ${e.message}")
return false
return WorkerResult.Failure("ImageCompressWorker error: ${e.message}")
}
}
}
Expand All @@ -86,37 +86,40 @@ import Foundation
import UIKit

class ImageCompressWorker: IosWorker {
func doWork(input: String?) async throws -> Bool {
func doWork(input: String?) async throws -> WorkerResult {
// Parse JSON input
guard let inputString = input,
let data = inputString.data(using: .utf8),
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let inputPath = json["inputPath"] as? String,
let outputPath = json["outputPath"] as? String else {
return false
return .failure(message: "Missing required input parameters")
}

let quality = json["quality"] as? Double ?? 0.85

// Load image
guard let image = UIImage(contentsOfFile: inputPath) else {
return false
return .failure(message: "Failed to load image at: \(inputPath)")
}

// Compress
guard let compressedData = image.jpegData(compressionQuality: quality) else {
return false
return .failure(message: "Failed to compress image")
}

// Save
let outputURL = URL(fileURLWithPath: outputPath)
try? FileManager.default.createDirectory(
try FileManager.default.createDirectory(
at: outputURL.deletingLastPathComponent(),
withIntermediateDirectories: true
)
try compressedData.write(to: outputURL)

return true
return .success(
message: "Compressed successfully",
data: ["outputPath": outputPath, "size": compressedData.count]
)
}
}
```
Expand All @@ -134,10 +137,10 @@ import dev.brewkits.native_workmanager.SimpleAndroidWorkerFactory
import com.yourapp.workers.ImageCompressWorker

class MainActivity: FlutterActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)

// Register custom workers BEFORE Flutter engine starts
// Register custom workers here — runs before any task can be scheduled
SimpleAndroidWorkerFactory.setUserFactory(object : AndroidWorkerFactory {
override fun createWorker(workerClassName: String): AndroidWorker? {
return when (workerClassName) {
Expand All @@ -158,8 +161,9 @@ In `ios/Runner/AppDelegate.swift`:
```swift
import UIKit
import Flutter
import native_workmanager

@UIApplicationMain
@main
@objc class AppDelegate: FlutterAppDelegate {
override func application(
_ application: UIApplication,
Expand Down Expand Up @@ -234,36 +238,38 @@ Future<void> compressCameraRoll() async {
**Android:**
```kotlin
class EncryptionWorker : AndroidWorker {
override suspend fun doWork(input: String?): Boolean {
override suspend fun doWork(input: String?): WorkerResult {
val json = Json.parseToJsonElement(input ?: "{}").jsonObject
val filePath = json["filePath"]?.jsonPrimitive?.content ?: return false
val password = json["password"]?.jsonPrimitive?.content ?: return false
val filePath = json["filePath"]?.jsonPrimitive?.content
?: return WorkerResult.Failure("filePath is required")
val password = json["password"]?.jsonPrimitive?.content
?: return WorkerResult.Failure("password is required")

// Use Android Keystore for encryption
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
// ... encrypt file with cipher

return true
return WorkerResult.Success()
}
}
```

**iOS:**
```swift
class EncryptionWorker: IosWorker {
func doWork(input: String?) async throws -> Bool {
func doWork(input: String?) async throws -> WorkerResult {
guard let inputString = input,
let data = inputString.data(using: .utf8),
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let filePath = json["filePath"] as? String,
let password = json["password"] as? String else {
return false
return .failure(message: "Missing required parameters")
}

// Use iOS CryptoKit for encryption
// ... encrypt file

return true
return .success()
}
}
```
Expand All @@ -272,9 +278,10 @@ class EncryptionWorker: IosWorker {

```kotlin
class BatchInsertWorker(private val database: AppDatabase) : AndroidWorker {
override suspend fun doWork(input: String?): Boolean {
override suspend fun doWork(input: String?): WorkerResult {
val json = Json.parseToJsonElement(input ?: "{}").jsonObject
val itemsArray = json["items"]?.jsonArray ?: return false
val itemsArray = json["items"]?.jsonArray
?: return WorkerResult.Failure("items array is required")

// Parse items
val items = itemsArray.map { element ->
Expand All @@ -288,7 +295,7 @@ class BatchInsertWorker(private val database: AppDatabase) : AndroidWorker {

// Batch insert using Room
database.itemDao().insertAll(items)
return true
return WorkerResult.Success()
}
}
```
Expand All @@ -314,7 +321,7 @@ class ImageCompressWorkerTest {
"""

val result = worker.doWork(input)
assertTrue(result)
assertTrue(result.success)
assertTrue(File("/sdcard/compressed.jpg").exists())
}
}
Expand Down Expand Up @@ -356,51 +363,52 @@ void main() {
### 1. Input Validation

```kotlin
override suspend fun doWork(input: String?): Boolean {
if (input == null || input.isEmpty()) {
Log.e("Worker", "Input is null or empty")
return false
override suspend fun doWork(input: String?): WorkerResult {
if (input.isNullOrEmpty()) {
return WorkerResult.Failure("Input is null or empty")
}

try {
return try {
val json = Json.parseToJsonElement(input).jsonObject
// Validate required fields
require(json.containsKey("inputPath")) { "inputPath is required" }
// ... continue
WorkerResult.Success()
} catch (e: Exception) {
Log.e("Worker", "Invalid input: ${e.message}")
return false
WorkerResult.Failure("Invalid input: ${e.message}")
}
}
```

### 2. Error Handling

```swift
func doWork(input: String?) async throws -> Bool {
func doWork(input: String?) async throws -> WorkerResult {
do {
// Your work here
return true
} catch let error as NSError {
return .success()
} catch {
// Log and return failure — don't rethrow
print("Worker error: \(error.localizedDescription)")
// Don't throw - return false instead
return false
return .failure(message: error.localizedDescription)
}
}
```

### 3. Resource Cleanup

```kotlin
override suspend fun doWork(input: String?): Boolean {
override suspend fun doWork(input: String?): WorkerResult {
var bitmap: Bitmap? = null
var outputStream: FileOutputStream? = null

try {
return try {
bitmap = BitmapFactory.decodeFile(inputPath)
?: return WorkerResult.Failure("Failed to load image")
outputStream = FileOutputStream(outputPath)
bitmap.compress(Bitmap.CompressFormat.JPEG, 85, outputStream)
return true
WorkerResult.Success()
} catch (e: Exception) {
WorkerResult.Failure("Compression failed: ${e.message}")
} finally {
bitmap?.recycle()
outputStream?.close()
Expand All @@ -418,8 +426,8 @@ override suspend fun doWork(input: String?): Boolean {

## Common Pitfalls

❌ **Don't** forget to register worker before `initialize()`
❌ **Don't** throw exceptions from `doWork()` (return false instead)
❌ **Don't** forget to register workers in `configureFlutterEngine()` (Android) or `application(_:didFinishLaunchingWithOptions:)` (iOS)
❌ **Don't** throw exceptions from `doWork()` return `.failure(message:)` instead
❌ **Don't** block on main thread (workers already run in background)
❌ **Don't** use instance methods as factories (use static/top-level)
✅ **Do** validate input thoroughly
Expand All @@ -443,7 +451,7 @@ override suspend fun doWork(input: String?): Boolean {

## Example App

See [`example/lib/tabs/custom_workers_tab.dart`](../../example/lib/tabs/custom_workers_tab.dart) for a complete working example with image compression and encryption workers.
See [`example/lib/pages/comprehensive_demo_page.dart`](../../example/lib/pages/comprehensive_demo_page.dart) (Custom Workers tab) for a working demo using `ImageCompressWorker` on both Android and iOS.

---

Expand Down
Loading
Loading