diff --git a/IOS_SETUP.md b/IOS_SETUP.md index b7f54fd..a7525bb 100644 --- a/IOS_SETUP.md +++ b/IOS_SETUP.md @@ -10,6 +10,8 @@ This plugin is compatible with **Swift 4.2** and up. Make sure you are using **X > ⚠️ BGTaskScheduler is similar to Background Fetch described below and brings a similar set of constraints. Most notably, there are no guarantees when the background task will be run. Excerpt from the documentation: > > Schedule a processing task request to ask that the system launch your app when conditions are favorable for battery life to handle deferrable, longer-running processing, such as syncing, database maintenance, or similar tasks. The system will attempt to fulfill this request to the best of its ability within the next two days as long as the user has used your app within the past week. +> +> Workmanager BGTaskScheduler methods `registerOneOffTask`, `registerPeriodicTask`, and `registerProcessingTask` are only available on iOS 13+ ![Screenshot of Background Fetch Capabilities tab in Xcode ](.art/ios_background_mode_background_processing.png) @@ -19,6 +21,9 @@ This will add the **UIBackgroundModes** key to your project's `Info.plist`: UIBackgroundModes processing + + + fetch ``` @@ -31,16 +36,25 @@ import workmanager ``` swift // In AppDelegate.application method -WorkmanagerPlugin.registerTask(withIdentifier: "task-identifier") +WorkmanagerPlugin.registerBGProcessingTask(withIdentifier: "task-identifier") + +// Register a periodic task in iOS 13+ +WorkmanagerPlugin.registerPeriodicTask(withIdentifier: "be.tramckrijte.workmanagerExample.iOSBackgroundAppRefresh", frequency: NSNumber(value: 20 * 60)) ``` - Info.plist ``` xml BGTaskSchedulerPermittedIdentifiers - - task-identifier - + + task-identifier + + + be.tramckrijte.workmanagerExample.iOSBackgroundAppRefresh + ``` +> ⚠️ On iOS 13+, adding a `BGTaskSchedulerPermittedIdentifiers` key to the Info.plist for new `BGTaskScheduler` API disables the `performFetchWithCompletionHandler` and `setMinimumBackgroundFetchInterval` +methods, which means you cannot use both old Background Fetch and new `registerPeriodicTask` at the same time, you have to choose one based on your minimum iOS target version. +For details see [Apple Docs](https://developer.apple.com/documentation/uikit/app_and_environment/scenes/preparing_your_ui_to_run_in_the_background/using_background_tasks_to_update_your_app) And will set the correct *SystemCapabilities* for your target in the `project.pbxproj` file: @@ -64,7 +78,11 @@ e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateLaunchForTaskWith ## Enabling Background Fetch -> ⚠️ Background fetch is one supported way to do background work on iOS with work manager: **Periodic tasks** are available on Android only for now! (see #109) +> ⚠️ Background fetch is one supported way to do background work on iOS with work manager. Note that this API is deprecated starting iOS 13, however it still works on iOS 13+ as of writing this article + +> ⚠️ On iOS 13+, adding a `BGTaskSchedulerPermittedIdentifiers` key to the Info.plist for new `BGTaskScheduler` API disables the `performFetchWithCompletionHandler` and `setMinimumBackgroundFetchInterval` +methods, which means you cannot use both old Background Fetch and new `registerPeriodicTask` at the same time, you have to choose one based on your minimum iOS target version. +For details see [Apple Docs](https://developer.apple.com/documentation/uikit/app_and_environment/scenes/preparing_your_ui_to_run_in_the_background/using_background_tasks_to_update_your_app) Background fetching is very different compared to Android's Background Jobs. In order for your app to support Background Fetch, you have to add the *Background Modes* capability in Xcode for your app's Target and check *Background fetch*: diff --git a/README.md b/README.md index e56b260..28d0872 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,7 @@ void callbackDispatcher() { ``` Android tasks are identified using their `taskName`. -iOS tasks are identitied using their `taskIdentifier`. +iOS tasks are identified using their `taskIdentifier`. However, there is an exception for iOS background fetch: `Workmanager.iOSBackgroundTask`, a constant for iOS background fetch task. @@ -93,25 +93,119 @@ Refer to the example app for a successful, retrying and a failed task. # iOS specific setup and note -iOS supports **One off tasks** with a few basic constraints: +Initialize Workmanager only once. +Background app refresh can only be tested on a real device, it cannot be tested on a simulator. + +### Migrate to 0.6.x +Version 0.6.x of this plugin has some breaking changes for iOS: +- Workmanager.registerOneOffTask was previously using iOS **BGProcessingTask**, now it will be an immediate run task which will continue in the background if user leaves the App. Since the previous solution meant the one off task will only run if the device is idle and as often experienced only when device is charging, in practice it means somewhere at night, or not at all during that day, because **BGProcessingTask** is meant for long running tasks. The new solution makes it more in line with Android except it does not support **initialDelay** +- If you need the old behavior you can use the new iOS only method `Workmanager.registerProcessingTask`: + 1. Replace `Workmanager().registerOneOffTask` with `Workmanager().registerProcessingTask` in your App + 1. Replace `WorkmanagerPlugin.registerTask` with `WorkmanagerPlugin.registerBGProcessingTask` in `AppDelegate.swift` +- Workmanager.registerOneOffTask does not support **initialDelay** +- Workmanager.registerOneOffTask now supports **inputData** which was always returning null in the previous solution +- Workmanager.registerOneOffTask now does NOT require `WorkmanagerPlugin.registerTask` call in `AppDelegate.swift` hence remove the call + +### One off tasks +iOS supports **One off tasks** only on iOS 13+ with a few basic constraints: + +`registerOneOffTask` starts immediately. It might run for only 30 seconds due to iOS restrictions. ```dart Workmanager().registerOneOffTask( "task-identifier", simpleTaskKey, // Ignored on iOS - initialDelay: Duration(minutes: 30), + initialDelay: Duration(minutes: 30), // Ignored on iOS + inputData: ... // fully supported +); +``` + +### Periodic tasks +iOS supports two types of **Periodic tasks**: +- On iOS 12 and lower you can use deprecated Background Fetch API, see [iOS Setup](./IOS_SETUP.md), even though the API is +deprecated by iOS it still works on iOS 13+ as of writing this article + +- `registerPeriodicTask` is only supported on iOS 13+, it might run for only 30 seconds due to iOS restrictions, but doesn't start immediately, rather iOS will schedule it as per user's App usage pattern. + +> ⚠️ On iOS 13+, adding a `BGTaskSchedulerPermittedIdentifiers` key to the Info.plist for new `BGTaskScheduler` API disables the `performFetchWithCompletionHandler` and `setMinimumBackgroundFetchInterval` +methods, which means you cannot use both old Background Fetch and new `registerPeriodicTask` at the same time, you have to choose one based on your minimum iOS target version. +For details see [Apple Docs](https://developer.apple.com/documentation/uikit/app_and_environment/scenes/preparing_your_ui_to_run_in_the_background/using_background_tasks_to_update_your_app) + +To use `registerPeriodicTask` first register the task in `Info.plist` and `AppDelegate.swift` [iOS Setup](./IOS_SETUP.md). Unlike Android, for iOS you have to set the frequency in `AppDelegate.swift`. The frequency is not guaranteed rather iOS will schedule it as per user's App usage pattern, iOS might take a few days to learn usage pattern. In reality frequency just means do not repeat the task before x seconds/minutes. If frequency is not provided it will default to 15 minutes. + +```objc +// Register a periodic task with 20 minutes frequency. The frequency is in seconds. +WorkmanagerPlugin.registerPeriodicTask(withIdentifier: "be.tramckrijte.workmanagerExample.iOSBackgroundAppRefresh", frequency: NSNumber(value: 20 * 60)) +``` + +Then schedule the task from your App +```dart +const iOSBackgroundAppRefresh = "be.tramckrijte.workmanagerExample.iOSBackgroundAppRefresh"; +Workmanager().registerPeriodicTask( + iOSBackgroundAppRefresh, + iOSBackgroundAppRefresh, + initialDelay: Duration(seconds: 10), + frequency: Duration(hours: 1), // Ignored on iOS, rather set in AppDelegate.swift + inputData: ... // Not supported +); +``` + +For more information see [BGAppRefreshTask](https://developer.apple.com/documentation/backgroundtasks/bgapprefreshtask) + +### Processing tasks +iOS supports **Processing tasks** only on iOS 13+ which can run for more than 30 seconds. + +`registerProcessingTask` is a long running one off background task, currently only for iOS. It can be run for more than 30 seconds but doesn't start immediately, rather iOS might schedule it when device is idle and charging. +Processing tasks are for long processes like data processing and app maintenance. Processing tasks can run for minutes, but the system can interrupt these. +iOS might terminate any running background processing tasks when the user starts using the device. +For more information see [BGProcessingTask](https://developer.apple.com/documentation/backgroundtasks/bgprocessingtask) + +```dart +const iOSBackgroundProcessingTask = "be.tramckrijte.workmanagerExample.iOSBackgroundProcessingTask"; +Workmanager().registerProcessingTask( + iOSBackgroundProcessingTask, + iOSBackgroundProcessingTask, + initialDelay: Duration(minutes: 2), constraints: Constraints( - // connected or metered mark the task as requiring internet + // Connected or metered mark the task as requiring internet networkType: NetworkType.connected, - // require external power + // Require external power requiresCharging: true, ), - inputData: ... // fully supported ); ``` +### Background App Refresh permission +On iOS user can disable `Background App Refresh` permission anytime, hence background tasks can only run if user has granted the permission. +With `Workmanager.checkBackgroundRefreshPermission` you can check whether background app refresh is enabled. If it is not enabled you might ask +the user to enable it in app settings. + +```dart +if (Platform.isIOS) { + final hasPermission = await Workmanager().checkBackgroundRefreshPermission(); + if (hasPermission != BackgroundRefreshPermissionState.available){ + // Inform the user that background app refresh is disabled + } +} +``` + For more information see the [BGTaskScheduler documentation](https://developer.apple.com/documentation/backgroundtasks). +### Print scheduled tasks +On iOS you can print scheduled tasks using `Workmanager.printScheduledTasks` + +It prints task details to console. To be used during development/debugging. +Currently only supported on iOS and only on iOS 13+. + +```dart +if (Platform.isIOS) { + Workmanager().printScheduledTasks(); + // Prints: [BGTaskScheduler] Task Identifier: iOSBackgroundAppRefresh earliestBeginDate: 2023.10.10 PM 11:10:12 + // Or: [BGTaskScheduler] There are no scheduled tasks +} +``` + + # Customisation (Android) Not every `Android WorkManager` feature is ported. diff --git a/example/ios/Runner.xcodeproj/project.pbxproj b/example/ios/Runner.xcodeproj/project.pbxproj index 190f796..d0b9ce9 100644 --- a/example/ios/Runner.xcodeproj/project.pbxproj +++ b/example/ios/Runner.xcodeproj/project.pbxproj @@ -490,7 +490,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SWIFT_VERSION = 4.2; @@ -577,7 +577,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; @@ -628,7 +628,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SWIFT_OPTIMIZATION_LEVEL = "-Owholemodule"; diff --git a/example/ios/Runner/AppDelegate.swift b/example/ios/Runner/AppDelegate.swift index 4b404a6..08b909c 100644 --- a/example/ios/Runner/AppDelegate.swift +++ b/example/ios/Runner/AppDelegate.swift @@ -20,13 +20,15 @@ import workmanager GeneratedPluginRegistrant.register(with: registry) } - WorkmanagerPlugin.registerTask(withIdentifier: "be.tramckrijte.workmanagerExample.taskId") - WorkmanagerPlugin.registerTask(withIdentifier: "be.tramckrijte.workmanagerExample.simpleTask") - WorkmanagerPlugin.registerTask(withIdentifier: "be.tramckrijte.workmanagerExample.rescheduledTask") - WorkmanagerPlugin.registerTask(withIdentifier: "be.tramckrijte.workmanagerExample.failedTask") - WorkmanagerPlugin.registerTask(withIdentifier: "be.tramckrijte.workmanagerExample.simpleDelayedTask") - WorkmanagerPlugin.registerTask(withIdentifier: "be.tramckrijte.workmanagerExample.simplePeriodicTask") - WorkmanagerPlugin.registerTask(withIdentifier: "be.tramckrijte.workmanagerExample.simplePeriodic1HourTask") + WorkmanagerPlugin.registerBGProcessingTask(withIdentifier: "be.tramckrijte.workmanagerExample.taskId") + WorkmanagerPlugin.registerBGProcessingTask(withIdentifier: "be.tramckrijte.workmanagerExample.rescheduledTask") + WorkmanagerPlugin.registerBGProcessingTask(withIdentifier: "be.tramckrijte.workmanagerExample.simpleDelayedTask") + WorkmanagerPlugin.registerBGProcessingTask(withIdentifier: "be.tramckrijte.workmanagerExample.iOSBackgroundProcessingTask") + + // When this task is scheduled from dart it will run with minimum 20 minute frequency. The + // frequency is not guaranteed rather iOS will schedule it as per user's App usage pattern. + // If frequency is not provided it will default to 15 minutes + WorkmanagerPlugin.registerPeriodicTask(withIdentifier: "be.tramckrijte.workmanagerExample.iOSBackgroundAppRefresh", frequency: NSNumber(value: 20 * 60)) return super.application(application, didFinishLaunchingWithOptions: launchOptions) diff --git a/example/ios/Runner/Info.plist b/example/ios/Runner/Info.plist index 066ae24..bb219cc 100644 --- a/example/ios/Runner/Info.plist +++ b/example/ios/Runner/Info.plist @@ -11,6 +11,8 @@ be.tramckrijte.workmanagerExample.simpleDelayedTask be.tramckrijte.workmanagerExample.simplePeriodicTask be.tramckrijte.workmanagerExample.simplePeriodic1HourTask + be.tramckrijte.workmanagerExample.iOSBackgroundAppRefresh + be.tramckrijte.workmanagerExample.iOSBackgroundProcessingTask CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) diff --git a/example/lib/main.dart b/example/lib/main.dart index cb16108..c57f7d2 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -7,7 +7,7 @@ import 'package:path_provider/path_provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:workmanager/workmanager.dart'; -void main() => runApp(MyApp()); +void main() => runApp(MaterialApp(home: MyApp())); const simpleTaskKey = "be.tramckrijte.workmanagerExample.simpleTask"; const rescheduledTaskKey = "be.tramckrijte.workmanagerExample.rescheduledTask"; @@ -17,21 +17,39 @@ const simplePeriodicTask = "be.tramckrijte.workmanagerExample.simplePeriodicTask"; const simplePeriodic1HourTask = "be.tramckrijte.workmanagerExample.simplePeriodic1HourTask"; +const iOSBackgroundAppRefresh = + "be.tramckrijte.workmanagerExample.iOSBackgroundAppRefresh"; +const iOSBackgroundProcessingTask = + "be.tramckrijte.workmanagerExample.iOSBackgroundProcessingTask"; -@pragma( - 'vm:entry-point') // Mandatory if the App is obfuscated or using Flutter 3.1+ +final List allTasks = [ + simpleTaskKey, + rescheduledTaskKey, + failedTaskKey, + simpleDelayedTask, + simplePeriodicTask, + simplePeriodic1HourTask, + iOSBackgroundAppRefresh, + iOSBackgroundProcessingTask, +]; + +// Pragma is mandatory if the App is obfuscated or using Flutter 3.1+ +@pragma('vm:entry-point') void callbackDispatcher() { Workmanager().executeTask((task, inputData) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.reload(); + + print("$task started. inputData = $inputData"); + await prefs.setString(task, 'Last ran at: ${DateTime.now().toString()}'); + switch (task) { case simpleTaskKey: - print("$simpleTaskKey was executed. inputData = $inputData"); - final prefs = await SharedPreferences.getInstance(); - prefs.setBool("test", true); + await prefs.setBool("test", true); print("Bool from prefs: ${prefs.getBool("test")}"); break; case rescheduledTaskKey: final key = inputData!['key']!; - final prefs = await SharedPreferences.getInstance(); if (prefs.containsKey('unique-$key')) { print('has been running before, task is successful'); return true; @@ -52,13 +70,24 @@ void callbackDispatcher() { case simplePeriodic1HourTask: print("$simplePeriodic1HourTask was executed"); break; - case Workmanager.iOSBackgroundTask: - print("The iOS background fetch was triggered"); + case iOSBackgroundAppRefresh: + // To test, follow the instructions on https://developer.apple.com/documentation/backgroundtasks/starting_and_terminating_tasks_during_development + // and https://github.com/fluttercommunity/flutter_workmanager/blob/main/IOS_SETUP.md Directory? tempDir = await getTemporaryDirectory(); String? tempPath = tempDir.path; print( "You can access other plugins in the background, for example Directory.getTemporaryDirectory(): $tempPath"); break; + case iOSBackgroundProcessingTask: + // To test, follow the instructions on https://developer.apple.com/documentation/backgroundtasks/starting_and_terminating_tasks_during_development + // and https://github.com/fluttercommunity/flutter_workmanager/blob/main/IOS_SETUP.md + // Processing tasks are started by iOS only when phone is idle, hence + // you need to manually trigger by following the docs and putting the App to background + await Future.delayed(Duration(seconds: 40)); + print("$task finished"); + break; + default: + return Future.value(false); } return Future.value(true); @@ -71,6 +100,9 @@ class MyApp extends StatefulWidget { } class _MyAppState extends State { + bool workmanagerInitialized = false; + String _prefsString = "empty"; + @override Widget build(BuildContext context) { return MaterialApp( @@ -90,11 +122,23 @@ class _MyAppState extends State { ), ElevatedButton( child: Text("Start the Flutter background service"), - onPressed: () { - Workmanager().initialize( - callbackDispatcher, - isInDebugMode: true, - ); + onPressed: () async { + if (Platform.isIOS) { + final hasPermission = await Workmanager() + .checkBackgroundRefreshPermission(); + if (hasPermission != + BackgroundRefreshPermissionState.available) { + _showNoPermission(context, hasPermission); + return; + } + } + if (!workmanagerInitialized) { + Workmanager().initialize( + callbackDispatcher, + isInDebugMode: true, + ); + setState(() => workmanagerInitialized = true); + } }, ), SizedBox(height: 16), @@ -171,12 +215,54 @@ class _MyAppState extends State { onPressed: Platform.isAndroid ? () { Workmanager().registerPeriodicTask( - simplePeriodicTask, + simplePeriodic1HourTask, simplePeriodic1HourTask, frequency: Duration(hours: 1), ); } : null), + + // Currently we cannot provide frequency for iOS, hence it will be + // minimum 15 minutes after which iOS will reschedule + ElevatedButton( + child: Text('Register Periodic Background App Refresh (iOS)'), + onPressed: Platform.isIOS + ? () async { + if (!workmanagerInitialized) { + _showNotInitialized(); + return; + } + await Workmanager().registerPeriodicTask( + iOSBackgroundAppRefresh, + iOSBackgroundAppRefresh, + initialDelay: Duration(seconds: 10), + inputData: {}, //ignored on iOS + ); + } + : null, + ), + + // This task runs only once, to perform a time consuming task at + // a later time decided by iOS. + // Processing tasks run only when the device is idle. iOS might + // terminate any running background processing tasks when the + // user starts using the device. + ElevatedButton( + child: Text('Register BackgroundProcessingTask (iOS)'), + onPressed: Platform.isIOS + ? () async { + if (!workmanagerInitialized) { + _showNotInitialized(); + return; + } + await Workmanager().registerProcessingTask( + iOSBackgroundProcessingTask, + iOSBackgroundProcessingTask, + initialDelay: Duration(seconds: 20), + ); + } + : null, + ), SizedBox(height: 16), Text( "Task cancellation", @@ -189,6 +275,19 @@ class _MyAppState extends State { print('Cancel all tasks completed'); }, ), + SizedBox(height: 15), + ElevatedButton( + child: Text('Refresh stats'), + onPressed: _refreshStats, + ), + SizedBox(height: 10), + SingleChildScrollView( + child: Text( + 'Task run stats:\n' + '${workmanagerInitialized ? '' : 'Workmanager not initialized'}' + '\n$_prefsString', + ), + ), ], ), ), @@ -196,4 +295,59 @@ class _MyAppState extends State { ), ); } + + // Refresh/get saved prefs + void _refreshStats() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.reload(); + + _prefsString = ''; + for (final task in allTasks) { + _prefsString = '$_prefsString \n$task:\n${prefs.getString(task)}\n'; + } + + if (Platform.isIOS) { + Workmanager().printScheduledTasks(); + } + + setState(() {}); + } + + void _showNotInitialized() { + showDialog( + context: context, + builder: (BuildContext context) { + return AlertDialog( + title: Text('Workmanager not initialized'), + content: Text('Workmanager is not initialized, please initialize'), + actions: [ + TextButton( + child: Text('OK'), + onPressed: () => Navigator.of(context).pop(), + ), + ], + ); + }, + ); + } + + void _showNoPermission( + BuildContext context, BackgroundRefreshPermissionState hasPermission) { + showDialog( + context: context, + builder: (BuildContext context) { + return AlertDialog( + title: Text('No permission'), + content: Text('Background app refresh is disabled, please enable in ' + 'App settings. Status ${hasPermission.name}'), + actions: [ + TextButton( + child: Text('OK'), + onPressed: () => Navigator.of(context).pop(), + ), + ], + ); + }, + ); + } } diff --git a/example/pubspec.yaml b/example/pubspec.yaml index 4741dbb..699a8bf 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -3,7 +3,7 @@ description: Demonstrates how to use the workmanager plugin. publish_to: 'none' environment: - sdk: ">=2.12.0-0 <3.0.0" + sdk: ">=2.18.0 <4.0.0" dependencies: path_provider: ^2.0.11 diff --git a/ios/Classes/BackgroundTaskOperation.swift b/ios/Classes/BackgroundTaskOperation.swift index 31837d3..d832f13 100644 --- a/ios/Classes/BackgroundTaskOperation.swift +++ b/ios/Classes/BackgroundTaskOperation.swift @@ -11,19 +11,26 @@ class BackgroundTaskOperation: Operation { private let identifier: String private let flutterPluginRegistrantCallback: FlutterPluginRegistrantCallback? + private let inputData: String + private let backgroundMode: BackgroundMode - init(_ identifier: String, flutterPluginRegistrantCallback: FlutterPluginRegistrantCallback?) { + init(_ identifier: String, + inputData: String, + flutterPluginRegistrantCallback: FlutterPluginRegistrantCallback?, + backgroundMode: BackgroundMode) { self.identifier = identifier + self.inputData = inputData self.flutterPluginRegistrantCallback = flutterPluginRegistrantCallback + self.backgroundMode = backgroundMode } + override func main() { let semaphore = DispatchSemaphore(value: 0) - + let worker = BackgroundWorker(mode: self.backgroundMode, + inputData: self.inputData, + flutterPluginRegistrantCallback: self.flutterPluginRegistrantCallback) DispatchQueue.main.async { - let worker = BackgroundWorker(mode: .backgroundTask(identifier: self.identifier), - flutterPluginRegistrantCallback: self.flutterPluginRegistrantCallback) - worker.performBackgroundRequest { _ in semaphore.signal() } diff --git a/ios/Classes/BackgroundWorker.swift b/ios/Classes/BackgroundWorker.swift index 376d5f5..a6aee7d 100644 --- a/ios/Classes/BackgroundWorker.swift +++ b/ios/Classes/BackgroundWorker.swift @@ -9,14 +9,20 @@ import Foundation enum BackgroundMode { case backgroundFetch - case backgroundTask(identifier: String) + case backgroundProcessingTask(identifier: String) + case backgroundPeriodicTask(identifier: String) + case backgroundOneOffTask(identifier: String) var flutterThreadlabelPrefix: String { switch self { case .backgroundFetch: return "\(SwiftWorkmanagerPlugin.identifier).BackgroundFetch" - case .backgroundTask: - return "\(SwiftWorkmanagerPlugin.identifier).BGTaskScheduler" + case .backgroundProcessingTask: + return "\(SwiftWorkmanagerPlugin.identifier).BackgroundProcessingTask" + case .backgroundPeriodicTask: + return "\(SwiftWorkmanagerPlugin.identifier).BackgroundPeriodicTask" + case .backgroundOneOffTask: + return "\(SwiftWorkmanagerPlugin.identifier).OneOffTask" } } @@ -24,7 +30,11 @@ enum BackgroundMode { switch self { case .backgroundFetch: return ["\(SwiftWorkmanagerPlugin.identifier).DART_TASK": "iOSPerformFetch"] - case .backgroundTask(let identifier): + case let .backgroundProcessingTask(identifier): + return ["\(SwiftWorkmanagerPlugin.identifier).DART_TASK": identifier] + case let .backgroundPeriodicTask(identifier): + return ["\(SwiftWorkmanagerPlugin.identifier).DART_TASK": identifier] + case let .backgroundOneOffTask(identifier): return ["\(SwiftWorkmanagerPlugin.identifier).DART_TASK": identifier] } } @@ -34,9 +44,11 @@ class BackgroundWorker { let backgroundMode: BackgroundMode let flutterPluginRegistrantCallback: FlutterPluginRegistrantCallback? + let inputData: String - init(mode: BackgroundMode, flutterPluginRegistrantCallback: FlutterPluginRegistrantCallback?) { - self.backgroundMode = mode + init(mode: BackgroundMode, inputData: String, flutterPluginRegistrantCallback: FlutterPluginRegistrantCallback?) { + backgroundMode = mode + self.inputData = inputData self.flutterPluginRegistrantCallback = flutterPluginRegistrantCallback } @@ -90,14 +102,18 @@ class BackgroundWorker { flutterEngine = nil } - backgroundMethodChannel?.setMethodCallHandler { (call, result) in + backgroundMethodChannel?.setMethodCallHandler { call, result in switch call.method { case BackgroundChannel.initialized: result(true) // Agree to Flutter's method invocation + var arguments = self.backgroundMode.onResultSendArguments + if self.inputData != "" { + arguments = arguments.merging(["be.tramckrijte.workmanager.INPUT_DATA": self.inputData]) { current, _ in current } + } backgroundMethodChannel?.invokeMethod( BackgroundChannel.onResultSendCommand, - arguments: self.backgroundMode.onResultSendArguments, + arguments:arguments, result: { flutterResult in cleanupFlutterResources() let taskSessionCompleter = Date() diff --git a/ios/Classes/CheckBackgroundRefreshPermission.swift b/ios/Classes/CheckBackgroundRefreshPermission.swift new file mode 100644 index 0000000..9f29f02 --- /dev/null +++ b/ios/Classes/CheckBackgroundRefreshPermission.swift @@ -0,0 +1,66 @@ +// +// CheckBackgroundRefreshPermission.swift +// workmanager +// +// Created by Lars Huth on 03/11/2022. +// +import Foundation + +func checkBackgroundRefreshPermission(result: @escaping FlutterResult) -> BackgroundRefreshPermissionState { + switch UIApplication.shared.backgroundRefreshStatus { + case .available: + result(BackgroundRefreshPermissionState.available.rawValue) + return BackgroundRefreshPermissionState.available + case .denied: + result(BackgroundRefreshPermissionState.denied.rawValue) + return BackgroundRefreshPermissionState.denied + case .restricted: + result(BackgroundRefreshPermissionState.restricted.rawValue) + return BackgroundRefreshPermissionState.restricted + default: + result( + FlutterError( + code: "103", + message: "BGAppRefreshTask - Probably you have restricted background refresh permission. " + + "\n" + + "BackgroundRefreshStatus is unknown\n", + details: nil + ) + ) + return BackgroundRefreshPermissionState.unknown + } +} + +func requestBackgroundPermission() { + // Request for permission + UIApplication.shared.open(URL(string: UIApplication.openSettingsURLString)!) +} + +enum BackgroundRefreshPermissionState: String { + /// Background app refresh is enabled in iOS Setting + case available + + /// Background app refresh is disabled in iOS Setting. Permission should be requested from user + case denied + + /// iOS Setting is under parental control etc. Can't be changed by user + case restricted + + /// Unknown state + case unknown + + /// Convenience constructor to build a [BackgroundRefreshPermissionState] from a Dart enum. + init?(fromDart: String) { + self.init(rawValue: fromDart.camelCased(with: "_")) + } +} + +private extension String { + func camelCased(with separator: Character) -> String { + return self.lowercased() + .split(separator: separator) + .enumerated() + .map { $0.offset > 0 ? $0.element.capitalized : $0.element.lowercased() } + .joined() + } +} diff --git a/ios/Classes/SwiftWorkmanagerPlugin.swift b/ios/Classes/SwiftWorkmanagerPlugin.swift index 6233ff4..03ed3e4 100644 --- a/ios/Classes/SwiftWorkmanagerPlugin.swift +++ b/ios/Classes/SwiftWorkmanagerPlugin.swift @@ -25,38 +25,74 @@ public class SwiftWorkmanagerPlugin: FlutterPluginAppLifeCycleDelegate { case callbackHandle } } + + struct CheckBackgroundRefreshPermission { + static let name = "\(CheckBackgroundRefreshPermission.self)".lowercasingFirst + } + struct RegisterOneOffTask { static let name = "\(RegisterOneOffTask.self)".lowercasingFirst enum Arguments: String { + case taskName + case uniqueName + case initialDelaySeconds + case inputData + } + } + + struct RegisterProcessingTask { + static let name = "\(RegisterProcessingTask.self)".lowercasingFirst + enum Arguments: String { + case taskName case uniqueName case initialDelaySeconds case networkType case requiresCharging } } + + struct RegisterPeriodicTask { + static let name = "\(RegisterPeriodicTask.self)".lowercasingFirst + enum Arguments: String { + case taskName + case uniqueName + case initialDelaySeconds + } + } + struct CancelAllTasks { static let name = "\(CancelAllTasks.self)".lowercasingFirst enum Arguments: String { case none } } + struct CancelTaskByUniqueName { static let name = "\(CancelTaskByUniqueName.self)".lowercasingFirst enum Arguments: String { case uniqueName } } + + struct PrintScheduledTasks { + static let name = "\(PrintScheduledTasks.self)".lowercasingFirst + enum Arguments: String { + case none + } + } } } @available(iOS 13.0, *) - private static func handleBGProcessingTask(_ task: BGProcessingTask) { + private static func handleBGProcessingTask(identifier: String, task: BGProcessingTask) { let operationQueue = OperationQueue() // Create an operation that performs the main part of the background task let operation = BackgroundTaskOperation( task.identifier, - flutterPluginRegistrantCallback: SwiftWorkmanagerPlugin.flutterPluginRegistrantCallback + inputData: "", + flutterPluginRegistrantCallback: SwiftWorkmanagerPlugin.flutterPluginRegistrantCallback, + backgroundMode: .backgroundProcessingTask(identifier: identifier) ) // Provide an expiration handler for the background task @@ -75,19 +111,139 @@ public class SwiftWorkmanagerPlugin: FlutterPluginAppLifeCycleDelegate { operationQueue.addOperation(operation) } + @available(iOS 13.0, *) + public static func handlePeriodicTask(identifier: String, task: BGAppRefreshTask, earliestBeginInSeconds: Double?) { + guard let callbackHandle = UserDefaultsHelper.getStoredCallbackHandle(), + let _ = FlutterCallbackCache.lookupCallbackInformation(callbackHandle) + else { + logError("[\(String(describing: self))] \(WMPError.workmanagerNotInitialized.message)") + return + } + + // If frequency is not provided it will default to 15 minutes + schedulePeriodicTask(taskIdentifier: task.identifier, earliestBeginInSeconds: earliestBeginInSeconds ?? (15 * 60)) + + let operationQueue = OperationQueue() + // Create an operation that performs the main part of the background task + let operation = BackgroundTaskOperation( + task.identifier, + inputData: "", + flutterPluginRegistrantCallback: SwiftWorkmanagerPlugin.flutterPluginRegistrantCallback, + backgroundMode: .backgroundPeriodicTask(identifier: identifier) + ) + + // Provide an expiration handler for the background task that cancels the operation + task.expirationHandler = { + operation.cancel() + } + + // Inform the system that the background task is complete when the operation completes + operation.completionBlock = { + task.setTaskCompleted(success: !operation.isCancelled) + } + + // Start the operation + operationQueue.addOperation(operation) + } + + /// Immediately starts a one off task + @available(iOS 13.0, *) + public static func startOneOffTask(identifier: String, taskIdentifier: UIBackgroundTaskIdentifier, inputData:String, delaySeconds: Int64) { + let operationQueue = OperationQueue() + // Create an operation that performs the main part of the background task + let operation = BackgroundTaskOperation( + identifier, + inputData: inputData, + flutterPluginRegistrantCallback: SwiftWorkmanagerPlugin.flutterPluginRegistrantCallback, + backgroundMode: .backgroundOneOffTask(identifier: identifier) + ) + + // Inform the system that the task is complete when the operation completes + operation.completionBlock = { + UIApplication.shared.endBackgroundTask(taskIdentifier) + } + + // Start the operation + operationQueue.addOperation(operation) + } + + /// Registers [BGAppRefresh] task name for the given identifier. + /// You must register task names before app finishes launching in AppDelegate. + @objc + public static func registerPeriodicTask(withIdentifier identifier: String, frequency: NSNumber?) { + if #available(iOS 13.0, *) { + var frequencyInSeconds: Double? + if let frequencyValue = frequency { + frequencyInSeconds = frequencyValue.doubleValue + } + + BGTaskScheduler.shared.register( + forTaskWithIdentifier: identifier, + using: nil + ) { task in + if let task = task as? BGAppRefreshTask { + handlePeriodicTask(identifier: identifier, task: task, earliestBeginInSeconds: frequencyInSeconds) + } + } + } + } + + @objc + @available(iOS 13.0, *) + private static func schedulePeriodicTask(taskIdentifier identifier: String, earliestBeginInSeconds begin: Double) { + if #available(iOS 13.0, *) { + let request = BGAppRefreshTaskRequest(identifier: identifier) + request.earliestBeginDate = Date(timeIntervalSinceNow: begin) + do { + try BGTaskScheduler.shared.submit(request) + logInfo("BGAppRefreshTask submitted \(identifier) earliestBeginInSeconds:\(begin)") + } catch { + logInfo("Could not schedule BGAppRefreshTask \(error.localizedDescription)") + return + } + } + } + + /// Registers [BGProcessingTask] task name for the given identifier. + /// Task names must be registered before app finishes launching in AppDelegate. @objc - public static func registerTask(withIdentifier identifier: String) { + public static func registerBGProcessingTask(withIdentifier identifier: String) { if #available(iOS 13.0, *) { BGTaskScheduler.shared.register( forTaskWithIdentifier: identifier, using: nil ) { task in if let task = task as? BGProcessingTask { - handleBGProcessingTask(task) + handleBGProcessingTask(identifier: identifier, task: task) } } } } + + /// Schedules a long running BackgroundProcessingTask + @objc + @available(iOS 13.0, *) + private static func scheduleBackgroundProcessingTask( + withIdentifier uniqueTaskIdentifier: String, + earliestBeginInSeconds begin: Double, + requiresNetworkConnectivity: Bool, + requiresExternalPower: Bool + ) { + let request = BGProcessingTaskRequest(identifier: uniqueTaskIdentifier) + request.earliestBeginDate = Date(timeIntervalSinceNow: begin) + request.requiresNetworkConnectivity = requiresNetworkConnectivity + request.requiresExternalPower = requiresExternalPower + do { + try BGTaskScheduler.shared.submit(request) + logInfo("BGProcessingTask submitted \(uniqueTaskIdentifier) earliestBeginInSeconds:\(begin)") + } catch { + logInfo("Could not schedule BGProcessingTask identifier:\(uniqueTaskIdentifier) error:\(error.localizedDescription)") + logInfo("Possible issues can be: running on a simulator instead of a real device, or the task name is not registered") + } + } + + static func callback(_: UIBackgroundFetchResult) { + } } // MARK: - FlutterPlugin conformance @@ -113,103 +269,216 @@ extension SwiftWorkmanagerPlugin: FlutterPlugin { switch (call.method, call.arguments as? [AnyHashable: Any]) { case (ForegroundMethodChannel.Methods.Initialize.name, let .some(arguments)): - let method = ForegroundMethodChannel.Methods.Initialize.self - guard let isInDebug = arguments[method.Arguments.isInDebugMode.rawValue] as? Bool, - let handle = arguments[method.Arguments.callbackHandle.rawValue] as? Int64 else { + initialize(arguments: arguments, result: result) + return + case (ForegroundMethodChannel.Methods.CheckBackgroundRefreshPermission.name, .some): + _ = checkBackgroundRefreshPermission(result: result) + return + case (ForegroundMethodChannel.Methods.RegisterOneOffTask.name, let .some(arguments)): + registerOneOffTask(arguments: arguments, result: result) + return + case (ForegroundMethodChannel.Methods.RegisterPeriodicTask.name, let .some(arguments)): + registerPeriodicTask(arguments: arguments, result: result) + return + case (ForegroundMethodChannel.Methods.RegisterProcessingTask.name, let .some(arguments)): + registerProcessingTask(arguments: arguments, result: result) + return + case (ForegroundMethodChannel.Methods.CancelAllTasks.name, .none): + cancelAllTasks(result: result) + return + case (ForegroundMethodChannel.Methods.CancelTaskByUniqueName.name, let .some(arguments)): + cancelTaskByUniqueName(arguments: arguments, result: result) + return + case (ForegroundMethodChannel.Methods.PrintScheduledTasks.name, .none): + printScheduledTasks(result: result) + return + default: + result(WMPError.unhandledMethod(call.method).asFlutterError) + return + } + } + + private func initialize(arguments: [AnyHashable: Any], result: @escaping FlutterResult) { + let method = ForegroundMethodChannel.Methods.Initialize.self + guard let isInDebug = arguments[method.Arguments.isInDebugMode.rawValue] as? Bool, + let handle = arguments[method.Arguments.callbackHandle.rawValue] as? Int64 else { + result(WMPError.invalidParameters.asFlutterError) + return + } + UserDefaultsHelper.storeCallbackHandle(handle) + UserDefaultsHelper.storeIsDebug(isInDebug) + result(true) + } + + private func registerOneOffTask(arguments: [AnyHashable: Any], result: @escaping FlutterResult) { + if !validateCallbackHandle(result: result) { + return + } + + if #available(iOS 13.0, *) { + let method = ForegroundMethodChannel.Methods.RegisterOneOffTask.self + guard let delaySeconds = + arguments[method.Arguments.initialDelaySeconds.rawValue] as? Int64 else { result(WMPError.invalidParameters.asFlutterError) return } - - UserDefaultsHelper.storeCallbackHandle(handle) - UserDefaultsHelper.storeIsDebug(isInDebug) - result(true) - - case (ForegroundMethodChannel.Methods.RegisterOneOffTask.name, let .some(arguments)): - if !validateCallbackHandle() { - result( - FlutterError( - code: "1", - message: "You have not properly initialized the Flutter WorkManager Package. " + - "You should ensure you have called the 'initialize' function first! " + - "Example: \n" + - "\n" + - "`Workmanager().initialize(\n" + - " callbackDispatcher,\n" + - " )`" + - "\n" + - "\n" + - "The `callbackDispatcher` is a top level function. See example in repository.", - details: nil - ) - ) + guard let uniqueTaskIdentifier = + arguments[method.Arguments.uniqueName.rawValue] as? String else { + result(WMPError.invalidParameters.asFlutterError) return } - if #available(iOS 13.0, *) { - let method = ForegroundMethodChannel.Methods.RegisterOneOffTask.self - guard let initialDelaySeconds = - arguments[method.Arguments.initialDelaySeconds.rawValue] as? Int64 else { - result(WMPError.invalidParameters.asFlutterError) - return - } - guard let identifier = - arguments[method.Arguments.uniqueName.rawValue] as? String else { - result(WMPError.invalidParameters.asFlutterError) - return - } - let request = BGProcessingTaskRequest( - identifier: identifier - ) - let requiresCharging = arguments[method.Arguments.requiresCharging.rawValue] as? Bool ?? false + var taskIdentifier: UIBackgroundTaskIdentifier = .invalid + let inputData = + arguments[method.Arguments.inputData.rawValue] as? String - var requiresNetworkConnectivity = false - if let networkTypeInput = arguments[method.Arguments.networkType.rawValue] as? String, - let networkType = NetworkType(fromDart: networkTypeInput), - networkType == .connected || networkType == .metered { - requiresNetworkConnectivity = true - } - request.earliestBeginDate = Date(timeIntervalSinceNow: Double(initialDelaySeconds)) - request.requiresExternalPower = requiresCharging - request.requiresNetworkConnectivity = requiresNetworkConnectivity + taskIdentifier = UIApplication.shared.beginBackgroundTask(withName: uniqueTaskIdentifier, expirationHandler: { + // Mark the task as ended if time is expired, otherwise iOS might terminate and will throttle future executions + UIApplication.shared.endBackgroundTask(taskIdentifier) + }) + SwiftWorkmanagerPlugin.startOneOffTask(identifier: uniqueTaskIdentifier, + taskIdentifier: taskIdentifier, + inputData: inputData ?? "", + delaySeconds: delaySeconds) + result(true) + return + } else { + result(FlutterError(code: "99", + message: "OneOffTask could not be registered", + details: "BGTaskScheduler tasks are only supported on iOS 13+")) + } + } - do { - try BGTaskScheduler.shared.submit(request) - result(true) - } catch { - result(WMPError.bgTaskSchedulingFailed(error).asFlutterError) - } + private func registerPeriodicTask(arguments: [AnyHashable: Any], result: @escaping FlutterResult) { + if !validateCallbackHandle(result: result) { + return + } + if #available(iOS 13.0, *) { + let method = ForegroundMethodChannel.Methods.RegisterPeriodicTask.self + guard let uniqueTaskIdentifier = + arguments[method.Arguments.uniqueName.rawValue] as? String else { + result(WMPError.invalidParameters.asFlutterError) return - } else { - result(WMPError.unhandledMethod(call.method).asFlutterError) } + let initialDelaySeconds = + arguments[method.Arguments.initialDelaySeconds.rawValue] as? Double ?? 0.0 - case (ForegroundMethodChannel.Methods.CancelAllTasks.name, .none): - if #available(iOS 13.0, *) { - BGTaskScheduler.shared.cancelAllTaskRequests() - } + SwiftWorkmanagerPlugin.schedulePeriodicTask( + taskIdentifier: uniqueTaskIdentifier, + earliestBeginInSeconds: initialDelaySeconds) result(true) + return + } else { + result(FlutterError(code: "99", + message: "PeriodicTask could not be registered", + details: "BGAppRefreshTasks are only supported on iOS 13+. Instead you should use Background Fetch")) + } + } - case (ForegroundMethodChannel.Methods.CancelTaskByUniqueName.name, let .some(arguments)): - if #available(iOS 13.0, *) { - let method = ForegroundMethodChannel.Methods.CancelTaskByUniqueName.self - guard let identifier = arguments[method.Arguments.uniqueName.rawValue] as? String else { - result(WMPError.invalidParameters.asFlutterError) - return - } - BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: identifier) + private func registerProcessingTask(arguments: [AnyHashable: Any], result: @escaping FlutterResult) { + if !validateCallbackHandle(result: result) { + return + } + + if #available(iOS 13.0, *) { + let method = ForegroundMethodChannel.Methods.RegisterProcessingTask.self + guard let uniqueTaskIdentifier = + arguments[method.Arguments.uniqueName.rawValue] as? String else { + result(WMPError.invalidParameters.asFlutterError) + return + } + let delaySeconds = + arguments[method.Arguments.initialDelaySeconds.rawValue] as? Double ?? 0.0 + + let requiresCharging = arguments[method.Arguments.requiresCharging.rawValue] as? Bool ?? false + var requiresNetwork = false + if let networkTypeInput = arguments[method.Arguments.networkType.rawValue] as? String, + let networkType = NetworkType(fromDart: networkTypeInput), + networkType == .connected || networkType == .metered { + requiresNetwork = true } - result(true) - default: - result(WMPError.unhandledMethod(call.method).asFlutterError) + SwiftWorkmanagerPlugin.scheduleBackgroundProcessingTask( + withIdentifier: uniqueTaskIdentifier, + earliestBeginInSeconds: delaySeconds, + requiresNetworkConnectivity: requiresCharging, + requiresExternalPower: requiresNetwork) + + result(true) return + } else { + result(FlutterError(code: "99", + message: "BackgroundProcessingTask could not be registered", + details: "BGProcessingTasks are only supported on iOS 13+")) } } - private func validateCallbackHandle() -> Bool { - return UserDefaultsHelper.getStoredCallbackHandle() != nil + private func cancelAllTasks(result: @escaping FlutterResult) { + if #available(iOS 13.0, *) { + BGTaskScheduler.shared.cancelAllTaskRequests() + } + result(true) + } + + private func cancelTaskByUniqueName(arguments: [AnyHashable: Any], result: @escaping FlutterResult) { + if #available(iOS 13.0, *) { + let method = ForegroundMethodChannel.Methods.CancelTaskByUniqueName.self + guard let identifier = arguments[method.Arguments.uniqueName.rawValue] as? String else { + result(WMPError.invalidParameters.asFlutterError) + return + } + BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: identifier) + } + result(true) + } + + /// Checks whether getStoredCallbackHandle is set. + /// Returns true when initialized, if false, result contains error message. + private func validateCallbackHandle(result: @escaping FlutterResult) -> Bool { + if UserDefaultsHelper.getStoredCallbackHandle() == nil { + result( + FlutterError( + code: "1", + message: "You have not properly initialized the Flutter WorkManager Package. " + + "You should ensure you have called the 'initialize' function first! " + + "Example: \n" + + "\n" + + "`Workmanager().initialize(\n" + + " callbackDispatcher,\n" + + " )`" + + "\n" + + "\n" + + "The `callbackDispatcher` is a top level function. See example in repository.", + details: nil + ) + ) + return false + } + return true + } + + /// Prints details of un-executed scheduled tasks. To be used during development/debugging + private func printScheduledTasks(result: @escaping FlutterResult) { + if #available(iOS 13.0, *) { + BGTaskScheduler.shared.getPendingTaskRequests { taskRequests in + if taskRequests.isEmpty { + print("[BGTaskScheduler] There are no scheduled tasks") + result(true) + return + } + print("[BGTaskScheduler] Scheduled Tasks:") + for taskRequest in taskRequests { + print("[BGTaskScheduler] Task Identifier: \(taskRequest.identifier) earliestBeginDate: \(taskRequest.earliestBeginDate?.formatted() ?? "")") + } + result(true) + } + } else { + result(FlutterError(code: "99", + message: "printScheduledTasks is only supported on iOS 13+", + details: "BGTaskScheduler.getPendingTaskRequests is only supported on iOS 13+")) + } } } @@ -221,8 +490,10 @@ extension SwiftWorkmanagerPlugin { _ application: UIApplication, performFetchWithCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void ) -> Bool { + // Old background fetch API for iOS 12 and lower, in theory it should work for iOS 13+ as well let worker = BackgroundWorker( mode: .backgroundFetch, + inputData: "", flutterPluginRegistrantCallback: SwiftWorkmanagerPlugin.flutterPluginRegistrantCallback ) diff --git a/ios/Classes/WorkmanagerPlugin.h b/ios/Classes/WorkmanagerPlugin.h index 783130b..5c8fd0a 100644 --- a/ios/Classes/WorkmanagerPlugin.h +++ b/ios/Classes/WorkmanagerPlugin.h @@ -10,4 +10,22 @@ */ + (void)registerTaskWithIdentifier:(NSString *) taskIdentifier; +/** + * Register a custom task identifier as iOS BGAppRefresh Task executed randomly in future. + * @author Lars Huth + * + * @param taskIdentifier The identifier of the custom task. Must be set in info.plist + * @param frequency The repeat frequency in seconds + */ ++ (void)registerPeriodicTaskWithIdentifier:(NSString *) taskIdentifier frequency:(NSNumber *) frequency; + +/** + * Register a custom task identifier as iOS BackgroundProcessingTask executed randomly in future. + * @author Lars Huth + * + * @param taskIdentifier The identifier of the custom task. Must be set in info.plist + */ ++ (void)registerBGProcessingTaskWithIdentifier:(NSString *) taskIdentifier; + + @end diff --git a/ios/Classes/WorkmanagerPlugin.m b/ios/Classes/WorkmanagerPlugin.m index 318baf5..8d38819 100644 --- a/ios/Classes/WorkmanagerPlugin.m +++ b/ios/Classes/WorkmanagerPlugin.m @@ -16,9 +16,22 @@ + (void)setPluginRegistrantCallback:(FlutterPluginRegistrantCallback)callback { [SwiftWorkmanagerPlugin setPluginRegistrantCallback:callback]; } +// TODO this might not be needed anymore + (void)registerTaskWithIdentifier:(NSString *) taskIdentifier { if (@available(iOS 13, *)) { - [SwiftWorkmanagerPlugin registerTaskWithIdentifier:taskIdentifier]; + [SwiftWorkmanagerPlugin registerBGProcessingTaskWithIdentifier:taskIdentifier]; + } +} + ++ (void)registerPeriodicTaskWithIdentifier:(NSString *)taskIdentifier frequency:(NSNumber *) frequency { + if (@available(iOS 13, *)) { + [SwiftWorkmanagerPlugin registerPeriodicTaskWithIdentifier:taskIdentifier frequency:frequency]; + } +} + ++ (void)registerBGProcessingTaskWithIdentifier:(NSString *) taskIdentifier{ + if (@available(iOS 13, *)) { + [SwiftWorkmanagerPlugin registerBGProcessingTaskWithIdentifier:taskIdentifier]; } } diff --git a/lib/src/options.dart b/lib/src/options.dart index 34f27c3..1ce4968 100644 --- a/lib/src/options.dart +++ b/lib/src/options.dart @@ -101,3 +101,23 @@ class Constraints { this.requiresStorageNotLow, }); } + +/// Background App Refresh permission states. Currently only available in iOS. +/// +/// On iOS user can disable Background App Refresh permission anytime, hence +/// background tasks can only run if user has granted the permission. +/// [Workmanager().checkBackgroundRefreshPermission()] can be used to check the +/// permission. +enum BackgroundRefreshPermissionState { + /// Background app refresh is enabled in OS Setting + available, + + /// Background app refresh is disabled in OS Setting. Permission should be requested from user + denied, + + /// OS setting is under parental control etc. Can't be changed by user + restricted, + + /// Unknown state + unknown +} diff --git a/lib/src/workmanager.dart b/lib/src/workmanager.dart index 2cfe3e8..78243f2 100644 --- a/lib/src/workmanager.dart +++ b/lib/src/workmanager.dart @@ -25,8 +25,10 @@ typedef BackgroundTaskHandler = Future Function( String taskName, Map? inputData); /// Make sure you followed the platform setup steps first before trying to register any task. +/// /// Android: /// - Custom Application class +/// /// iOS: /// - Enabled the Background Fetch API /// @@ -52,14 +54,24 @@ typedef BackgroundTaskHandler = Future Function( /// } /// ``` /// -/// You can schedule a specific iOS task using: -/// - `Workmanager#registerOneOffTask()` +/// ## You can schedule a specific iOS task using: +/// - `Workmanager().registerOneOffTask()` /// Please read the documentation on limitations for background processing on iOS. /// -/// You can now schedule Android tasks using: -/// - `Workmanager#registerOneOffTask()` or `Workmanager#registerPeriodicTask` /// -/// iOS periodic task is automatically scheduled if you setup the plugin properly. +/// iOS periodic background fetch task is automatically scheduled if you setup the plugin properly for Background Fetch. +/// +/// If you are targeting iOS 13+, you can use `Workmanager().registerPeriodicTask()` +/// +/// Note: On iOS 13+, adding a BGTaskSchedulerPermittedIdentifiers key to the Info.plist +/// disables the performFetchWithCompletionHandler and setMinimumBackgroundFetchInterval +/// methods, which means you cannot use both old Background Fetch and new registerPeriodicTask +/// at the same time, you have to choose one based on your minimum iOS target version. +/// For details see [Using background tasks to update your app](https://developer.apple.com/documentation/uikit/app_and_environment/scenes/preparing_your_ui_to_run_in_the_background/using_background_tasks_to_update_your_app/) +/// +/// +/// ## You can schedule Android tasks using: +/// - `Workmanager().registerOneOffTask()` or `Workmanager().registerPeriodicTask()` class Workmanager { factory Workmanager() => _instance; @@ -158,7 +170,11 @@ class Workmanager { } } - /// Schedule a one off task + /// Schedule a one off task. + /// + /// On iOS it should start immediately, iOS might terminate the task if it takes + /// more than 30 seconds. + /// /// A [uniqueName] is required so only one task can be registered. /// The [taskName] is the value that will be returned in the [BackgroundTaskHandler] /// The [inputData] is the input data for task. Valid value types are: int, bool, double, String and their list @@ -168,7 +184,6 @@ class Workmanager { /// Only supported on Android. final String taskName, { - /// Only supported on Android. final String? tag, @@ -207,11 +222,22 @@ class Workmanager { ); /// Schedules a periodic task that will run every provided [frequency]. + /// + /// On iOS it is not guaranteed when or how often it will run, iOS will schedule + /// it as per user's App usage pattern, iOS might terminate the task or throttle + /// it's frequency if it takes more than 30 seconds. + /// /// A [uniqueName] is required so only one task can be registered. - /// The [taskName] is the value that will be returned in the [BackgroundTaskHandler] + /// The [taskName] is the value that will be returned in the [BackgroundTaskHandler], ignored on iOS where you should use [uniqueName]. /// a [frequency] is not required and will be defaulted to 15 minutes if not provided. /// a [frequency] has a minimum of 15 min. Android will automatically change your frequency to 15 min if you have configured a lower frequency. - /// The [inputData] is the input data for task. Valid value types are: int, bool, double, String and their list + /// Unlike Android, you cannot set [frequency] for iOS here rather you have to set in `AppDelegate.swift` while registering the task. + /// The [inputData] is the input data for task. Valid value types are: int, bool, double, String and their list. It is not supported on iOS. + /// + /// For iOS see Apple docs: + /// [iOS 13+ Using background tasks to update your app](https://developer.apple.com/documentation/uikit/app_and_environment/scenes/preparing_your_ui_to_run_in_the_background/using_background_tasks_to_update_your_app/) + /// + /// [iOS 13+ BGAppRefreshTask](https://developer.apple.com/documentation/backgroundtasks/bgapprefreshtask/) Future registerPeriodicTask( final String uniqueName, final String taskName, { @@ -243,6 +269,74 @@ class Workmanager { ), ); + /// Schedule a background long running task, currently only available on iOS. + /// + /// Processing tasks are for long processes like data processing and app maintenance. + /// Processing tasks can run for minutes, but the system can interrupt these. + /// Processing tasks run only when the device is idle. iOS might terminate any + /// running background processing tasks when the user starts using the device. + /// However background refresh tasks aren’t affected. + /// + /// For iOS see Apple docs: + /// [iOS 13+ Using background tasks to update your app](https://developer.apple.com/documentation/uikit/app_and_environment/scenes/preparing_your_ui_to_run_in_the_background/using_background_tasks_to_update_your_app/) + /// + /// [iOS 13+ BGProcessingTask](https://developer.apple.com/documentation/backgroundtasks/bgprocessingtask/) + Future registerProcessingTask( + final String uniqueName, + final String taskName, { + final Duration initialDelay = Duration.zero, + + /// Only partially supported on iOS. + /// See [Constraints] for details. + final Constraints? constraints, + }) async => + await _foregroundChannel.invokeMethod( + "registerProcessingTask", + JsonMapperHelper.toRegisterMethodArgument( + isInDebugMode: _isInDebugMode, + uniqueName: uniqueName, + taskName: taskName, + initialDelay: initialDelay, + constraints: constraints, + ), + ); + + /// Check whether background app refresh is enabled. If it is not enabled you + /// might ask the user to enable it in app settings. + /// + /// On iOS user can disable Background App Refresh permission anytime, hence + /// background tasks can only run if user has granted the permission. Parental + /// controls can also restrict it. + /// + /// Only available on iOS. + Future + checkBackgroundRefreshPermission() async { + try { + var result = await _foregroundChannel.invokeMethod( + 'checkBackgroundRefreshPermission', + JsonMapperHelper.toInitializeMethodArgument( + isInDebugMode: _isInDebugMode, + callbackHandle: 0, + ), + ); + switch (result.toString()) { + case 'available': + return BackgroundRefreshPermissionState.available; + case 'denied': + return BackgroundRefreshPermissionState.denied; + case 'restricted': + return BackgroundRefreshPermissionState.restricted; + case 'unknown': + return BackgroundRefreshPermissionState.unknown; + } + } catch (e) { + // TODO not sure it's a good idea to handle and print a message + print("Could not retrieve BackgroundRefreshPermissionState " + + e.toString()); + } + return BackgroundRefreshPermissionState.unknown; + } + /// Cancels a task by its [uniqueName] Future cancelByUniqueName(final String uniqueName) async => await _foregroundChannel.invokeMethod( @@ -260,6 +354,13 @@ class Workmanager { /// Cancels all tasks Future cancelAll() async => await _foregroundChannel.invokeMethod("cancelAllTasks"); + + /// Prints details of un-executed scheduled tasks to console. To be used during + /// development/debugging. + /// + /// Currently only supported on iOS and only on iOS 13+. + Future printScheduledTasks() async => + await _foregroundChannel.invokeMethod("printScheduledTasks"); } /// A helper object to convert the selected options to JSON format. Mainly for testability.