Skip to content

Commit c26ea4e

Browse files
Merge pull request #3 from brewkits/release/v1.0.7
fix: iOS custom workers silently failed — input not passed to doWork() (v1.0.7)
2 parents 094ada8 + 2a41a57 commit c26ea4e

File tree

9 files changed

+324
-84
lines changed

9 files changed

+324
-84
lines changed

CHANGELOG.md

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

88
---
99

10+
## [1.0.7] - 2026-03-04
11+
12+
### Fixed
13+
14+
- **iOS: Custom workers silently failed — input data never reached `doWork()`** (`NativeWorkmanagerPlugin.swift`, `BGTaskSchedulerManager.swift`)
15+
- **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.
16+
- **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`).
17+
18+
### Improved
19+
20+
- **`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.
21+
- **`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.
22+
- **Demo app** — Custom Workers tab now exercises real `NativeWorker.custom()` calls against `ImageCompressWorker` instead of placeholder `DartWorker` stubs.
23+
- **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.
24+
- **`SimpleAndroidWorkerFactory`** — Unknown worker class now logs a clear `Log.e` message pointing to `setUserFactory()` instead of silently returning `null`.
25+
26+
---
27+
1028
## [1.0.6] - 2026-02-28
1129

1230
### Fixed

README.md

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ Schedule background tasks that survive app restarts, reboots, and force-quits. N
2121
| Constraints enforced (network, charging…) || ✅ fixed in v1.0.5 |
2222
| Periodic tasks that actually repeat || ✅ fixed in v1.0.5 |
2323
| Dart callbacks for custom logic |||
24+
| Custom Kotlin/Swift workers (no fork) |||
2425

2526
---
2627

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

81-
Extend with your own Kotlin/Swift workers — [guide →](doc/use-cases/07-custom-native-workers.md)
82+
---
83+
84+
## Custom Native Workers
85+
86+
Extend with your own Kotlin or Swift workers — no forking, no MethodChannel boilerplate. Runs on native thread, zero Flutter Engine overhead.
87+
88+
```kotlin
89+
// Android — implement AndroidWorker
90+
class EncryptWorker : AndroidWorker {
91+
override suspend fun doWork(input: String?): WorkerResult {
92+
val path = Json.parseToJsonElement(input!!).jsonObject["path"]!!.jsonPrimitive.content
93+
// Android Keystore, Room, TensorFlow Lite — any native API
94+
return WorkerResult.Success()
95+
}
96+
}
97+
// Register in MainActivity.kt (once):
98+
// SimpleAndroidWorkerFactory.setUserFactory { name -> if (name == "EncryptWorker") EncryptWorker() else null }
99+
```
100+
101+
```swift
102+
// iOS — implement IosWorker
103+
class EncryptWorker: IosWorker {
104+
func doWork(input: String?) async throws -> WorkerResult {
105+
// CryptoKit, Core Data, Core ML — any native API
106+
return .success()
107+
}
108+
}
109+
// Register in AppDelegate.swift (once):
110+
// IosWorkerFactory.registerWorker(className: "EncryptWorker") { EncryptWorker() }
111+
```
112+
113+
```dart
114+
// Dart — identical call on both platforms
115+
await NativeWorkManager.enqueue(
116+
taskId: 'encrypt-file',
117+
trigger: TaskTrigger.oneTime(),
118+
worker: NativeWorker.custom(
119+
className: 'EncryptWorker',
120+
input: {'path': '/data/document.pdf'},
121+
),
122+
);
123+
```
124+
125+
[Full guide →](doc/use-cases/07-custom-native-workers.md) · [Architecture →](doc/EXTENSIBILITY.md)
82126

83127
---
84128

android/src/main/kotlin/dev/brewkits/native_workmanager/SimpleAndroidWorkerFactory.kt

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package dev.brewkits.native_workmanager
22

33
import android.content.Context
4+
import android.util.Log
45
import dev.brewkits.kmpworkmanager.background.domain.AndroidWorker
56
import dev.brewkits.kmpworkmanager.background.domain.AndroidWorkerFactory
67
import dev.brewkits.native_workmanager.workers.CryptoWorker
@@ -81,7 +82,11 @@ class SimpleAndroidWorkerFactory(
8182
"ImageProcessWorker" -> ImageProcessWorker()
8283
"CryptoWorker" -> CryptoWorker()
8384
"FileSystemWorker" -> FileSystemWorker()
84-
else -> null
85+
else -> {
86+
Log.e("SimpleAndroidWorkerFactory", "Unknown worker class: '$workerClassName'. " +
87+
"Register it via SimpleAndroidWorkerFactory.setUserFactory() in MainActivity.")
88+
null
89+
}
8590
}
8691
}
8792
}

doc/use-cases/07-custom-native-workers.md

Lines changed: 55 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -34,29 +34,30 @@ package com.yourapp.workers
3434
import android.graphics.Bitmap
3535
import android.graphics.BitmapFactory
3636
import dev.brewkits.kmpworkmanager.background.domain.AndroidWorker
37+
import dev.brewkits.kmpworkmanager.background.domain.WorkerResult
3738
import kotlinx.serialization.json.Json
3839
import kotlinx.serialization.json.jsonObject
3940
import kotlinx.serialization.json.jsonPrimitive
4041
import java.io.File
4142
import java.io.FileOutputStream
4243

4344
class ImageCompressWorker : AndroidWorker {
44-
override suspend fun doWork(input: String?): Boolean {
45+
override suspend fun doWork(input: String?): WorkerResult {
4546
try {
4647
// Parse JSON input
4748
val json = Json.parseToJsonElement(input ?: "{}")
4849
val config = json.jsonObject
4950

5051
val inputPath = config["inputPath"]?.jsonPrimitive?.content
51-
?: return false
52+
?: return WorkerResult.Failure("inputPath is required")
5253
val outputPath = config["outputPath"]?.jsonPrimitive?.content
53-
?: return false
54+
?: return WorkerResult.Failure("outputPath is required")
5455
val quality = config["quality"]?.jsonPrimitive?.content?.toIntOrNull()
5556
?: 85
5657

5758
// Load image
5859
val bitmap = BitmapFactory.decodeFile(inputPath)
59-
?: return false
60+
?: return WorkerResult.Failure("Failed to load image at: $inputPath")
6061

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

6970
bitmap.recycle()
70-
return true
71+
return WorkerResult.Success()
7172

7273
} catch (e: Exception) {
73-
println("ImageCompressWorker error: ${e.message}")
74-
return false
74+
return WorkerResult.Failure("ImageCompressWorker error: ${e.message}")
7575
}
7676
}
7777
}
@@ -86,37 +86,40 @@ import Foundation
8686
import UIKit
8787

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

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

101101
// Load image
102102
guard let image = UIImage(contentsOfFile: inputPath) else {
103-
return false
103+
return .failure(message: "Failed to load image at: \(inputPath)")
104104
}
105105

106106
// Compress
107107
guard let compressedData = image.jpegData(compressionQuality: quality) else {
108-
return false
108+
return .failure(message: "Failed to compress image")
109109
}
110110

111111
// Save
112112
let outputURL = URL(fileURLWithPath: outputPath)
113-
try? FileManager.default.createDirectory(
113+
try FileManager.default.createDirectory(
114114
at: outputURL.deletingLastPathComponent(),
115115
withIntermediateDirectories: true
116116
)
117117
try compressedData.write(to: outputURL)
118118

119-
return true
119+
return .success(
120+
message: "Compressed successfully",
121+
data: ["outputPath": outputPath, "size": compressedData.count]
122+
)
120123
}
121124
}
122125
```
@@ -134,10 +137,10 @@ import dev.brewkits.native_workmanager.SimpleAndroidWorkerFactory
134137
import com.yourapp.workers.ImageCompressWorker
135138

136139
class MainActivity: FlutterActivity() {
137-
override fun onCreate(savedInstanceState: Bundle?) {
138-
super.onCreate(savedInstanceState)
140+
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
141+
super.configureFlutterEngine(flutterEngine)
139142

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

162-
@UIApplicationMain
166+
@main
163167
@objc class AppDelegate: FlutterAppDelegate {
164168
override func application(
165169
_ application: UIApplication,
@@ -234,36 +238,38 @@ Future<void> compressCameraRoll() async {
234238
**Android:**
235239
```kotlin
236240
class EncryptionWorker : AndroidWorker {
237-
override suspend fun doWork(input: String?): Boolean {
241+
override suspend fun doWork(input: String?): WorkerResult {
238242
val json = Json.parseToJsonElement(input ?: "{}").jsonObject
239-
val filePath = json["filePath"]?.jsonPrimitive?.content ?: return false
240-
val password = json["password"]?.jsonPrimitive?.content ?: return false
243+
val filePath = json["filePath"]?.jsonPrimitive?.content
244+
?: return WorkerResult.Failure("filePath is required")
245+
val password = json["password"]?.jsonPrimitive?.content
246+
?: return WorkerResult.Failure("password is required")
241247

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

246-
return true
252+
return WorkerResult.Success()
247253
}
248254
}
249255
```
250256

251257
**iOS:**
252258
```swift
253259
class EncryptionWorker: IosWorker {
254-
func doWork(input: String?) async throws -> Bool {
260+
func doWork(input: String?) async throws -> WorkerResult {
255261
guard let inputString = input,
256262
let data = inputString.data(using: .utf8),
257263
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
258264
let filePath = json["filePath"] as? String,
259265
let password = json["password"] as? String else {
260-
return false
266+
return .failure(message: "Missing required parameters")
261267
}
262268

263269
// Use iOS CryptoKit for encryption
264270
// ... encrypt file
265271

266-
return true
272+
return .success()
267273
}
268274
}
269275
```
@@ -272,9 +278,10 @@ class EncryptionWorker: IosWorker {
272278

273279
```kotlin
274280
class BatchInsertWorker(private val database: AppDatabase) : AndroidWorker {
275-
override suspend fun doWork(input: String?): Boolean {
281+
override suspend fun doWork(input: String?): WorkerResult {
276282
val json = Json.parseToJsonElement(input ?: "{}").jsonObject
277-
val itemsArray = json["items"]?.jsonArray ?: return false
283+
val itemsArray = json["items"]?.jsonArray
284+
?: return WorkerResult.Failure("items array is required")
278285

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

289296
// Batch insert using Room
290297
database.itemDao().insertAll(items)
291-
return true
298+
return WorkerResult.Success()
292299
}
293300
}
294301
```
@@ -314,7 +321,7 @@ class ImageCompressWorkerTest {
314321
"""
315322

316323
val result = worker.doWork(input)
317-
assertTrue(result)
324+
assertTrue(result.success)
318325
assertTrue(File("/sdcard/compressed.jpg").exists())
319326
}
320327
}
@@ -356,51 +363,52 @@ void main() {
356363
### 1. Input Validation
357364

358365
```kotlin
359-
override suspend fun doWork(input: String?): Boolean {
360-
if (input == null || input.isEmpty()) {
361-
Log.e("Worker", "Input is null or empty")
362-
return false
366+
override suspend fun doWork(input: String?): WorkerResult {
367+
if (input.isNullOrEmpty()) {
368+
return WorkerResult.Failure("Input is null or empty")
363369
}
364370

365-
try {
371+
return try {
366372
val json = Json.parseToJsonElement(input).jsonObject
367-
// Validate required fields
368373
require(json.containsKey("inputPath")) { "inputPath is required" }
369374
// ... continue
375+
WorkerResult.Success()
370376
} catch (e: Exception) {
371-
Log.e("Worker", "Invalid input: ${e.message}")
372-
return false
377+
WorkerResult.Failure("Invalid input: ${e.message}")
373378
}
374379
}
375380
```
376381

377382
### 2. Error Handling
378383

379384
```swift
380-
func doWork(input: String?) async throws -> Bool {
385+
func doWork(input: String?) async throws -> WorkerResult {
381386
do {
382387
// Your work here
383-
return true
384-
} catch let error as NSError {
388+
return .success()
389+
} catch {
390+
// Log and return failure — don't rethrow
385391
print("Worker error: \(error.localizedDescription)")
386-
// Don't throw - return false instead
387-
return false
392+
return .failure(message: error.localizedDescription)
388393
}
389394
}
390395
```
391396

392397
### 3. Resource Cleanup
393398

394399
```kotlin
395-
override suspend fun doWork(input: String?): Boolean {
400+
override suspend fun doWork(input: String?): WorkerResult {
396401
var bitmap: Bitmap? = null
397402
var outputStream: FileOutputStream? = null
398403

399-
try {
404+
return try {
400405
bitmap = BitmapFactory.decodeFile(inputPath)
406+
?: return WorkerResult.Failure("Failed to load image")
401407
outputStream = FileOutputStream(outputPath)
402408
bitmap.compress(Bitmap.CompressFormat.JPEG, 85, outputStream)
403-
return true
409+
WorkerResult.Success()
410+
} catch (e: Exception) {
411+
WorkerResult.Failure("Compression failed: ${e.message}")
404412
} finally {
405413
bitmap?.recycle()
406414
outputStream?.close()
@@ -418,8 +426,8 @@ override suspend fun doWork(input: String?): Boolean {
418426

419427
## Common Pitfalls
420428

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

444452
## Example App
445453

446-
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.
454+
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.
447455

448456
---
449457

0 commit comments

Comments
 (0)