Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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+`
Expand Down
Original file line number Diff line number Diff line change
@@ -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()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
}
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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()
}
Expand Down Expand Up @@ -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)") }
}
}

Expand All @@ -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()
Expand Down
5 changes: 5 additions & 0 deletions lib/flutter_foreground_task.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> setIosBackgroundRefreshEnabled(bool enabled) =>
_platform.setIosBackgroundRefreshEnabled(enabled);

// ====================== Service ======================

@visibleForTesting
Expand Down
9 changes: 9 additions & 0 deletions lib/flutter_foreground_task_method_channel.dart
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,15 @@ class MethodChannelFlutterForegroundTask extends FlutterForegroundTaskPlatform {
@visibleForTesting
Platform platform = const LocalPlatform();

@override
Future<void> setIosBackgroundRefreshEnabled(bool enabled) async {
if (!platform.isIOS) return;
await mMDChannel.invokeMethod<void>(
'setIosBackgroundRefreshEnabled',
enabled,
);
}

// ====================== Service ======================

@override
Expand Down
6 changes: 6 additions & 0 deletions lib/flutter_foreground_task_platform_interface.dart
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,12 @@ abstract class FlutterForegroundTaskPlatform extends PlatformInterface {
_instance = instance;
}

Future<void> setIosBackgroundRefreshEnabled(bool enabled) {
throw UnimplementedError(
'setIosBackgroundRefreshEnabled() has not been implemented.',
);
}

// ====================== Service ======================

Future<void> startService({
Expand Down
25 changes: 25 additions & 0 deletions test/service_api_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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 = <MethodCall>[];
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;

Expand Down