diff --git a/CHANGELOG.md b/CHANGELOG.md index d1740c86..7d0345d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +## Unreleased + +* Add persisted iOS refresh opt-out and guard missing refresh configuration. + ## 11.0.3 * [**FIX-iOS**] Include the objective-c shim in the podspec source_files so it is compiled under CocoaPods diff --git a/README.md b/README.md index 1fe4f643..64eb44f8 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,24 @@ This plugin is used to implement a foreground service on the Android platform. * Provides useful utilities that can use while performing tasks. * Provides an option to automatically resume the foreground service on boot. +## Disabling unused iOS refresh + +If the foreground service is only needed on Android, disable iOS periodic refresh: + +```dart +await FlutterForegroundTask.setIosBackgroundRefreshEnabled(false); +``` + +This setting persists across launches. Disabling cancels pending and running +refresh work and prevents completion from scheduling another refresh. Android +behavior is unchanged. This does not provide an execution allowance or transfer +files in the background. + +The refresh identifier can be omitted from Info.plist when unused; registration +and scheduling are then skipped safely. Existing configured apps retain refresh +by default. Re-enabling requires its permitted identifier and a registered handler; +relaunch after changing configuration. + ## Support version - Flutter: `3.44.0+` diff --git a/ios/flutter_foreground_task/Sources/flutter_foreground_task/IOSBackgroundRefreshPolicy.swift b/ios/flutter_foreground_task/Sources/flutter_foreground_task/IOSBackgroundRefreshPolicy.swift new file mode 100644 index 00000000..20e281f8 --- /dev/null +++ b/ios/flutter_foreground_task/Sources/flutter_foreground_task/IOSBackgroundRefreshPolicy.swift @@ -0,0 +1,50 @@ +import Foundation + +/// Serializes disable with submission/completion across Flutter engines. +final class IOSBackgroundRefreshPolicy { + let lock = NSRecursiveLock() + private let defaults: UserDefaults + private let permitted: () -> Bool + private let registered: () -> Bool + private let cancel: () -> Void + private let cancelRunning: () -> Void + private let key = "com.pravera.flutter_foreground_task.backgroundRefreshEnabled" + + init(defaults: UserDefaults = .standard, permitted: @escaping () -> Bool, + registered: @escaping () -> Bool, cancel: @escaping () -> Void, + cancelRunning: @escaping () -> Void) { + self.defaults = defaults + self.permitted = permitted + self.registered = registered + self.cancel = cancel + self.cancelRunning = cancelRunning + } + + var enabled: Bool { + lock.lock() + defer { lock.unlock() } + return permitted() && (defaults.object(forKey: key) as? Bool ?? true) + } + + func setEnabled(_ value: Bool) -> Bool { + lock.lock() + defer { lock.unlock() } + if value && (!permitted() || !registered()) { return false } + defaults.set(value, forKey: key) + if !value { + cancel() + cancelRunning() + } + return true + } + + func submitIfEnabled(_ submit: () -> Void) { + lock.lock() + defer { lock.unlock() } + guard enabled, registered() else { + cancel() + return + } + submit() + } +} diff --git a/ios/flutter_foreground_task/Sources/flutter_foreground_task/SwiftFlutterForegroundTaskPlugin.swift b/ios/flutter_foreground_task/Sources/flutter_foreground_task/SwiftFlutterForegroundTaskPlugin.swift index 33046730..17a4944b 100644 --- a/ios/flutter_foreground_task/Sources/flutter_foreground_task/SwiftFlutterForegroundTaskPlugin.swift +++ b/ios/flutter_foreground_task/Sources/flutter_foreground_task/SwiftFlutterForegroundTaskPlugin.swift @@ -27,8 +27,6 @@ public class SwiftFlutterForegroundTaskPlugin: NSObject, FlutterPlugin { @objc public static func registerAppRefreshForBackgroundLaunch() { if #available(iOS 13.0, *) { - let permitted = Bundle.main.object(forInfoDictionaryKey: "BGTaskSchedulerPermittedIdentifiers") as? [String] ?? [] - guard permitted.contains(refreshIdentifier) else { return } registerAppRefresh() } } @@ -63,6 +61,16 @@ public class SwiftFlutterForegroundTaskPlugin: NSObject, FlutterPlugin { notificationPermissionManager!.requestPermission { permission in result(permission.rawValue) } + case "setIosBackgroundRefreshEnabled": + guard let enabled = args as? Bool else { + result(FlutterError(code: "invalid-arguments", message: "Expected a boolean", details: nil)) + return + } + guard Self.refreshPolicy.setEnabled(enabled) else { + result(FlutterError(code: "background-refresh-configuration", message: "Add the permitted identifier and register before app launch finishes", details: nil)) + return + } + result(nil) case "startService": try backgroundServiceManager!.start(arguments: args) result(true) @@ -96,7 +104,9 @@ public class SwiftFlutterForegroundTaskPlugin: NSObject, FlutterPlugin { // ================== App Lifecycle =================== public func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [AnyHashable : Any] = [:]) -> Bool { - UIApplication.shared.setMinimumBackgroundFetchInterval(UIApplication.backgroundFetchIntervalMinimum) + if Self.refreshEnabled { + UIApplication.shared.setMinimumBackgroundFetchInterval(UIApplication.backgroundFetchIntervalMinimum) + } if #available(iOS 13.0, *) { SwiftFlutterForegroundTaskPlugin.registerAppRefresh() } @@ -144,25 +154,44 @@ public class SwiftFlutterForegroundTaskPlugin: NSObject, FlutterPlugin { // ============== Background App Refresh ============== public static var refreshIdentifier: String = "com.pravera.flutter_foreground_task.refresh" private static var isBgTaskRegistered: Bool = false + private static let refreshPolicy = IOSBackgroundRefreshPolicy( + permitted: { refreshPermitted }, registered: { isBgTaskRegistered }, + cancel: { cancelAppRefresh() }, + cancelRunning: { + for operation in refreshOperations.values { operation.cancel() } + }) + private static var refreshLock: NSRecursiveLock { refreshPolicy.lock } + private static var refreshOperations = [UUID: AppRefreshOperation]() + private static var refreshPermitted: Bool { + let permitted = Bundle.main.object(forInfoDictionaryKey: "BGTaskSchedulerPermittedIdentifiers") as? [String] ?? [] + return permitted.contains(refreshIdentifier) + } + private static var refreshEnabled: Bool { + refreshPolicy.enabled + } @available(iOS 13.0, *) private static func registerAppRefresh() { + refreshLock.lock() + defer { refreshLock.unlock() } + guard refreshPermitted else { + cancelAppRefresh() + return + } + if !refreshEnabled { cancelAppRefresh() } guard !isBgTaskRegistered else { return } - isBgTaskRegistered = true - BGTaskScheduler.shared.register(forTaskWithIdentifier: refreshIdentifier, using: nil) { task in + isBgTaskRegistered = BGTaskScheduler.shared.register(forTaskWithIdentifier: refreshIdentifier, using: nil) { task in handleAppRefresh(task: task as! BGAppRefreshTask) } } @available(iOS 13.0, *) private static func scheduleAppRefresh() { - let request = BGAppRefreshTaskRequest(identifier: refreshIdentifier) - request.earliestBeginDate = Date(timeIntervalSinceNow: 15 * 60) - - do { - try BGTaskScheduler.shared.submit(request) - } catch { - print("Could not schedule app refresh: \(error)") + refreshPolicy.submitIfEnabled { + let request = BGAppRefreshTaskRequest(identifier: refreshIdentifier) + request.earliestBeginDate = Date(timeIntervalSinceNow: 15 * 60) + do { try BGTaskScheduler.shared.submit(request) } + catch { print("Could not schedule app refresh: \(error)") } } } @@ -173,31 +202,46 @@ public class SwiftFlutterForegroundTaskPlugin: NSObject, FlutterPlugin { @available(iOS 13.0, *) private static func handleAppRefresh(task: BGAppRefreshTask) { + refreshLock.lock() + defer { refreshLock.unlock() } + guard refreshEnabled else { + task.setTaskCompleted(success: true) + return + } let queue = OperationQueue() let operation = AppRefreshOperation() + let id = UUID() + refreshOperations[id] = operation task.expirationHandler = { operation.cancel() } - operation.completionBlock = { - // Schedule a new refresh task - scheduleAppRefresh() - - task.setTaskCompleted(success: true) + operation.completionBlock = { [weak operation] in + refreshLock.lock() + defer { refreshLock.unlock() } + let cancelled = operation?.isCancelled ?? true + refreshOperations.removeValue(forKey: id) + if !cancelled { scheduleAppRefresh() } + task.setTaskCompleted(success: !cancelled) } queue.addOperation(operation) } } -class AppRefreshOperation: Operation { +class AppRefreshOperation: Operation, @unchecked Sendable { + private let semaphore = DispatchSemaphore(value: 0) + override func cancel() { + super.cancel() + semaphore.signal() + } override func main() { - let semaphore = DispatchSemaphore(value: 0) + if isCancelled { return } // avoid non-platform thread DispatchQueue.main.asyncAfter(deadline: .now() + 25) { - semaphore.signal() + self.semaphore.signal() } semaphore.wait() diff --git a/lib/flutter_foreground_task.dart b/lib/flutter_foreground_task.dart index 2fedb617..5113d901 100644 --- a/lib/flutter_foreground_task.dart +++ b/lib/flutter_foreground_task.dart @@ -45,6 +45,11 @@ typedef DataCallback = void Function(Object data); /// A class that implements foreground task and provides useful utilities. class FlutterForegroundTask { + /// Disables unused iOS refresh and cancels pending/running refresh work. + /// The setting persists across launches and has no effect on Android. + static Future setIosBackgroundRefreshEnabled(bool enabled) => + _platform.setIosBackgroundRefreshEnabled(enabled); + // ====================== Service ====================== @visibleForTesting diff --git a/lib/flutter_foreground_task_method_channel.dart b/lib/flutter_foreground_task_method_channel.dart index 434888d8..1aad11b5 100644 --- a/lib/flutter_foreground_task_method_channel.dart +++ b/lib/flutter_foreground_task_method_channel.dart @@ -30,6 +30,15 @@ class MethodChannelFlutterForegroundTask extends FlutterForegroundTaskPlatform { @visibleForTesting Platform platform = const LocalPlatform(); + @override + Future setIosBackgroundRefreshEnabled(bool enabled) async { + if (!platform.isIOS) return; + await mMDChannel.invokeMethod( + 'setIosBackgroundRefreshEnabled', + enabled, + ); + } + // ====================== Service ====================== @override diff --git a/lib/flutter_foreground_task_platform_interface.dart b/lib/flutter_foreground_task_platform_interface.dart index 5d3974ff..0905f0dd 100644 --- a/lib/flutter_foreground_task_platform_interface.dart +++ b/lib/flutter_foreground_task_platform_interface.dart @@ -31,6 +31,12 @@ abstract class FlutterForegroundTaskPlatform extends PlatformInterface { _instance = instance; } + Future setIosBackgroundRefreshEnabled(bool enabled) { + throw UnimplementedError( + 'setIosBackgroundRefreshEnabled() has not been implemented.', + ); + } + // ====================== Service ====================== Future startService({ diff --git a/test/service_api_test.dart b/test/service_api_test.dart index 13a0196a..3fbe9cb4 100644 --- a/test/service_api_test.dart +++ b/test/service_api_test.dart @@ -40,6 +40,13 @@ void main() { group('Android', () { final Platform platform = FakePlatform(operatingSystem: Platform.android); + test('refresh policy does not call native code', () async { + platformChannel.platform = platform; + await FlutterForegroundTask.setIosBackgroundRefreshEnabled(false); + await FlutterForegroundTask.setIosBackgroundRefreshEnabled(true); + expect(methodCallHandler.log, isEmpty); + }); + test('init', () { platformChannel.platform = platform; @@ -272,6 +279,24 @@ void main() { group('iOS', () { final Platform platform = FakePlatform(operatingSystem: Platform.iOS); + test('refresh policy works before service initialization', () async { + platformChannel.platform = platform; + final calls = []; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(platformChannel.mMDChannel, (call) async { + calls.add(call); + return null; + }); + await FlutterForegroundTask.setIosBackgroundRefreshEnabled(false); + await FlutterForegroundTask.setIosBackgroundRefreshEnabled(true); + expect(FlutterForegroundTask.isInitialized, false); + expect( + calls.map((call) => call.method), + everyElement('setIosBackgroundRefreshEnabled'), + ); + expect(calls.map((call) => call.arguments), [false, true]); + }); + test('init', () { platformChannel.platform = platform;