diff --git a/docs/debugging.mdx b/docs/debugging.mdx index 3626d292..23fd7ecb 100644 --- a/docs/debugging.mdx +++ b/docs/debugging.mdx @@ -69,7 +69,10 @@ class AppDelegate: FlutterAppDelegate { _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? ) -> Bool { + GeneratedPluginRegistrant.register(with: self) + WorkmanagerDebug.setCurrent(LoggingDebugHandler()) + return super.application(application, didFinishLaunchingWithOptions: launchOptions) } } @@ -77,12 +80,50 @@ class AppDelegate: FlutterAppDelegate { ### Notification Debug Handler -Shows debug information as notifications: +Shows debug information as notifications (helpful for seeing background task execution): ```swift -WorkmanagerDebug.setCurrent(NotificationDebugHandler()) +// In your AppDelegate.swift +import workmanager_apple + +@main +class AppDelegate: FlutterAppDelegate { + override func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { + GeneratedPluginRegistrant.register(with: self) + + // Set notification delegate first + UNUserNotificationCenter.current().delegate = self + + // Request notification permission + UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .badge, .sound]) { granted, error in + if granted { + print("Notification permission granted") + } + } + + // Enable notification debug handler + WorkmanagerDebug.setCurrent(NotificationDebugHandler()) + + return super.application(application, didFinishLaunchingWithOptions: launchOptions) + } + + // REQUIRED: Override to show notifications when app is in foreground + override func userNotificationCenter( + _ center: UNUserNotificationCenter, + willPresent notification: UNNotification, + withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) { + completionHandler(.alert) // Show notification banner even if app is in foreground + } +} ``` + +**Important:** The `userNotificationCenter` override is required to display debug notifications when your app is in the foreground. Without it, you'll only see notifications when the app is in the background. + + ## Custom Debug Handlers Create your own debug handler for custom logging needs: @@ -361,4 +402,4 @@ Future isTaskHealthy(String taskName, Duration maxAge) async { - [ ] Error handling with try-catch blocks - [ ] iOS 30-second execution limit respected -Remember: Background task execution is controlled by the operating system and is never guaranteed. Always design your app to work gracefully when background tasks don't run as expected. \ No newline at end of file +Remember: Background task execution is controlled by the operating system and is never guaranteed. Always design your app to work gracefully when background tasks don't run as expected. diff --git a/docs/quickstart.mdx b/docs/quickstart.mdx index a54d114f..7c66fca3 100644 --- a/docs/quickstart.mdx +++ b/docs/quickstart.mdx @@ -77,6 +77,10 @@ WorkmanagerPlugin.registerBGProcessingTask( ) ``` + +**iOS Task Identifier Matching:** The task name in your Dart code must **exactly match** the identifier in Info.plist and AppDelegate. Using short names like `"data_sync"` in Dart while having `com.yourapp.processing_task` in native code will cause `BGTaskSchedulerErrorDomain Code 3` errors. + + **Why BGTaskScheduler registration is needed:** iOS requires explicit registration of background task identifiers for security and system resource management. The task identifier in Info.plist tells iOS which background tasks your app can schedule, while the AppDelegate registration connects the identifier to the actual task handler. Background Fetch (Option A) doesn't require this since it uses the simpler, system-managed approach. @@ -108,6 +112,10 @@ WorkmanagerPlugin.registerPeriodicTask( ) ``` + +**iOS Task Identifier Matching:** The task name in your Dart code must **exactly match** the identifier in Info.plist and AppDelegate. Using short names like `"cleanup"` in Dart while having `com.yourapp.periodic_task` in native code will cause `BGTaskSchedulerErrorDomain Code 3` errors. + + **Which option to choose?** - **Option A (Background Fetch)** for non-critical updates that can happen once daily (data sync, content refresh) @@ -124,10 +132,10 @@ WorkmanagerPlugin.registerPeriodicTask( void callbackDispatcher() { Workmanager().executeTask((task, inputData) async { switch (task) { - case "data_sync": + case "com.yourapp.processing_task": // Must match Info.plist and AppDelegate await syncDataWithServer(); break; - case "cleanup": + case "com.yourapp.periodic_task": // Must match Info.plist and AppDelegate await cleanupOldFiles(); break; case Workmanager.iOSBackgroundTask: @@ -166,14 +174,14 @@ void main() { // Schedule a one-time task Workmanager().registerOneOffTask( "sync-task", - "data_sync", + "com.yourapp.processing_task", // Must match Info.plist and AppDelegate initialDelay: Duration(seconds: 10), ); // Schedule a periodic task Workmanager().registerPeriodicTask( "cleanup-task", - "cleanup", + "com.yourapp.periodic_task", // Must match Info.plist and AppDelegate frequency: Duration(hours: 24), ); ``` @@ -191,6 +199,7 @@ Your background tasks can return: - **Callback Dispatcher**: Must be a top-level function (not inside a class) - **Separate Isolate**: Background tasks run in isolation - initialize dependencies inside the task +- **iOS Task Identifiers**: When using BGTaskScheduler (Options B & C), task names in Dart must exactly match identifiers in Info.plist and AppDelegate - **Platform Differences**: - Android: Reliable background execution, 15-minute minimum frequency - iOS: 30-second limit, execution depends on user patterns and device state @@ -200,4 +209,4 @@ Your background tasks can return: - **[Task Customization](customization)** - Advanced configuration with constraints, input data, and management - **[Debugging Guide](debugging)** - Learn how to debug and troubleshoot background tasks -- **[Example App](https://github.com/fluttercommunity/flutter_workmanager/tree/main/example)** - Complete working demo \ No newline at end of file +- **[Example App](https://github.com/fluttercommunity/flutter_workmanager/tree/main/example)** - Complete working demo diff --git a/workmanager/lib/src/workmanager_impl.dart b/workmanager/lib/src/workmanager_impl.dart index c045c946..b6ef07c6 100644 --- a/workmanager/lib/src/workmanager_impl.dart +++ b/workmanager/lib/src/workmanager_impl.dart @@ -281,6 +281,30 @@ class Workmanager { ); } + /// Schedule a health research background task, currently only available on iOS 15+. + /// + /// Health research tasks use [BGHealthResearchTask] and are designed for + /// apps that need to process HealthKit data in the background. The system + /// grants these tasks more runtime than standard background tasks. + /// + /// Requires iOS 15.0 or later. Will throw [UnsupportedError] on Android. + /// + /// For iOS see Apple docs: + /// [iOS 15+ BGHealthResearchTask](https://developer.apple.com/documentation/backgroundtasks/bghealthresearchtask/) + Future registerHealthResearchTask( + String uniqueName, + String taskName, { + Duration? initialDelay, + Map? inputData, + }) async { + return _platform.registerHealthResearchTask( + uniqueName, + taskName, + initialDelay: initialDelay, + inputData: inputData, + ); + } + /// Cancels task by [uniqueName] Future cancelByUniqueName(String uniqueName) async => _platform.cancelByUniqueName(uniqueName); diff --git a/workmanager/test/workmanager_test.dart b/workmanager/test/workmanager_test.dart index 4033e2fe..fe00e19a 100644 --- a/workmanager/test/workmanager_test.dart +++ b/workmanager/test/workmanager_test.dart @@ -47,5 +47,48 @@ void main() { verify(GetIt.I().initialize(testCallBackDispatcher)); verify(GetIt.I().cancelByUniqueName(testTaskName)); }); + + test("registerHealthResearchTask - It calls methods on the mocked class", + () { + when(GetIt.I().registerHealthResearchTask( + 'com.example.health-task', + 'healthTask', + )).thenAnswer((_) => Future.value()); + + GetIt.I().registerHealthResearchTask( + 'com.example.health-task', + 'healthTask', + ); + + verify(GetIt.I().registerHealthResearchTask( + 'com.example.health-task', + 'healthTask', + )); + }); + + test( + "registerHealthResearchTask with optional params - It calls methods on the mocked class", + () { + when(GetIt.I().registerHealthResearchTask( + 'com.example.health-task', + 'healthTask', + initialDelay: const Duration(minutes: 10), + inputData: {'dataType': 'heartRate'}, + )).thenAnswer((_) => Future.value()); + + GetIt.I().registerHealthResearchTask( + 'com.example.health-task', + 'healthTask', + initialDelay: const Duration(minutes: 10), + inputData: {'dataType': 'heartRate'}, + ); + + verify(GetIt.I().registerHealthResearchTask( + 'com.example.health-task', + 'healthTask', + initialDelay: const Duration(minutes: 10), + inputData: {'dataType': 'heartRate'}, + )); + }); }); } diff --git a/workmanager/test/workmanager_test.mocks.dart b/workmanager/test/workmanager_test.mocks.dart index c7926dd4..51c6309d 100644 --- a/workmanager/test/workmanager_test.mocks.dart +++ b/workmanager/test/workmanager_test.mocks.dart @@ -24,6 +24,7 @@ import 'package:workmanager_platform_interface/workmanager_platform_interface.da // ignore_for_file: unnecessary_parenthesis // ignore_for_file: camel_case_types // ignore_for_file: subtype_of_sealed_class +// ignore_for_file: invalid_use_of_internal_member /// A class which mocks [Workmanager]. /// @@ -165,6 +166,29 @@ class MockWorkmanager extends _i1.Mock implements _i2.Workmanager { returnValueForMissingStub: _i3.Future.value(), ) as _i3.Future); + @override + _i3.Future registerHealthResearchTask( + String? uniqueName, + String? taskName, { + Duration? initialDelay, + Map? inputData, + }) => + (super.noSuchMethod( + Invocation.method( + #registerHealthResearchTask, + [ + uniqueName, + taskName, + ], + { + #initialDelay: initialDelay, + #inputData: inputData, + }, + ), + returnValue: _i3.Future.value(), + returnValueForMissingStub: _i3.Future.value(), + ) as _i3.Future); + @override _i3.Future cancelByUniqueName(String? uniqueName) => (super.noSuchMethod( diff --git a/workmanager_android/android/src/main/kotlin/dev/fluttercommunity/workmanager/WorkmanagerPlugin.kt b/workmanager_android/android/src/main/kotlin/dev/fluttercommunity/workmanager/WorkmanagerPlugin.kt index d7774bcc..3dd063b3 100644 --- a/workmanager_android/android/src/main/kotlin/dev/fluttercommunity/workmanager/WorkmanagerPlugin.kt +++ b/workmanager_android/android/src/main/kotlin/dev/fluttercommunity/workmanager/WorkmanagerPlugin.kt @@ -1,5 +1,6 @@ package dev.fluttercommunity.workmanager +import dev.fluttercommunity.workmanager.pigeon.HealthResearchTaskRequest import dev.fluttercommunity.workmanager.pigeon.InitializeRequest import dev.fluttercommunity.workmanager.pigeon.OneOffTaskRequest import dev.fluttercommunity.workmanager.pigeon.PeriodicTaskRequest @@ -103,6 +104,14 @@ class WorkmanagerPlugin : callback(Result.failure(UnsupportedOperationException("Processing tasks are not supported on Android"))) } + override fun registerHealthResearchTask( + request: HealthResearchTaskRequest, + callback: (Result) -> Unit, + ) { + // Health research tasks are iOS 15+ specific + callback(Result.failure(UnsupportedOperationException("Health research tasks are not supported on Android"))) + } + override fun cancelByUniqueName( uniqueName: String, callback: (Result) -> Unit, diff --git a/workmanager_android/android/src/main/kotlin/dev/fluttercommunity/workmanager/pigeon/WorkmanagerApi.g.kt b/workmanager_android/android/src/main/kotlin/dev/fluttercommunity/workmanager/pigeon/WorkmanagerApi.g.kt index f274eeac..d70810b8 100644 --- a/workmanager_android/android/src/main/kotlin/dev/fluttercommunity/workmanager/pigeon/WorkmanagerApi.g.kt +++ b/workmanager_android/android/src/main/kotlin/dev/fluttercommunity/workmanager/pigeon/WorkmanagerApi.g.kt @@ -1,7 +1,7 @@ // // Copyright 2024 The Flutter Workmanager Authors. All rights reserved. // // Use of this source code is governed by a MIT-style license that can be // // found in the LICENSE file. -// Autogenerated from Pigeon (v26.0.1), do not edit directly. +// Autogenerated from Pigeon (v26.0.2), do not edit directly. // See also: https://pub.dev/packages/pigeon @file:Suppress("UNCHECKED_CAST", "ArrayInDataClass") @@ -510,6 +510,43 @@ data class ProcessingTaskRequest ( override fun hashCode(): Int = toList().hashCode() } + +/** Generated class from Pigeon that represents data sent in messages. */ +data class HealthResearchTaskRequest ( + val uniqueName: String, + val taskName: String, + val inputData: Map? = null, + val initialDelaySeconds: Long? = null +) + { + companion object { + fun fromList(pigeonVar_list: List): HealthResearchTaskRequest { + val uniqueName = pigeonVar_list[0] as String + val taskName = pigeonVar_list[1] as String + val inputData = pigeonVar_list[2] as Map? + val initialDelaySeconds = pigeonVar_list[3] as Long? + return HealthResearchTaskRequest(uniqueName, taskName, inputData, initialDelaySeconds) + } + } + fun toList(): List { + return listOf( + uniqueName, + taskName, + inputData, + initialDelaySeconds, + ) + } + override fun equals(other: Any?): Boolean { + if (other !is HealthResearchTaskRequest) { + return false + } + if (this === other) { + return true + } + return WorkmanagerApiPigeonUtils.deepEquals(toList(), other.toList()) } + + override fun hashCode(): Int = toList().hashCode() +} private open class WorkmanagerApiPigeonCodec : StandardMessageCodec() { override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? { return when (type) { @@ -573,6 +610,11 @@ private open class WorkmanagerApiPigeonCodec : StandardMessageCodec() { ProcessingTaskRequest.fromList(it) } } + 141.toByte() -> { + return (readValue(buffer) as? List)?.let { + HealthResearchTaskRequest.fromList(it) + } + } else -> super.readValueOfType(type, buffer) } } @@ -626,6 +668,10 @@ private open class WorkmanagerApiPigeonCodec : StandardMessageCodec() { stream.write(140) writeValue(stream, value.toList()) } + is HealthResearchTaskRequest -> { + stream.write(141) + writeValue(stream, value.toList()) + } else -> super.writeValue(stream, value) } } @@ -638,6 +684,7 @@ interface WorkmanagerHostApi { fun registerOneOffTask(request: OneOffTaskRequest, callback: (Result) -> Unit) fun registerPeriodicTask(request: PeriodicTaskRequest, callback: (Result) -> Unit) fun registerProcessingTask(request: ProcessingTaskRequest, callback: (Result) -> Unit) + fun registerHealthResearchTask(request: HealthResearchTaskRequest, callback: (Result) -> Unit) fun cancelByUniqueName(uniqueName: String, callback: (Result) -> Unit) fun cancelByTag(tag: String, callback: (Result) -> Unit) fun cancelAll(callback: (Result) -> Unit) @@ -729,6 +776,25 @@ interface WorkmanagerHostApi { channel.setMessageHandler(null) } } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.workmanager_platform_interface.WorkmanagerHostApi.registerHealthResearchTask$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val requestArg = args[0] as HealthResearchTaskRequest + api.registerHealthResearchTask(requestArg) { result: Result -> + val error = result.exceptionOrNull() + if (error != null) { + reply.reply(WorkmanagerApiPigeonUtils.wrapError(error)) + } else { + reply.reply(WorkmanagerApiPigeonUtils.wrapResult(null)) + } + } + } + } else { + channel.setMessageHandler(null) + } + } run { val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.workmanager_platform_interface.WorkmanagerHostApi.cancelByUniqueName$separatedMessageChannelSuffix", codec) if (api != null) { diff --git a/workmanager_android/lib/workmanager_android.dart b/workmanager_android/lib/workmanager_android.dart index d6917fb5..37e30bcd 100644 --- a/workmanager_android/lib/workmanager_android.dart +++ b/workmanager_android/lib/workmanager_android.dart @@ -103,6 +103,17 @@ class WorkmanagerAndroid extends WorkmanagerPlatform { throw UnsupportedError('Processing tasks are not supported on Android'); } + @override + Future registerHealthResearchTask( + String uniqueName, + String taskName, { + Duration? initialDelay, + Map? inputData, + }) async { + throw UnsupportedError( + 'Health research tasks are not supported on Android'); + } + @override Future cancelByUniqueName(String uniqueName) async { await _api.cancelByUniqueName(uniqueName); diff --git a/workmanager_apple/ios/Assets/.gitkeep b/workmanager_apple/ios/workmanager_apple/Assets/.gitkeep similarity index 100% rename from workmanager_apple/ios/Assets/.gitkeep rename to workmanager_apple/ios/workmanager_apple/Assets/.gitkeep diff --git a/workmanager_apple/ios/Package.swift b/workmanager_apple/ios/workmanager_apple/Package.swift similarity index 86% rename from workmanager_apple/ios/Package.swift rename to workmanager_apple/ios/workmanager_apple/Package.swift index 66351ca9..64695ee5 100644 --- a/workmanager_apple/ios/Package.swift +++ b/workmanager_apple/ios/workmanager_apple/Package.swift @@ -10,7 +10,7 @@ let package = Package( ], products: [ .library( - name: "workmanager_apple", + name: "workmanager-apple", targets: ["workmanager_apple"] ) ], @@ -19,7 +19,7 @@ let package = Package( name: "workmanager_apple", path: "Sources/workmanager_apple", resources: [ - .process("../Resources") + .process("../../Resources") ] ) ] diff --git a/workmanager_apple/ios/Resources/PrivacyInfo.xcprivacy b/workmanager_apple/ios/workmanager_apple/Resources/PrivacyInfo.xcprivacy similarity index 100% rename from workmanager_apple/ios/Resources/PrivacyInfo.xcprivacy rename to workmanager_apple/ios/workmanager_apple/Resources/PrivacyInfo.xcprivacy diff --git a/workmanager_apple/ios/Sources/workmanager_apple/BackgroundTaskOperation.swift b/workmanager_apple/ios/workmanager_apple/Sources/workmanager_apple/BackgroundTaskOperation.swift similarity index 100% rename from workmanager_apple/ios/Sources/workmanager_apple/BackgroundTaskOperation.swift rename to workmanager_apple/ios/workmanager_apple/Sources/workmanager_apple/BackgroundTaskOperation.swift diff --git a/workmanager_apple/ios/Sources/workmanager_apple/BackgroundWorker.swift b/workmanager_apple/ios/workmanager_apple/Sources/workmanager_apple/BackgroundWorker.swift similarity index 95% rename from workmanager_apple/ios/Sources/workmanager_apple/BackgroundWorker.swift rename to workmanager_apple/ios/workmanager_apple/Sources/workmanager_apple/BackgroundWorker.swift index 9f85ff2e..7a6bab85 100644 --- a/workmanager_apple/ios/Sources/workmanager_apple/BackgroundWorker.swift +++ b/workmanager_apple/ios/workmanager_apple/Sources/workmanager_apple/BackgroundWorker.swift @@ -20,6 +20,7 @@ enum BackgroundMode { case backgroundProcessingTask(identifier: String) case backgroundPeriodicTask(identifier: String) case backgroundOneOffTask(identifier: String) + case backgroundHealthResearchTask(identifier: String) var flutterThreadlabelPrefix: String { switch self { @@ -31,6 +32,8 @@ enum BackgroundMode { return "\(WorkmanagerPlugin.identifier).BackgroundPeriodicTask" case .backgroundOneOffTask: return "\(WorkmanagerPlugin.identifier).OneOffTask" + case .backgroundHealthResearchTask: + return "\(WorkmanagerPlugin.identifier).BackgroundHealthResearchTask" } } @@ -44,6 +47,8 @@ enum BackgroundMode { return ["\(WorkmanagerPlugin.identifier).DART_TASK": identifier] case let .backgroundOneOffTask(identifier): return ["\(WorkmanagerPlugin.identifier).DART_TASK": identifier] + case let .backgroundHealthResearchTask(identifier): + return ["\(WorkmanagerPlugin.identifier).DART_TASK": identifier] } } } diff --git a/workmanager_apple/ios/Sources/workmanager_apple/Extensions.swift b/workmanager_apple/ios/workmanager_apple/Sources/workmanager_apple/Extensions.swift similarity index 98% rename from workmanager_apple/ios/Sources/workmanager_apple/Extensions.swift rename to workmanager_apple/ios/workmanager_apple/Sources/workmanager_apple/Extensions.swift index ba53f9ef..ca3f47ed 100644 --- a/workmanager_apple/ios/Sources/workmanager_apple/Extensions.swift +++ b/workmanager_apple/ios/workmanager_apple/Sources/workmanager_apple/Extensions.swift @@ -6,6 +6,7 @@ // import Foundation +import UIKit extension UIBackgroundFetchResult: CustomDebugStringConvertible { public var debugDescription: String { diff --git a/workmanager_apple/ios/Sources/workmanager_apple/LoggingDebugHandler.swift b/workmanager_apple/ios/workmanager_apple/Sources/workmanager_apple/LoggingDebugHandler.swift similarity index 100% rename from workmanager_apple/ios/Sources/workmanager_apple/LoggingDebugHandler.swift rename to workmanager_apple/ios/workmanager_apple/Sources/workmanager_apple/LoggingDebugHandler.swift diff --git a/workmanager_apple/ios/Sources/workmanager_apple/NotificationDebugHandler.swift b/workmanager_apple/ios/workmanager_apple/Sources/workmanager_apple/NotificationDebugHandler.swift similarity index 100% rename from workmanager_apple/ios/Sources/workmanager_apple/NotificationDebugHandler.swift rename to workmanager_apple/ios/workmanager_apple/Sources/workmanager_apple/NotificationDebugHandler.swift diff --git a/workmanager_apple/ios/Sources/workmanager_apple/SimpleLogger.swift b/workmanager_apple/ios/workmanager_apple/Sources/workmanager_apple/SimpleLogger.swift similarity index 100% rename from workmanager_apple/ios/Sources/workmanager_apple/SimpleLogger.swift rename to workmanager_apple/ios/workmanager_apple/Sources/workmanager_apple/SimpleLogger.swift diff --git a/workmanager_apple/ios/Sources/workmanager_apple/ThumbnailGenerator.swift b/workmanager_apple/ios/workmanager_apple/Sources/workmanager_apple/ThumbnailGenerator.swift similarity index 100% rename from workmanager_apple/ios/Sources/workmanager_apple/ThumbnailGenerator.swift rename to workmanager_apple/ios/workmanager_apple/Sources/workmanager_apple/ThumbnailGenerator.swift diff --git a/workmanager_apple/ios/Sources/workmanager_apple/UserDefaultsHelper.swift b/workmanager_apple/ios/workmanager_apple/Sources/workmanager_apple/UserDefaultsHelper.swift similarity index 100% rename from workmanager_apple/ios/Sources/workmanager_apple/UserDefaultsHelper.swift rename to workmanager_apple/ios/workmanager_apple/Sources/workmanager_apple/UserDefaultsHelper.swift diff --git a/workmanager_apple/ios/Sources/workmanager_apple/WMPError.swift b/workmanager_apple/ios/workmanager_apple/Sources/workmanager_apple/WMPError.swift similarity index 100% rename from workmanager_apple/ios/Sources/workmanager_apple/WMPError.swift rename to workmanager_apple/ios/workmanager_apple/Sources/workmanager_apple/WMPError.swift diff --git a/workmanager_apple/ios/Sources/workmanager_apple/WorkmanagerDebugHandler.swift b/workmanager_apple/ios/workmanager_apple/Sources/workmanager_apple/WorkmanagerDebugHandler.swift similarity index 100% rename from workmanager_apple/ios/Sources/workmanager_apple/WorkmanagerDebugHandler.swift rename to workmanager_apple/ios/workmanager_apple/Sources/workmanager_apple/WorkmanagerDebugHandler.swift diff --git a/workmanager_apple/ios/Sources/workmanager_apple/WorkmanagerPlugin.swift b/workmanager_apple/ios/workmanager_apple/Sources/workmanager_apple/WorkmanagerPlugin.swift similarity index 83% rename from workmanager_apple/ios/Sources/workmanager_apple/WorkmanagerPlugin.swift rename to workmanager_apple/ios/workmanager_apple/Sources/workmanager_apple/WorkmanagerPlugin.swift index 16b14c70..e86d73a9 100644 --- a/workmanager_apple/ios/Sources/workmanager_apple/WorkmanagerPlugin.swift +++ b/workmanager_apple/ios/workmanager_apple/Sources/workmanager_apple/WorkmanagerPlugin.swift @@ -32,6 +32,21 @@ public class WorkmanagerPlugin: FlutterPluginAppLifeCycleDelegate, FlutterPlugin operationQueue.addOperation(operation) } + @available(iOS 15.0, *) + private static func handleHealthResearchTask(identifier: String, task: BGHealthResearchTask) { + let operationQueue = OperationQueue() + let operation = createBackgroundOperation( + identifier: task.identifier, + inputData: nil, + backgroundMode: .backgroundHealthResearchTask(identifier: identifier) + ) + + task.expirationHandler = { operation.cancel() } + operation.completionBlock = { task.setTaskCompleted(success: !operation.isCancelled) } + + operationQueue.addOperation(operation) + } + @available(iOS 13.0, *) public static func handlePeriodicTask(identifier: String, task: BGAppRefreshTask, earliestBeginInSeconds: Double?) { guard let callbackHandle = UserDefaultsHelper.getStoredCallbackHandle(), @@ -116,6 +131,20 @@ public class WorkmanagerPlugin: FlutterPluginAppLifeCycleDelegate, FlutterPlugin } } + @objc + public static func registerHealthResearchTask(withIdentifier identifier: String) { + if #available(iOS 15.0, *) { + BGTaskScheduler.shared.register( + forTaskWithIdentifier: identifier, + using: nil + ) { task in + if let task = task as? BGHealthResearchTask { + handleHealthResearchTask(identifier: identifier, task: task) + } + } + } + } + @objc @available(iOS 13.0, *) private static func scheduleBackgroundProcessingTask( @@ -138,6 +167,23 @@ public class WorkmanagerPlugin: FlutterPluginAppLifeCycleDelegate, FlutterPlugin } } + @objc + @available(iOS 15.0, *) + private static func scheduleHealthResearchTask( + taskIdentifier identifier: String, + earliestBeginInSeconds begin: Double + ) { + let request = BGHealthResearchTaskRequest(identifier: identifier) + request.earliestBeginDate = Date(timeIntervalSinceNow: begin) + + do { + try BGTaskScheduler.shared.submit(request) + logInfo("BGHealthResearchTask submitted \(identifier) earliestBeginInSeconds:\(begin)") + } catch { + logInfo("Could not schedule BGHealthResearchTask \(error.localizedDescription)") + } + } + // MARK: - FlutterPlugin conformance @objc @@ -226,6 +272,36 @@ public class WorkmanagerPlugin: FlutterPluginAppLifeCycleDelegate, FlutterPlugin } } + func registerHealthResearchTask(request: HealthResearchTaskRequest, completion: @escaping (Result) -> Void) { + guard validateCallbackHandle() else { + completion(.failure(createInitializationError())) + return + } + + if #available(iOS 15.0, *) { + let delaySeconds = Double(request.initialDelaySeconds ?? 0) + WorkmanagerPlugin.scheduleHealthResearchTask( + taskIdentifier: request.uniqueName, + earliestBeginInSeconds: delaySeconds + ) + + let taskInfo = TaskDebugInfo( + taskName: request.taskName, + uniqueName: request.uniqueName, + inputData: request.inputData as? [String: Any], + startTime: Date().timeIntervalSince1970 + ) + WorkmanagerDebug.getCurrent().onTaskStatusUpdate(taskInfo: taskInfo, status: .scheduled, result: nil) + completion(.success(())) + } else { + completion(.failure(PigeonError( + code: "99", + message: "HealthResearchTask could not be registered", + details: "BGHealthResearchTask is only supported on iOS 15+" + ))) + } + } + func cancelByUniqueName(uniqueName: String, completion: @escaping (Result) -> Void) { executeIfSupportedVoid(completion: completion, feature: "cancelByUniqueName") { BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: uniqueName) diff --git a/workmanager_apple/ios/Sources/workmanager_apple/pigeon/WorkmanagerApi.g.swift b/workmanager_apple/ios/workmanager_apple/Sources/workmanager_apple/pigeon/WorkmanagerApi.g.swift similarity index 93% rename from workmanager_apple/ios/Sources/workmanager_apple/pigeon/WorkmanagerApi.g.swift rename to workmanager_apple/ios/workmanager_apple/Sources/workmanager_apple/pigeon/WorkmanagerApi.g.swift index b5b845ad..a1a5a6d9 100644 --- a/workmanager_apple/ios/Sources/workmanager_apple/pigeon/WorkmanagerApi.g.swift +++ b/workmanager_apple/ios/workmanager_apple/Sources/workmanager_apple/pigeon/WorkmanagerApi.g.swift @@ -1,7 +1,7 @@ // // Copyright 2024 The Flutter Workmanager Authors. All rights reserved. // // Use of this source code is governed by a MIT-style license that can be // // found in the LICENSE file. -// Autogenerated from Pigeon (v26.0.1), do not edit directly. +// Autogenerated from Pigeon (v26.0.2), do not edit directly. // See also: https://pub.dev/packages/pigeon import Foundation @@ -507,6 +507,43 @@ struct ProcessingTaskRequest: Hashable { } } +/// Generated class from Pigeon that represents data sent in messages. +struct HealthResearchTaskRequest: Hashable { + var uniqueName: String + var taskName: String + var inputData: [String?: Any?]? = nil + var initialDelaySeconds: Int64? = nil + + + // swift-format-ignore: AlwaysUseLowerCamelCase + static func fromList(_ pigeonVar_list: [Any?]) -> HealthResearchTaskRequest? { + let uniqueName = pigeonVar_list[0] as! String + let taskName = pigeonVar_list[1] as! String + let inputData: [String?: Any?]? = nilOrValue(pigeonVar_list[2]) + let initialDelaySeconds: Int64? = nilOrValue(pigeonVar_list[3]) + + return HealthResearchTaskRequest( + uniqueName: uniqueName, + taskName: taskName, + inputData: inputData, + initialDelaySeconds: initialDelaySeconds + ) + } + func toList() -> [Any?] { + return [ + uniqueName, + taskName, + inputData, + initialDelaySeconds, + ] + } + static func == (lhs: HealthResearchTaskRequest, rhs: HealthResearchTaskRequest) -> Bool { + return deepEqualsWorkmanagerApi(lhs.toList(), rhs.toList()) } + func hash(into hasher: inout Hasher) { + deepHashWorkmanagerApi(value: toList(), hasher: &hasher) + } +} + private class WorkmanagerApiPigeonCodecReader: FlutterStandardReader { override func readValue(ofType type: UInt8) -> Any? { switch type { @@ -558,6 +595,8 @@ private class WorkmanagerApiPigeonCodecReader: FlutterStandardReader { return PeriodicTaskRequest.fromList(self.readValue() as! [Any?]) case 140: return ProcessingTaskRequest.fromList(self.readValue() as! [Any?]) + case 141: + return HealthResearchTaskRequest.fromList(self.readValue() as! [Any?]) default: return super.readValue(ofType: type) } @@ -602,6 +641,9 @@ private class WorkmanagerApiPigeonCodecWriter: FlutterStandardWriter { } else if let value = value as? ProcessingTaskRequest { super.writeByte(140) super.writeValue(value.toList()) + } else if let value = value as? HealthResearchTaskRequest { + super.writeByte(141) + super.writeValue(value.toList()) } else { super.writeValue(value) } @@ -629,6 +671,7 @@ protocol WorkmanagerHostApi { func registerOneOffTask(request: OneOffTaskRequest, completion: @escaping (Result) -> Void) func registerPeriodicTask(request: PeriodicTaskRequest, completion: @escaping (Result) -> Void) func registerProcessingTask(request: ProcessingTaskRequest, completion: @escaping (Result) -> Void) + func registerHealthResearchTask(request: HealthResearchTaskRequest, completion: @escaping (Result) -> Void) func cancelByUniqueName(uniqueName: String, completion: @escaping (Result) -> Void) func cancelByTag(tag: String, completion: @escaping (Result) -> Void) func cancelAll(completion: @escaping (Result) -> Void) @@ -710,6 +753,23 @@ class WorkmanagerHostApiSetup { } else { registerProcessingTaskChannel.setMessageHandler(nil) } + let registerHealthResearchTaskChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.workmanager_platform_interface.WorkmanagerHostApi.registerHealthResearchTask\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + registerHealthResearchTaskChannel.setMessageHandler { message, reply in + let args = message as! [Any?] + let requestArg = args[0] as! HealthResearchTaskRequest + api.registerHealthResearchTask(request: requestArg) { result in + switch result { + case .success: + reply(wrapResult(nil)) + case .failure(let error): + reply(wrapError(error)) + } + } + } + } else { + registerHealthResearchTaskChannel.setMessageHandler(nil) + } let cancelByUniqueNameChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.workmanager_platform_interface.WorkmanagerHostApi.cancelByUniqueName\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) if let api = api { cancelByUniqueNameChannel.setMessageHandler { message, reply in diff --git a/workmanager_apple/lib/workmanager_apple.dart b/workmanager_apple/lib/workmanager_apple.dart index 2092a31b..ade9c96f 100644 --- a/workmanager_apple/lib/workmanager_apple.dart +++ b/workmanager_apple/lib/workmanager_apple.dart @@ -109,6 +109,21 @@ class WorkmanagerApple extends WorkmanagerPlatform { )); } + @override + Future registerHealthResearchTask( + String uniqueName, + String taskName, { + Duration? initialDelay, + Map? inputData, + }) async { + await _api.registerHealthResearchTask(HealthResearchTaskRequest( + uniqueName: uniqueName, + taskName: taskName, + inputData: inputData?.cast(), + initialDelaySeconds: initialDelay?.inSeconds, + )); + } + @override Future cancelByUniqueName(String uniqueName) async { await _api.cancelByUniqueName(uniqueName); diff --git a/workmanager_apple/test/workmanager_apple_test.dart b/workmanager_apple/test/workmanager_apple_test.dart index e05e4331..9947ce6b 100644 --- a/workmanager_apple/test/workmanager_apple_test.dart +++ b/workmanager_apple/test/workmanager_apple_test.dart @@ -131,6 +131,54 @@ void main() { }); }); + group('iOS-specific health research task request validation', () { + test('should handle HealthResearchTaskRequest creation with all fields', + () { + final request = HealthResearchTaskRequest( + uniqueName: 'com.example.health-research', + taskName: 'Health Research Task', + inputData: {'dataType': 'heartRate', 'batchSize': 100}, + initialDelaySeconds: 600, // 10 minutes + ); + + expect(request.uniqueName, 'com.example.health-research'); + expect(request.taskName, 'Health Research Task'); + expect(request.inputData?['dataType'], 'heartRate'); + expect(request.inputData?['batchSize'], 100); + expect(request.initialDelaySeconds, 600); + }); + + test('should handle minimal HealthResearchTaskRequest', () { + final request = HealthResearchTaskRequest( + uniqueName: 'minimal-health-task', + taskName: 'Minimal Health Task', + ); + + expect(request.uniqueName, 'minimal-health-task'); + expect(request.taskName, 'Minimal Health Task'); + expect(request.inputData, null); + expect(request.initialDelaySeconds, null); + }); + + test('should not have constraint fields unlike ProcessingTaskRequest', + () { + // BGHealthResearchTask does not support network/charging constraints + // Verify HealthResearchTaskRequest only has the expected fields + final request = HealthResearchTaskRequest( + uniqueName: 'com.example.health-task', + taskName: 'Health Task', + inputData: {'key': 'value'}, + initialDelaySeconds: 0, + ); + + expect(request.uniqueName, isNotNull); + expect(request.taskName, isNotNull); + expect(request.inputData, isNotNull); + expect(request.initialDelaySeconds, isNotNull); + // No networkType or requiresCharging fields exist on this type + }); + }); + group('iOS constraint handling differences', () { test('should handle battery constraints appropriately for iOS', () { // iOS handles battery constraints differently than Android diff --git a/workmanager_platform_interface/lib/src/pigeon/workmanager_api.g.dart b/workmanager_platform_interface/lib/src/pigeon/workmanager_api.g.dart index 75474851..f89737fa 100644 --- a/workmanager_platform_interface/lib/src/pigeon/workmanager_api.g.dart +++ b/workmanager_platform_interface/lib/src/pigeon/workmanager_api.g.dart @@ -1,7 +1,7 @@ // // Copyright 2024 The Flutter Workmanager Authors. All rights reserved. // // Use of this source code is governed by a MIT-style license that can be // // found in the LICENSE file. -// Autogenerated from Pigeon (v26.0.1), do not edit directly. +// Autogenerated from Pigeon (v26.0.2), do not edit directly. // See also: https://pub.dev/packages/pigeon // ignore_for_file: public_member_api_docs, non_constant_identifier_names, avoid_as, unused_import, unnecessary_parenthesis, prefer_null_aware_operators, omit_local_variable_types, unused_shown_name, unnecessary_import, no_leading_underscores_for_local_identifiers @@ -537,6 +537,62 @@ class ProcessingTaskRequest { ; } +class HealthResearchTaskRequest { + HealthResearchTaskRequest({ + required this.uniqueName, + required this.taskName, + this.inputData, + this.initialDelaySeconds, + }); + + String uniqueName; + + String taskName; + + Map? inputData; + + int? initialDelaySeconds; + + List _toList() { + return [ + uniqueName, + taskName, + inputData, + initialDelaySeconds, + ]; + } + + Object encode() { + return _toList(); } + + static HealthResearchTaskRequest decode(Object result) { + result as List; + return HealthResearchTaskRequest( + uniqueName: result[0]! as String, + taskName: result[1]! as String, + inputData: (result[2] as Map?)?.cast(), + initialDelaySeconds: result[3] as int?, + ); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + bool operator ==(Object other) { + if (other is! HealthResearchTaskRequest || other.runtimeType != runtimeType) { + return false; + } + if (identical(this, other)) { + return true; + } + return _deepEquals(encode(), other.encode()); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + int get hashCode => Object.hashAll(_toList()) +; +} + class _PigeonCodec extends StandardMessageCodec { const _PigeonCodec(); @@ -581,6 +637,9 @@ class _PigeonCodec extends StandardMessageCodec { } else if (value is ProcessingTaskRequest) { buffer.putUint8(140); writeValue(buffer, value.encode()); + } else if (value is HealthResearchTaskRequest) { + buffer.putUint8(141); + writeValue(buffer, value.encode()); } else { super.writeValue(buffer, value); } @@ -619,6 +678,8 @@ class _PigeonCodec extends StandardMessageCodec { return PeriodicTaskRequest.decode(readValue(buffer)!); case 140: return ProcessingTaskRequest.decode(readValue(buffer)!); + case 141: + return HealthResearchTaskRequest.decode(readValue(buffer)!); default: return super.readValueOfType(type, buffer); } @@ -730,6 +791,29 @@ class WorkmanagerHostApi { } } + Future registerHealthResearchTask(HealthResearchTaskRequest request) async { + final String pigeonVar_channelName = 'dev.flutter.pigeon.workmanager_platform_interface.WorkmanagerHostApi.registerHealthResearchTask$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send([request]); + final List? pigeonVar_replyList = + await pigeonVar_sendFuture as List?; + if (pigeonVar_replyList == null) { + throw _createConnectionError(pigeonVar_channelName); + } else if (pigeonVar_replyList.length > 1) { + throw PlatformException( + code: pigeonVar_replyList[0]! as String, + message: pigeonVar_replyList[1] as String?, + details: pigeonVar_replyList[2], + ); + } else { + return; + } + } + Future cancelByUniqueName(String uniqueName) async { final String pigeonVar_channelName = 'dev.flutter.pigeon.workmanager_platform_interface.WorkmanagerHostApi.cancelByUniqueName$pigeonVar_messageChannelSuffix'; final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( diff --git a/workmanager_platform_interface/lib/src/workmanager_platform_interface.dart b/workmanager_platform_interface/lib/src/workmanager_platform_interface.dart index 40adea93..ef296409 100644 --- a/workmanager_platform_interface/lib/src/workmanager_platform_interface.dart +++ b/workmanager_platform_interface/lib/src/workmanager_platform_interface.dart @@ -116,6 +116,28 @@ abstract class WorkmanagerPlatform extends PlatformInterface { 'registerProcessingTask() has not been implemented.'); } + /// Register a health research task (iOS 15+ only). + /// + /// Health research tasks use [BGHealthResearchTask] and are designed for + /// apps that need to process HealthKit data in the background. The system + /// grants these tasks more runtime than standard background tasks. + /// + /// Requires iOS 15.0 or later. Will throw [UnsupportedError] on Android. + /// + /// [uniqueName] is the unique identifier for this task. + /// [taskName] is the name of the task that will be passed to the callback. + /// [initialDelay] is the delay before the task is executed. + /// [inputData] is optional data that will be passed to the callback. + Future registerHealthResearchTask( + String uniqueName, + String taskName, { + Duration? initialDelay, + Map? inputData, + }) { + throw UnimplementedError( + 'registerHealthResearchTask() has not been implemented.'); + } + /// Cancel a task by its unique name. Future cancelByUniqueName(String uniqueName) { throw UnimplementedError('cancelByUniqueName() has not been implemented.'); @@ -208,6 +230,18 @@ class _PlaceholderImplementation extends WorkmanagerPlatform { ); } + @override + Future registerHealthResearchTask( + String uniqueName, + String taskName, { + Duration? initialDelay, + Map? inputData, + }) async { + throw UnimplementedError( + 'No implementation found for workmanager on this platform.', + ); + } + @override Future cancelByUniqueName(String uniqueName) async { throw UnimplementedError( diff --git a/workmanager_platform_interface/pigeons/workmanager_api.dart b/workmanager_platform_interface/pigeons/workmanager_api.dart index a5fb3adb..e7ddd7c5 100644 --- a/workmanager_platform_interface/pigeons/workmanager_api.dart +++ b/workmanager_platform_interface/pigeons/workmanager_api.dart @@ -251,6 +251,21 @@ class ProcessingTaskRequest { bool? requiresCharging; } +// iOS specific request for BGHealthResearchTask +class HealthResearchTaskRequest { + HealthResearchTaskRequest({ + required this.uniqueName, + required this.taskName, + this.inputData, + this.initialDelaySeconds, + }); + + String uniqueName; + String taskName; + Map? inputData; + int? initialDelaySeconds; +} + // Host API (Flutter calls native) @HostApi() abstract class WorkmanagerHostApi { @@ -266,6 +281,9 @@ abstract class WorkmanagerHostApi { @async void registerProcessingTask(ProcessingTaskRequest request); + @async + void registerHealthResearchTask(HealthResearchTaskRequest request); + @async void cancelByUniqueName(String uniqueName);