diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e5198c..6234d74 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 718f2d9..7db20c3 100644 --- a/README.md +++ b/README.md @@ -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) | ❌ | ✅ | --- @@ -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) --- diff --git a/android/src/main/kotlin/dev/brewkits/native_workmanager/SimpleAndroidWorkerFactory.kt b/android/src/main/kotlin/dev/brewkits/native_workmanager/SimpleAndroidWorkerFactory.kt index 387825c..195bb50 100644 --- a/android/src/main/kotlin/dev/brewkits/native_workmanager/SimpleAndroidWorkerFactory.kt +++ b/android/src/main/kotlin/dev/brewkits/native_workmanager/SimpleAndroidWorkerFactory.kt @@ -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 @@ -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 + } } } } diff --git a/doc/use-cases/07-custom-native-workers.md b/doc/use-cases/07-custom-native-workers.md index 08ab0e5..5f01c67 100644 --- a/doc/use-cases/07-custom-native-workers.md +++ b/doc/use-cases/07-custom-native-workers.md @@ -34,6 +34,7 @@ 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 @@ -41,22 +42,22 @@ 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) @@ -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}") } } } @@ -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] + ) } } ``` @@ -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) { @@ -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, @@ -234,16 +238,18 @@ Future 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() } } ``` @@ -251,19 +257,19 @@ class EncryptionWorker : AndroidWorker { **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() } } ``` @@ -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 -> @@ -288,7 +295,7 @@ class BatchInsertWorker(private val database: AppDatabase) : AndroidWorker { // Batch insert using Room database.itemDao().insertAll(items) - return true + return WorkerResult.Success() } } ``` @@ -314,7 +321,7 @@ class ImageCompressWorkerTest { """ val result = worker.doWork(input) - assertTrue(result) + assertTrue(result.success) assertTrue(File("/sdcard/compressed.jpg").exists()) } } @@ -356,20 +363,18 @@ 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}") } } ``` @@ -377,14 +382,14 @@ override suspend fun doWork(input: String?): Boolean { ### 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) } } ``` @@ -392,15 +397,18 @@ func doWork(input: String?) async throws -> Bool { ### 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() @@ -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 @@ -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. --- diff --git a/example/integration_test/device_integration_test.dart b/example/integration_test/device_integration_test.dart index 563a0d4..1a29207 100644 --- a/example/integration_test/device_integration_test.dart +++ b/example/integration_test/device_integration_test.dart @@ -1,6 +1,6 @@ // ignore_for_file: avoid_print // ============================================================ -// Native WorkManager v1.0.4 – DEVICE INTEGRATION TESTS +// Native WorkManager v1.0.6 – DEVICE INTEGRATION TESTS // ============================================================ // // Run on a real device or emulator (NOT unit/mock tests): @@ -16,6 +16,7 @@ // ✅ ExistingPolicy (REPLACE, KEEP) // ✅ All constraints (network, charging, heavy, backoff, systemConstraints) // ✅ All 11 workers (HTTP, File, Image, Crypto, DartWorker) +// ✅ Custom native workers (success, missing input, unknown className) // ✅ Task chains (sequential A→B→C) // ✅ Tags (assign, query, cancelByTag) // ✅ Events & Progress streams @@ -1195,4 +1196,101 @@ void main() { ); }); }); + + // ════════════════════════════════════════════════════════════ + // GROUP 10 – Custom Native Workers + // Verifies the extensibility feature end-to-end on real device. + // ImageCompressWorker is registered in: + // Android → example/android/.../MainActivity.kt + // iOS → example/ios/Runner/AppDelegate.swift + // These tests run the REAL native worker, not a simulation. + // ════════════════════════════════════════════════════════════ + group('Custom Native Workers', () { + // Uses _minimalPng (defined at top of file) — a verified valid 1×1 PNG. + // UIImage and BitmapFactory both support PNG input; the worker outputs JPEG. + + testWidgets( + 'ImageCompressWorker – compresses image and creates output file', + (tester) async { + final id = _id('custom_compress_ok'); + final inputPath = '${tmpDir.path}/custom_input.png'; + final outputPath = '${tmpDir.path}/custom_compressed.jpg'; + + await File(inputPath).writeAsBytes(_minimalPng); + + final future = _waitEvent(id, timeout: const Duration(seconds: 45)); + + await NativeWorkManager.enqueue( + taskId: id, + trigger: const TaskTrigger.oneTime(), + worker: NativeWorker.custom( + className: 'ImageCompressWorker', + input: { + 'inputPath': inputPath, + 'outputPath': outputPath, + 'quality': 80, + }, + ), + ); + + final event = await future; + expect(event, isNotNull, + reason: 'Must receive a completion event from ImageCompressWorker'); + expect(event!.success, isTrue, + reason: 'ImageCompressWorker must succeed with a valid image'); + expect(File(outputPath).existsSync(), isTrue, + reason: 'Output file must be created on disk after compression'); + }); + + testWidgets( + 'ImageCompressWorker – fails gracefully when input file is missing', + (tester) async { + final id = _id('custom_compress_no_input'); + final outputPath = '${tmpDir.path}/custom_missing_out.jpg'; + + final future = _waitEvent(id, timeout: const Duration(seconds: 30)); + + await NativeWorkManager.enqueue( + taskId: id, + trigger: const TaskTrigger.oneTime(), + worker: NativeWorker.custom( + className: 'ImageCompressWorker', + input: { + 'inputPath': '/nonexistent/path/does_not_exist.jpg', + 'outputPath': outputPath, + 'quality': 80, + }, + ), + ); + + final event = await future; + expect(event, isNotNull, + reason: 'Worker must emit a completion event, not hang'); + expect(event!.success, isFalse, + reason: 'Worker must return failure when input file does not exist'); + }); + + testWidgets( + 'Custom worker – unregistered className emits failure event (no crash)', + (tester) async { + final id = _id('custom_unknown_class'); + + final future = _waitEvent(id, timeout: const Duration(seconds: 30)); + + await NativeWorkManager.enqueue( + taskId: id, + trigger: const TaskTrigger.oneTime(), + worker: NativeWorker.custom( + className: 'ThisWorkerIsNotRegistered_xyz123', + input: {'key': 'value'}, + ), + ); + + final event = await future; + expect(event, isNotNull, + reason: 'Unknown worker must emit a completion event, not hang'); + expect(event!.success, isFalse, + reason: 'Unknown className must produce a failure, not silently succeed'); + }); + }); } diff --git a/example/lib/pages/comprehensive_demo_page.dart b/example/lib/pages/comprehensive_demo_page.dart index 8eddd76..3b5fada 100644 --- a/example/lib/pages/comprehensive_demo_page.dart +++ b/example/lib/pages/comprehensive_demo_page.dart @@ -1204,6 +1204,21 @@ class _CustomWorkersTab extends StatelessWidget { const _CustomWorkersTab({required this.onResult}); + // Minimal valid 1×1 red pixel PNG — used to give ImageCompressWorker a real + // file to process. UIImage (iOS) and BitmapFactory (Android) both support PNG + // input; the worker re-encodes the result as JPEG. + static const List _kMinimalPng = [ + 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, // PNG signature + 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, // IHDR + 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, // 1×1 + 0x08, 0x02, 0x00, 0x00, 0x00, 0x90, 0x77, 0x53, // RGB, CRC + 0xDE, 0x00, 0x00, 0x00, 0x0C, 0x49, 0x44, 0x41, // IDAT + 0x54, 0x78, 0xDA, 0x63, 0xF8, 0xCF, 0xC0, 0x00, // zlib data + 0x00, 0x03, 0x01, 0x01, 0x00, 0xF7, 0x03, 0x41, // Adler-32 + CRC + 0x43, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4E, // IEND + 0x44, 0xAE, 0x42, 0x60, 0x82, // IEND CRC + ]; + @override Widget build(BuildContext context) { return ListView( @@ -1235,47 +1250,78 @@ DartWorker( _DemoCard( title: '2. Custom Native Worker (Kotlin)', - description: 'Write your own Kotlin worker for Android', + description: 'ImageCompressWorker registered in MainActivity.kt — runs real Kotlin code', icon: Icons.android, code: ''' +// Kotlin: class ImageCompressWorker : AndroidWorker { +// override suspend fun doWork(input: String?): WorkerResult { ... } +// } +// Registered in MainActivity.kt via SimpleAndroidWorkerFactory.setUserFactory(...) NativeWorker.custom( - className: 'MyCustomWorker', - input: {'key': 'value'}, -) -// Implement in Kotlin: -// class MyCustomWorker : AndroidWorker''', + className: 'ImageCompressWorker', + input: { + 'inputPath': '/tmp/nwm_demo.png', + 'outputPath': '/tmp/nwm_demo_out.jpg', + 'quality': 80, + }, +)''', onRun: () async { - // NOTE: In this demo app, MyCustomWorker is not actually implemented in Native. - // We substitute it with DartWorker to simulate a successful run for demo purposes, - // otherwise it would throw "Worker factory returned null" and crash/fail. + final tmpDir = Directory.systemTemp.path; + final inputPath = '$tmpDir/nwm_demo.png'; + final outputPath = '$tmpDir/nwm_kotlin_compressed.jpg'; + // Write a minimal valid JPEG so the worker has a real file to process + await File(inputPath).writeAsBytes(_kMinimalPng); await NativeWorkManager.enqueue( - taskId: 'comprehensive-custom-kotlin', + taskId: 'custom-kotlin-${DateTime.now().millisecondsSinceEpoch}', trigger: TaskTrigger.oneTime(), - worker: DartWorker(callbackId: 'customTask'), // Simulated + worker: NativeWorker.custom( + className: 'ImageCompressWorker', + input: { + 'inputPath': inputPath, + 'outputPath': outputPath, + 'quality': 80, + }, + ), ); - onResult('🤖 Custom Kotlin Worker scheduled (Simulated)'); + onResult('ImageCompressWorker (Kotlin) enqueued — real native worker'); }, ), _DemoCard( title: '3. Custom Native Worker (Swift)', - description: 'Write your own Swift worker for iOS', + description: 'ImageCompressWorker registered in AppDelegate.swift — runs real Swift code', icon: Icons.apple, code: ''' +// Swift: class ImageCompressWorker: IosWorker { +// func doWork(input: String?) async throws -> WorkerResult { ... } +// } +// Registered in AppDelegate.swift via IosWorkerFactory.registerWorker(...) NativeWorker.custom( - className: 'MyCustomWorker', - input: {'key': 'value'}, -) -// Implement in Swift: -// class MyCustomWorker: IosWorker''', + className: 'ImageCompressWorker', + input: { + 'inputPath': '/tmp/nwm_demo.png', + 'outputPath': '/tmp/nwm_demo_out.jpg', + 'quality': 60, + }, +)''', onRun: () async { - // NOTE: Simulated for demo stability + final tmpDir = Directory.systemTemp.path; + final inputPath = '$tmpDir/nwm_demo.png'; + final outputPath = '$tmpDir/nwm_swift_compressed.jpg'; + await File(inputPath).writeAsBytes(_kMinimalPng); await NativeWorkManager.enqueue( - taskId: 'comprehensive-custom-swift', + taskId: 'custom-swift-${DateTime.now().millisecondsSinceEpoch}', trigger: TaskTrigger.oneTime(), - worker: DartWorker(callbackId: 'customTask'), // Simulated + worker: NativeWorker.custom( + className: 'ImageCompressWorker', + input: { + 'inputPath': inputPath, + 'outputPath': outputPath, + 'quality': 60, + }, + ), ); - onResult('🍎 Custom Swift Worker scheduled (Simulated)'); + onResult('ImageCompressWorker (Swift) enqueued — real native worker'); }, ), ], diff --git a/ios/native_workmanager/Sources/native_workmanager/NativeWorkmanagerPlugin.swift b/ios/native_workmanager/Sources/native_workmanager/NativeWorkmanagerPlugin.swift index 4508142..12c8f6a 100644 --- a/ios/native_workmanager/Sources/native_workmanager/NativeWorkmanagerPlugin.swift +++ b/ios/native_workmanager/Sources/native_workmanager/NativeWorkmanagerPlugin.swift @@ -673,14 +673,24 @@ public class NativeWorkmanagerPlugin: NSObject, FlutterPlugin { return await withCheckedContinuation { (continuation: CheckedContinuation) in DispatchQueue.global(qos: qosClass).async { Task { - // Convert worker config to JSON string - guard let jsonData = try? JSONSerialization.data(withJSONObject: workerConfig), - let inputJson = String(data: jsonData, encoding: .utf8) else { - print("NativeWorkManager: Error serializing worker config") - let result = WorkerResult.failure(message: "Config serialization failed") - self.emitTaskEvent(taskId: taskId, success: false, message: result.message) - continuation.resume(returning: result) - return + // Custom workers (NativeWorker.custom) store user data under the + // "input" key as a pre-encoded JSON string. Pass that directly to + // doWork() so the worker reads its own fields without knowing the + // outer workerConfig structure — consistent with Android behavior. + // Built-in workers have no "input" key, so they receive the full config. + let inputJson: String + if let nestedInput = workerConfig["input"] as? String { + inputJson = nestedInput + } else { + guard let jsonData = try? JSONSerialization.data(withJSONObject: workerConfig), + let configJson = String(data: jsonData, encoding: .utf8) else { + print("NativeWorkManager: Error serializing worker config") + let result = WorkerResult.failure(message: "Config serialization failed") + self.emitTaskEvent(taskId: taskId, success: false, message: result.message) + continuation.resume(returning: result) + return + } + inputJson = configJson } // Create worker diff --git a/ios/native_workmanager/Sources/native_workmanager/scheduling/BGTaskSchedulerManager.swift b/ios/native_workmanager/Sources/native_workmanager/scheduling/BGTaskSchedulerManager.swift index ee2dcc3..c4f8cde 100644 --- a/ios/native_workmanager/Sources/native_workmanager/scheduling/BGTaskSchedulerManager.swift +++ b/ios/native_workmanager/Sources/native_workmanager/scheduling/BGTaskSchedulerManager.swift @@ -277,10 +277,21 @@ class BGTaskSchedulerManager { } do { - let configData = try JSONEncoder().encode(taskInfo.workerConfig) - let configJson = String(data: configData, encoding: .utf8) + // Custom workers (via NativeWorker.custom) store user data under the + // "input" key as a pre-encoded JSON string. Pass that directly to + // doWork() so the worker can parse it without knowing the outer + // workerConfig structure — matching Android's doWork(input:) behavior. + // Built-in workers have no "input" key, so they receive the full config. + let inputForWorker: String? + if let inputAnyCodable = taskInfo.workerConfig["input"], + let inputString = inputAnyCodable.value as? String { + inputForWorker = inputString + } else { + let configData = try JSONEncoder().encode(taskInfo.workerConfig) + inputForWorker = String(data: configData, encoding: .utf8) + } - let result = try await worker.doWork(input: configJson) + let result = try await worker.doWork(input: inputForWorker) print("BGTaskSchedulerManager: Worker execution \(result.success ? "succeeded" : "failed")") // Remove from pending tasks on success diff --git a/pubspec.yaml b/pubspec.yaml index 1bb4cad..32d503a 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: native_workmanager description: "Background task manager for Flutter using platform-native APIs. Zero Flutter Engine overhead for I/O operations." -version: 1.0.6 +version: 1.0.7 homepage: https://github.com/brewkits/native_workmanager repository: https://github.com/brewkits/native_workmanager issue_tracker: https://github.com/brewkits/native_workmanager/issues