diff --git a/.gitignore b/.gitignore index ddde1066..9245c580 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,8 @@ .idea/ .packages .pub/ +.build/ +.swiftpm/ /pubspec.lock build/ diff --git a/ios/flutter_foreground_task.podspec b/ios/flutter_foreground_task.podspec index 45788fda..65ee9da2 100644 --- a/ios/flutter_foreground_task.podspec +++ b/ios/flutter_foreground_task.podspec @@ -15,7 +15,7 @@ A new Flutter plugin. s.source = { :path => '.' } s.source_files = 'Classes/**/*' s.dependency 'Flutter' - s.platform = :ios, '8.0' + s.platform = :ios, '12.0' # Flutter.framework does not contain a i386 slice. s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386' } diff --git a/ios/flutter_foreground_task/Package.swift b/ios/flutter_foreground_task/Package.swift new file mode 100644 index 00000000..36d203e4 --- /dev/null +++ b/ios/flutter_foreground_task/Package.swift @@ -0,0 +1,20 @@ +// swift-tools-version: 5.9 +import PackageDescription + +let package = Package( + name: "flutter_foreground_task", + platforms: [ + .iOS("12.0") + ], + products: [ + .library(name: "flutter-foreground-task", targets: ["flutter_foreground_task"]) + ], + dependencies: [], + targets: [ + .target( + name: "flutter_foreground_task", + dependencies: [], + path: "Sources/flutter_foreground_task" + ) + ] +) diff --git a/ios/flutter_foreground_task/Sources/flutter_foreground_task/FlutterForegroundTaskLifecycleListener.swift b/ios/flutter_foreground_task/Sources/flutter_foreground_task/FlutterForegroundTaskLifecycleListener.swift new file mode 100644 index 00000000..c4879001 --- /dev/null +++ b/ios/flutter_foreground_task/Sources/flutter_foreground_task/FlutterForegroundTaskLifecycleListener.swift @@ -0,0 +1,35 @@ +// +// FlutterForegroundTaskLifecycleListener.swift +// flutter_foreground_task +// +// Created by Woo Jin Hwang on 7/15/24. +// + +import Foundation +import Flutter + +/** A listener that can listen to the task lifecycle events. */ +public protocol FlutterForegroundTaskLifecycleListener : AnyObject { + /** + * Each time a task starts, a new FlutterEngine is created. + * + * This is called before onTaskStart, Initialize the service you want to use in the task. (like PlatformChannel initialization) + */ + func onEngineCreate(flutterEngine: FlutterEngine?) + + /** Called when the task is started. */ + func onTaskStart(starter: FlutterForegroundTaskStarter) + + /** Called based on the eventAction set in ForegroundTaskOptions. */ + func onTaskRepeatEvent() + + /** Called when the task is destroyed. */ + func onTaskDestroy() + + /** + * If one task is finished or replaced by another, the FlutterEngine is destroyed. + * + * This is called after onTaskDestroy, where dispose the service that was initialized in onEngineCreate. + */ + func onEngineWillDestroy() +} diff --git a/ios/flutter_foreground_task/Sources/flutter_foreground_task/FlutterForegroundTaskStarter.swift b/ios/flutter_foreground_task/Sources/flutter_foreground_task/FlutterForegroundTaskStarter.swift new file mode 100644 index 00000000..cf69cf83 --- /dev/null +++ b/ios/flutter_foreground_task/Sources/flutter_foreground_task/FlutterForegroundTaskStarter.swift @@ -0,0 +1,13 @@ +// +// FlutterForegroundTaskStarter.swift +// flutter_foreground_task +// +// Created by Woo Jin Hwang on 9/23/24. +// + +import Foundation + +public enum FlutterForegroundTaskStarter : Int { + case DEVELOPER = 0 + case SYSTEM = 1 +} diff --git a/ios/flutter_foreground_task/Sources/flutter_foreground_task/PreferencesKey.swift b/ios/flutter_foreground_task/Sources/flutter_foreground_task/PreferencesKey.swift new file mode 100644 index 00000000..da46a244 --- /dev/null +++ b/ios/flutter_foreground_task/Sources/flutter_foreground_task/PreferencesKey.swift @@ -0,0 +1,26 @@ +// +// BackgroundServicePrefsKey.swift +// flutter_foreground_task +// +// Created by WOO JIN HWANG on 2021/08/11. +// + +// service status +let BACKGROUND_SERVICE_ACTION = "backgroundServiceAction" + +// notification options +let SHOW_NOTIFICATION = "showNotification" +let PLAY_SOUND = "playSound" + +// notification content +let NOTIFICATION_CONTENT_TITLE = "notificationContentTitle" +let NOTIFICATION_CONTENT_TEXT = "notificationContentText" +let NOTIFICATION_CONTENT_BUTTONS = "buttons" + +// task options +let TASK_EVENT_ACTION = "taskEventAction" // new +let INTERVAL = "interval" // deprecated +let IS_ONCE_EVENT = "isOnceEvent" // deprecated + +// task data +let CALLBACK_HANDLE = "callbackHandle" diff --git a/ios/flutter_foreground_task/Sources/flutter_foreground_task/SwiftFlutterForegroundTaskPlugin.swift b/ios/flutter_foreground_task/Sources/flutter_foreground_task/SwiftFlutterForegroundTaskPlugin.swift new file mode 100644 index 00000000..8c163e66 --- /dev/null +++ b/ios/flutter_foreground_task/Sources/flutter_foreground_task/SwiftFlutterForegroundTaskPlugin.swift @@ -0,0 +1,191 @@ +import Flutter +import UIKit +import BackgroundTasks + +public class SwiftFlutterForegroundTaskPlugin: NSObject, FlutterPlugin { + // ====================== Plugin ====================== + static private(set) var registerPlugins: FlutterPluginRegistrantCallback? = nil + + private var notificationPermissionManager: NotificationPermissionManager? = nil + private var backgroundServiceManager: BackgroundServiceManager? = nil + + private var foregroundChannel: FlutterMethodChannel? = nil + + public static func register(with registrar: FlutterPluginRegistrar) { + let instance = SwiftFlutterForegroundTaskPlugin() + instance.initServices() + instance.initChannels(registrar.messenger()) + registrar.addApplicationDelegate(instance) + } + + public static func setPluginRegistrantCallback(_ callback: @escaping FlutterPluginRegistrantCallback) { + registerPlugins = callback + } + + public static func addTaskLifecycleListener(_ listener: FlutterForegroundTaskLifecycleListener) { + BackgroundService.sharedInstance.addTaskLifecycleListener(listener) + } + + public static func removeTaskLifecycleListener(_ listener: FlutterForegroundTaskLifecycleListener) { + BackgroundService.sharedInstance.removeTaskLifecycleListener(listener) + } + + private func initServices() { + notificationPermissionManager = NotificationPermissionManager() + backgroundServiceManager = BackgroundServiceManager() + } + + private func initChannels(_ messenger: FlutterBinaryMessenger) { + foregroundChannel = FlutterMethodChannel(name: "flutter_foreground_task/methods", binaryMessenger: messenger) + foregroundChannel?.setMethodCallHandler(onMethodCall) + } + + private func onMethodCall(call: FlutterMethodCall, result: @escaping FlutterResult) { + let args = call.arguments + do { + switch call.method { + case "checkNotificationPermission": + notificationPermissionManager!.checkPermission { permission in + result(permission.rawValue) + } + case "requestNotificationPermission": + notificationPermissionManager!.requestPermission { permission in + result(permission.rawValue) + } + case "startService": + try backgroundServiceManager!.start(arguments: args) + result(true) + case "restartService": + try backgroundServiceManager!.restart(arguments: args) + result(true) + case "updateService": + try backgroundServiceManager!.update(arguments: args) + result(true) + case "stopService": + try backgroundServiceManager!.stop() + result(true) + case "sendData": + backgroundServiceManager!.sendData(data: args) + case "isRunningService": + result(backgroundServiceManager!.isRunningService()) + case "minimizeApp": + UIControl().sendAction(#selector(URLSessionTask.suspend), to: UIApplication.shared, for: nil) + case "isAppOnForeground": + result(UIApplication.shared.applicationState == .active) + default: + result(FlutterMethodNotImplemented) + } + } catch { + let code = String(describing: error.self) + let message = error.localizedDescription + let flutterError = FlutterError(code: code, message: message, details: nil) + result(flutterError) + } + } + + // ================== App Lifecycle =================== + public func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [AnyHashable : Any] = [:]) -> Bool { + UIApplication.shared.setMinimumBackgroundFetchInterval(UIApplication.backgroundFetchIntervalMinimum) + if #available(iOS 13.0, *) { + SwiftFlutterForegroundTaskPlugin.registerAppRefresh() + } + return true + } + + public func application(_ application: UIApplication, performFetchWithCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) -> Bool { + completionHandler(.newData) + return true + } + + public func applicationDidEnterBackground(_ application: UIApplication) { + if #available(iOS 13.0, *) { + SwiftFlutterForegroundTaskPlugin.scheduleAppRefresh() + } + } + + public func applicationWillTerminate(_ application: UIApplication) { + if !BackgroundService.sharedInstance.isRunningService { + return + } + + BackgroundServiceStatus.setData(action: BackgroundServiceAction.APP_TERMINATE) + BackgroundService.sharedInstance.run() + + // Chance to handle onDestroy before app terminates + sleep(5) + } + + // ================= Service Delegate ================= + @available(iOS 10.0, *) + public func userNotificationCenter(_ center: UNUserNotificationCenter, + didReceive response: UNNotificationResponse, + withCompletionHandler completionHandler: @escaping () -> Void) { + BackgroundService.sharedInstance.userNotificationCenter(center, response, completionHandler) + } + + @available(iOS 10.0, *) + public func userNotificationCenter(_ center: UNUserNotificationCenter, + willPresent notification: UNNotification, + withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) { + BackgroundService.sharedInstance.userNotificationCenter(center, notification, completionHandler) + } + + // ============== Background App Refresh ============== + public static var refreshIdentifier: String = "com.pravera.flutter_foreground_task.refresh" + + @available(iOS 13.0, *) + private static func registerAppRefresh() { + 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)") + } + } + + @available(iOS 13.0, *) + private static func cancelAppRefresh() { + BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: refreshIdentifier) + } + + @available(iOS 13.0, *) + private static func handleAppRefresh(task: BGAppRefreshTask) { + let queue = OperationQueue() + let operation = AppRefreshOperation() + + task.expirationHandler = { + operation.cancel() + } + + operation.completionBlock = { + // Schedule a new refresh task + scheduleAppRefresh() + + task.setTaskCompleted(success: true) + } + + queue.addOperation(operation) + } +} + +class AppRefreshOperation: Operation { + override func main() { + let semaphore = DispatchSemaphore(value: 0) + + // avoid non-platform thread + DispatchQueue.main.asyncAfter(deadline: .now() + 25) { + semaphore.signal() + } + + semaphore.wait() + } +} diff --git a/ios/flutter_foreground_task/Sources/flutter_foreground_task/errors/ServiceError.swift b/ios/flutter_foreground_task/Sources/flutter_foreground_task/errors/ServiceError.swift new file mode 100644 index 00000000..c3f8bb35 --- /dev/null +++ b/ios/flutter_foreground_task/Sources/flutter_foreground_task/errors/ServiceError.swift @@ -0,0 +1,30 @@ +// +// ServiceError.swift +// flutter_foreground_task +// +// Created by Woo Jin Hwang on 7/17/24. +// + +import Foundation + +enum ServiceError { + case ServiceArgumentNullException + case ServiceAlreadyStartedException + case ServiceNotStartedException + case ServiceNotSupportedException +} + +extension ServiceError : LocalizedError { + public var errorDescription: String? { + switch self { + case .ServiceArgumentNullException: + return NSLocalizedString("The required argument was not passed to the service.", comment: "ServiceArgumentNullException") + case .ServiceAlreadyStartedException: + return NSLocalizedString("The service has already started.", comment: "ServiceAlreadyStartedException") + case .ServiceNotStartedException: + return NSLocalizedString("The service is not started.", comment: "ServiceNotStartedException") + case .ServiceNotSupportedException: + return NSLocalizedString("The current iOS version does not support the service.", comment: "ServiceNotSupportedException") + } + } +} diff --git a/ios/flutter_foreground_task/Sources/flutter_foreground_task/models/BackgroundServiceAction.swift b/ios/flutter_foreground_task/Sources/flutter_foreground_task/models/BackgroundServiceAction.swift new file mode 100644 index 00000000..4c39f27a --- /dev/null +++ b/ios/flutter_foreground_task/Sources/flutter_foreground_task/models/BackgroundServiceAction.swift @@ -0,0 +1,17 @@ +// +// BackgroundServiceAction.swift +// flutter_foreground_task +// +// Created by WOO JIN HWANG on 2021/08/11. +// + +import Foundation + +enum BackgroundServiceAction: String { + case API_START + case API_RESTART + case API_UPDATE + case API_STOP + + case APP_TERMINATE +} diff --git a/ios/flutter_foreground_task/Sources/flutter_foreground_task/models/BackgroundServiceStatus.swift b/ios/flutter_foreground_task/Sources/flutter_foreground_task/models/BackgroundServiceStatus.swift new file mode 100644 index 00000000..7bd95995 --- /dev/null +++ b/ios/flutter_foreground_task/Sources/flutter_foreground_task/models/BackgroundServiceStatus.swift @@ -0,0 +1,25 @@ +// +// BackgroundServiceStatus.swift +// flutter_foreground_task +// +// Created by Woo Jin Hwang on 9/13/24. +// + +import Foundation + +struct BackgroundServiceStatus { + let action: BackgroundServiceAction + + static func getData() -> BackgroundServiceStatus { + let prefs = UserDefaults.standard + let actionValue = prefs.string(forKey: BACKGROUND_SERVICE_ACTION) ?? BackgroundServiceAction.API_STOP.rawValue + let action = BackgroundServiceAction(rawValue: actionValue)! + + return BackgroundServiceStatus(action: action) + } + + static func setData(action: BackgroundServiceAction) { + let prefs = UserDefaults.standard + prefs.set(action.rawValue, forKey: BACKGROUND_SERVICE_ACTION) + } +} diff --git a/ios/flutter_foreground_task/Sources/flutter_foreground_task/models/ForegroundTaskData.swift b/ios/flutter_foreground_task/Sources/flutter_foreground_task/models/ForegroundTaskData.swift new file mode 100644 index 00000000..1a35a261 --- /dev/null +++ b/ios/flutter_foreground_task/Sources/flutter_foreground_task/models/ForegroundTaskData.swift @@ -0,0 +1,42 @@ +// +// ForegroundTaskData.swift +// flutter_foreground_task +// +// Created by Woo Jin Hwang on 9/24/24. +// + +import Foundation + +struct ForegroundTaskData { + let callbackHandle: Int64? + + static func getData() -> ForegroundTaskData { + let prefs = UserDefaults.standard + + let callbackHandle = prefs.object(forKey: CALLBACK_HANDLE) as? Int64 + + return ForegroundTaskData(callbackHandle: callbackHandle) + } + + static func setData(args: Dictionary) { + let prefs = UserDefaults.standard + + prefs.removeObject(forKey: CALLBACK_HANDLE) + if let callbackHandle = args[CALLBACK_HANDLE] as? Int64 { + prefs.set(callbackHandle, forKey: CALLBACK_HANDLE) + } + } + + static func updateData(args: Dictionary) { + let prefs = UserDefaults.standard + + if let callbackHandle = args[CALLBACK_HANDLE] as? Int64 { + prefs.set(callbackHandle, forKey: CALLBACK_HANDLE) + } + } + + static func clearData() { + let prefs = UserDefaults.standard + prefs.removeObject(forKey: CALLBACK_HANDLE) + } +} diff --git a/ios/flutter_foreground_task/Sources/flutter_foreground_task/models/ForegroundTaskEventAction.swift b/ios/flutter_foreground_task/Sources/flutter_foreground_task/models/ForegroundTaskEventAction.swift new file mode 100644 index 00000000..ac52a1b4 --- /dev/null +++ b/ios/flutter_foreground_task/Sources/flutter_foreground_task/models/ForegroundTaskEventAction.swift @@ -0,0 +1,39 @@ +// +// ForegroundTaskEventAction.swift +// flutter_foreground_task +// +// Created by Woo Jin Hwang on 8/28/24. +// + +import Foundation + +private let TASK_EVENT_TYPE_KEY = "taskEventType" +private let TASK_EVENT_INTERVAL_KEY = "taskEventInterval" + +struct ForegroundTaskEventAction: Equatable { + let type: ForegroundTaskEventType + let interval: Int + + static func fromJsonString(_ jsonString: String) -> ForegroundTaskEventAction { + var type: ForegroundTaskEventType = .NOTHING + var interval: Int = 5000 + + if let jsonData = jsonString.data(using: .utf8) { + if let jsonObj = try? JSONSerialization.jsonObject(with: jsonData, options: []) as? Dictionary { + if let _type = jsonObj[TASK_EVENT_TYPE_KEY] as? Int { + type = ForegroundTaskEventType.fromValue(_type) + } + + if let _interval = jsonObj[TASK_EVENT_INTERVAL_KEY] as? Int { + interval = _interval + } + } + } + + return ForegroundTaskEventAction(type: type, interval: interval) + } + + static func == (lhs: Self, rhs: Self) -> Bool { + return lhs.type.rawValue == rhs.type.rawValue && lhs.interval == rhs.interval + } +} diff --git a/ios/flutter_foreground_task/Sources/flutter_foreground_task/models/ForegroundTaskEventType.swift b/ios/flutter_foreground_task/Sources/flutter_foreground_task/models/ForegroundTaskEventType.swift new file mode 100644 index 00000000..224eba1c --- /dev/null +++ b/ios/flutter_foreground_task/Sources/flutter_foreground_task/models/ForegroundTaskEventType.swift @@ -0,0 +1,18 @@ +// +// ForegroundTaskEventType.swift +// flutter_foreground_task +// +// Created by Woo Jin Hwang on 8/28/24. +// + +import Foundation + +enum ForegroundTaskEventType: Int { + case NOTHING = 1 + case ONCE = 2 + case REPEAT = 3 + + static func fromValue(_ value: Int) -> ForegroundTaskEventType { + return ForegroundTaskEventType(rawValue: value) ?? .NOTHING + } +} diff --git a/ios/flutter_foreground_task/Sources/flutter_foreground_task/models/ForegroundTaskOptions.swift b/ios/flutter_foreground_task/Sources/flutter_foreground_task/models/ForegroundTaskOptions.swift new file mode 100644 index 00000000..c5cb1f93 --- /dev/null +++ b/ios/flutter_foreground_task/Sources/flutter_foreground_task/models/ForegroundTaskOptions.swift @@ -0,0 +1,64 @@ +// +// ForegroundTaskOptions.swift +// flutter_foreground_task +// +// Created by Woo Jin Hwang on 9/24/24. +// + +import Foundation + +struct ForegroundTaskOptions { + let eventAction: ForegroundTaskEventAction + + static func getData() -> ForegroundTaskOptions { + let prefs = UserDefaults.standard + + let eventActionJsonString = prefs.string(forKey: TASK_EVENT_ACTION) + let eventAction: ForegroundTaskEventAction + if eventActionJsonString != nil { + eventAction = ForegroundTaskEventAction.fromJsonString(eventActionJsonString!) + } else { + // for deprecated api + let oldIsOnceEvent = prefs.bool(forKey: IS_ONCE_EVENT) + let oldInterval = prefs.integer(forKey: INTERVAL) + if oldIsOnceEvent { + eventAction = ForegroundTaskEventAction(type: .ONCE, interval: oldInterval) + } else { + eventAction = ForegroundTaskEventAction(type: .REPEAT, interval: oldInterval) + } + } + + return ForegroundTaskOptions(eventAction: eventAction) + } + + static func setData(args: Dictionary) { + let prefs = UserDefaults.standard + + if let eventActionJson = args[TASK_EVENT_ACTION] as? Dictionary { + if let eventActionJsonData = try? JSONSerialization.data(withJSONObject: eventActionJson, options: []) { + if let eventActionJsonString = String(data: eventActionJsonData, encoding: .utf8) { + prefs.set(eventActionJsonString, forKey: TASK_EVENT_ACTION) + } + } + } + } + + static func updateData(args: Dictionary) { + let prefs = UserDefaults.standard + + if let eventActionJson = args[TASK_EVENT_ACTION] as? Dictionary { + if let eventActionJsonData = try? JSONSerialization.data(withJSONObject: eventActionJson, options: []) { + if let eventActionJsonString = String(data: eventActionJsonData, encoding: .utf8) { + prefs.set(eventActionJsonString, forKey: TASK_EVENT_ACTION) + } + } + } + } + + static func clearData() { + let prefs = UserDefaults.standard + prefs.removeObject(forKey: TASK_EVENT_ACTION) // new + prefs.removeObject(forKey: INTERVAL) // deprecated + prefs.removeObject(forKey: IS_ONCE_EVENT) // deprecated + } +} diff --git a/ios/flutter_foreground_task/Sources/flutter_foreground_task/models/NotificationButton.swift b/ios/flutter_foreground_task/Sources/flutter_foreground_task/models/NotificationButton.swift new file mode 100644 index 00000000..5ea18708 --- /dev/null +++ b/ios/flutter_foreground_task/Sources/flutter_foreground_task/models/NotificationButton.swift @@ -0,0 +1,33 @@ +// +// NotificationButton.swift +// flutter_foreground_task +// +// Created by Woo Jin Hwang on 8/5/24. +// + +import Foundation + +private let ID_KEY = "id" +private let TEXT_KEY = "text" + +struct NotificationButton { + let id: String + let text: String + + static func fromJSONObject(_ jsonObj: Any) -> NotificationButton { + var id: String = "unknown" + var text: String = "" + + if let _jsonObj = jsonObj as? Dictionary { + if let _id = _jsonObj[ID_KEY] as? String { + id = _id + } + + if let _text = _jsonObj[TEXT_KEY] as? String { + text = _text + } + } + + return NotificationButton(id: id, text: text) + } +} diff --git a/ios/flutter_foreground_task/Sources/flutter_foreground_task/models/NotificationContent.swift b/ios/flutter_foreground_task/Sources/flutter_foreground_task/models/NotificationContent.swift new file mode 100644 index 00000000..2d44f3c9 --- /dev/null +++ b/ios/flutter_foreground_task/Sources/flutter_foreground_task/models/NotificationContent.swift @@ -0,0 +1,79 @@ +// +// NotificationContent.swift +// flutter_foreground_task +// +// Created by Woo Jin Hwang on 8/2/24. +// + +import Foundation + +struct NotificationContent { + let title: String + let text: String + let buttons: Array + + static func getData() -> NotificationContent { + let prefs = UserDefaults.standard + + let title = prefs.string(forKey: NOTIFICATION_CONTENT_TITLE) ?? "" + let text = prefs.string(forKey: NOTIFICATION_CONTENT_TEXT) ?? "" + + var buttons: [NotificationButton] = [] + if let buttonsJsonString = prefs.string(forKey: NOTIFICATION_CONTENT_BUTTONS) { + if let buttonsJsonData = buttonsJsonString.data(using: .utf8) { + if let buttonsJsonArr = try? JSONSerialization.jsonObject(with: buttonsJsonData, options: []) as? [Any] { + for buttonJsonObj in buttonsJsonArr { + buttons.append(NotificationButton.fromJSONObject(buttonJsonObj)) + } + } + } + } + + return NotificationContent(title: title, text: text, buttons: buttons) + } + + static func setData(args: Dictionary) { + let prefs = UserDefaults.standard + + let title = args[NOTIFICATION_CONTENT_TITLE] as? String ?? "" + prefs.set(title, forKey: NOTIFICATION_CONTENT_TITLE) + + let text = args[NOTIFICATION_CONTENT_TEXT] as? String ?? "" + prefs.set(text, forKey: NOTIFICATION_CONTENT_TEXT) + + if let buttonsJson = args[NOTIFICATION_CONTENT_BUTTONS] as? [Any] { + if let buttonsJsonData = try? JSONSerialization.data(withJSONObject: buttonsJson, options: []) { + if let buttonsJsonString = String(data: buttonsJsonData, encoding: .utf8) { + prefs.set(buttonsJsonString, forKey: NOTIFICATION_CONTENT_BUTTONS) + } + } + } + } + + static func updateData(args: Dictionary) { + let prefs = UserDefaults.standard + + if let title = args[NOTIFICATION_CONTENT_TITLE] as? String { + prefs.set(title, forKey: NOTIFICATION_CONTENT_TITLE) + } + + if let text = args[NOTIFICATION_CONTENT_TEXT] as? String { + prefs.set(text, forKey: NOTIFICATION_CONTENT_TEXT) + } + + if let buttonsJson = args[NOTIFICATION_CONTENT_BUTTONS] as? [Any] { + if let buttonsJsonData = try? JSONSerialization.data(withJSONObject: buttonsJson, options: []) { + if let buttonsJsonString = String(data: buttonsJsonData, encoding: .utf8) { + prefs.set(buttonsJsonString, forKey: NOTIFICATION_CONTENT_BUTTONS) + } + } + } + } + + static func clearData() { + let prefs = UserDefaults.standard + prefs.removeObject(forKey: NOTIFICATION_CONTENT_TITLE) + prefs.removeObject(forKey: NOTIFICATION_CONTENT_TEXT) + prefs.removeObject(forKey: NOTIFICATION_CONTENT_BUTTONS) + } +} diff --git a/ios/flutter_foreground_task/Sources/flutter_foreground_task/models/NotificationOptions.swift b/ios/flutter_foreground_task/Sources/flutter_foreground_task/models/NotificationOptions.swift new file mode 100644 index 00000000..14fa854a --- /dev/null +++ b/ios/flutter_foreground_task/Sources/flutter_foreground_task/models/NotificationOptions.swift @@ -0,0 +1,38 @@ +// +// NotificationOptions.swift +// flutter_foreground_task +// +// Created by Woo Jin Hwang on 8/2/24. +// + +import Foundation + +struct NotificationOptions { + let showNotification: Bool + let playSound: Bool + + static func getData() -> NotificationOptions { + let prefs = UserDefaults.standard + + let showNotification = prefs.bool(forKey: SHOW_NOTIFICATION) + let playSound = prefs.bool(forKey: PLAY_SOUND) + + return NotificationOptions(showNotification: showNotification, playSound: playSound) + } + + static func setData(args: Dictionary) { + let prefs = UserDefaults.standard + + let showNotification = args[SHOW_NOTIFICATION] as? Bool ?? false + let playSound = args[PLAY_SOUND] as? Bool ?? false + + prefs.set(showNotification, forKey: SHOW_NOTIFICATION) + prefs.set(playSound, forKey: PLAY_SOUND) + } + + static func clearData() { + let prefs = UserDefaults.standard + prefs.removeObject(forKey: SHOW_NOTIFICATION) + prefs.removeObject(forKey: PLAY_SOUND) + } +} diff --git a/ios/flutter_foreground_task/Sources/flutter_foreground_task/models/NotificationPermission.swift b/ios/flutter_foreground_task/Sources/flutter_foreground_task/models/NotificationPermission.swift new file mode 100644 index 00000000..157bd9db --- /dev/null +++ b/ios/flutter_foreground_task/Sources/flutter_foreground_task/models/NotificationPermission.swift @@ -0,0 +1,13 @@ +// +// NotificationPermission.swift +// flutter_foreground_task +// +// Created by Woo Jin Hwang on 8/6/24. +// + +import Foundation + +enum NotificationPermission : Int { + case GRANTED = 0 + case DENIED = 1 +} diff --git a/ios/flutter_foreground_task/Sources/flutter_foreground_task/service/BackgroundService.swift b/ios/flutter_foreground_task/Sources/flutter_foreground_task/service/BackgroundService.swift new file mode 100644 index 00000000..4029ad80 --- /dev/null +++ b/ios/flutter_foreground_task/Sources/flutter_foreground_task/service/BackgroundService.swift @@ -0,0 +1,206 @@ +// +// BackgroundService.swift +// flutter_foreground_task +// +// Created by WOO JIN HWANG on 2021/08/11. +// + +import Flutter +import Foundation +import UserNotifications + +private let NOTIFICATION_ID = "flutter_foreground_task/notification" +private let NOTIFICATION_CATEGORY_ID = "flutter_foreground_task/notification_category" + +private let ACTION_RECEIVE_DATA = "onReceiveData" +private let ACTION_NOTIFICATION_BUTTON_PRESSED = "onNotificationButtonPressed" +private let ACTION_NOTIFICATION_PRESSED = "onNotificationPressed" +private let ACTION_NOTIFICATION_DISMISSED = "onNotificationDismissed" + +@available(iOS 10.0, *) +class BackgroundService: NSObject { + static let sharedInstance = BackgroundService() + + private(set) var isRunningService: Bool = false + + private var foregroundTask: ForegroundTask? = nil + private var taskLifecycleListeners = ForegroundTaskLifecycleListeners() + + func sendData(data: Any?) { + if isRunningService { + foregroundTask?.invokeMethod(ACTION_RECEIVE_DATA, arguments: data) + } + } + + func addTaskLifecycleListener(_ listener: FlutterForegroundTaskLifecycleListener) { + taskLifecycleListeners.addListener(listener) + } + + func removeTaskLifecycleListener(_ listener: FlutterForegroundTaskLifecycleListener) { + taskLifecycleListeners.removeListener(listener) + } + + private let notificationCenter: UNUserNotificationCenter + private let notificationPermissionManager: NotificationPermissionManager + private var canReceiveNotificationResponse: Bool = false + + private var backgroundServiceStatus: BackgroundServiceStatus + private var notificationOptions: NotificationOptions + private var notificationContent: NotificationContent + private var prevForegroundTaskOptions: ForegroundTaskOptions? + private var currForegroundTaskOptions: ForegroundTaskOptions + private var prevForegroundTaskData: ForegroundTaskData? + private var currForegroundTaskData: ForegroundTaskData + + override init() { + notificationCenter = UNUserNotificationCenter.current() + notificationPermissionManager = NotificationPermissionManager() + backgroundServiceStatus = BackgroundServiceStatus.getData() + notificationOptions = NotificationOptions.getData() + notificationContent = NotificationContent.getData() + currForegroundTaskOptions = ForegroundTaskOptions.getData() + currForegroundTaskData = ForegroundTaskData.getData() + super.init() + } + + func run() { + backgroundServiceStatus = BackgroundServiceStatus.getData() + notificationOptions = NotificationOptions.getData() + notificationContent = NotificationContent.getData() + prevForegroundTaskOptions = currForegroundTaskOptions + currForegroundTaskOptions = ForegroundTaskOptions.getData() + prevForegroundTaskData = currForegroundTaskData + currForegroundTaskData = ForegroundTaskData.getData() + + switch backgroundServiceStatus.action { + case .API_START, .API_RESTART: + requestNotification() + createForegroundTask() + isRunningService = true + break + case .API_UPDATE: + requestNotification() + let prevCallbackHandle = prevForegroundTaskData?.callbackHandle + let currCallbackHandle = currForegroundTaskData.callbackHandle + if prevCallbackHandle != currCallbackHandle { + createForegroundTask() + } else { + let prevEventAction = prevForegroundTaskOptions?.eventAction + let currEventAction = currForegroundTaskOptions.eventAction + if prevEventAction != currEventAction { + updateForegroundTask() + } + } + break + case .API_STOP, .APP_TERMINATE: + destroyForegroundTask() + removeAllNotification() + isRunningService = false + break + } + } + + func userNotificationCenter(_ center: UNUserNotificationCenter, + _ response: UNNotificationResponse, + _ completionHandler: @escaping () -> Void) { + // If it is not a notification requested by this plugin, the processing below is ignored. + if response.notification.request.identifier != NOTIFICATION_ID { return } + + // Prevents duplicate processing due to the `registrar.addApplicationDelegate`. + if !canReceiveNotificationResponse { return } + canReceiveNotificationResponse = false + + let actionId = response.actionIdentifier + if notificationContent.buttons.contains(where: { $0.id == actionId }) { + foregroundTask?.invokeMethod(ACTION_NOTIFICATION_BUTTON_PRESSED, arguments: actionId) + } else if actionId == UNNotificationDefaultActionIdentifier { + foregroundTask?.invokeMethod(ACTION_NOTIFICATION_PRESSED, arguments: nil) + } else if actionId == UNNotificationDismissActionIdentifier { + foregroundTask?.invokeMethod(ACTION_NOTIFICATION_DISMISSED, arguments: nil) + } + + completionHandler() + } + + func userNotificationCenter(_ center: UNUserNotificationCenter, + _ notification: UNNotification, + _ completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) { + // If it is not a notification requested by this plugin, the processing below is ignored. + if notification.request.identifier != NOTIFICATION_ID { return } + + if notificationOptions.playSound { + completionHandler([.alert, .sound]) + } else { + completionHandler([.alert]) + } + + // Prevents duplicate processing due to the `registrar.addApplicationDelegate`. + canReceiveNotificationResponse = true + } + + private func setNotificationActions() { + var actions: [UNNotificationAction] = [] + for button in notificationContent.buttons { + let action = UNNotificationAction(identifier: button.id, title: button.text) + actions.append(action) + } + + let category = UNNotificationCategory( + identifier: NOTIFICATION_CATEGORY_ID, + actions: actions, + intentIdentifiers: [], + options: .customDismissAction + ) + + notificationCenter.setNotificationCategories([category]) + } + + private func requestNotification() { + if !notificationOptions.showNotification { + return + } + + notificationPermissionManager.checkPermission { permission in + if permission == NotificationPermission.DENIED { + return + } + + let content = UNMutableNotificationContent() + content.title = self.notificationContent.title + content.body = self.notificationContent.text + content.categoryIdentifier = NOTIFICATION_CATEGORY_ID + if self.notificationOptions.playSound { + content.sound = .default + } + self.setNotificationActions() + + let request = UNNotificationRequest(identifier: NOTIFICATION_ID, content: content, trigger: nil) + self.notificationCenter.add(request, withCompletionHandler: nil) + } + } + + private func removeAllNotification() { + notificationCenter.removePendingNotificationRequests(withIdentifiers: [NOTIFICATION_ID]) + notificationCenter.removeDeliveredNotifications(withIdentifiers: [NOTIFICATION_ID]) + } + + private func createForegroundTask() { + destroyForegroundTask() + + foregroundTask = ForegroundTask( + serviceStatus: backgroundServiceStatus, + taskData: currForegroundTaskData, + taskEventAction: currForegroundTaskOptions.eventAction, + taskLifecycleListener: taskLifecycleListeners + ) + } + + private func updateForegroundTask() { + foregroundTask?.update(taskEventAction: currForegroundTaskOptions.eventAction) + } + + private func destroyForegroundTask() { + foregroundTask?.destroy() + foregroundTask = nil + } +} diff --git a/ios/flutter_foreground_task/Sources/flutter_foreground_task/service/BackgroundServiceManager.swift b/ios/flutter_foreground_task/Sources/flutter_foreground_task/service/BackgroundServiceManager.swift new file mode 100644 index 00000000..ae30acb5 --- /dev/null +++ b/ios/flutter_foreground_task/Sources/flutter_foreground_task/service/BackgroundServiceManager.swift @@ -0,0 +1,96 @@ +// +// BackgroundServiceManager.swift +// flutter_foreground_task +// +// Created by WOO JIN HWANG on 2021/08/10. +// + +import Flutter +import Foundation + +class BackgroundServiceManager: NSObject { + func start(arguments: Any?) throws { + if #available(iOS 10.0, *) { + if BackgroundService.sharedInstance.isRunningService { + throw ServiceError.ServiceAlreadyStartedException + } + + guard let args = arguments as? Dictionary else { + throw ServiceError.ServiceArgumentNullException + } + + BackgroundServiceStatus.setData(action: BackgroundServiceAction.API_START) + NotificationOptions.setData(args: args) + NotificationContent.setData(args: args) + ForegroundTaskOptions.setData(args: args) + ForegroundTaskData.setData(args: args) + BackgroundService.sharedInstance.run() + } else { + throw ServiceError.ServiceNotSupportedException + } + } + + func restart(arguments: Any?) throws { + if #available(iOS 10.0, *) { + if !BackgroundService.sharedInstance.isRunningService { + throw ServiceError.ServiceNotStartedException + } + + BackgroundServiceStatus.setData(action: BackgroundServiceAction.API_RESTART) + BackgroundService.sharedInstance.run() + } else { + throw ServiceError.ServiceNotSupportedException + } + } + + func update(arguments: Any?) throws { + if #available(iOS 10.0, *) { + if !BackgroundService.sharedInstance.isRunningService { + throw ServiceError.ServiceNotStartedException + } + + guard let args = arguments as? Dictionary else { + throw ServiceError.ServiceArgumentNullException + } + + BackgroundServiceStatus.setData(action: BackgroundServiceAction.API_UPDATE) + NotificationContent.updateData(args: args) + ForegroundTaskOptions.updateData(args: args) + ForegroundTaskData.updateData(args: args) + BackgroundService.sharedInstance.run() + } else { + throw ServiceError.ServiceNotSupportedException + } + } + + func stop() throws { + if #available(iOS 10.0, *) { + if !BackgroundService.sharedInstance.isRunningService { + throw ServiceError.ServiceNotStartedException + } + + BackgroundServiceStatus.setData(action: BackgroundServiceAction.API_STOP) + NotificationOptions.clearData() + NotificationContent.clearData() + ForegroundTaskOptions.clearData() + ForegroundTaskData.clearData() + BackgroundService.sharedInstance.run() + } else { + throw ServiceError.ServiceNotSupportedException + } + } + + func sendData(data: Any?) { + if data != nil { + BackgroundService.sharedInstance.sendData(data: data) + } + } + + func isRunningService() -> Bool { + if #available(iOS 10.0, *) { + return BackgroundService.sharedInstance.isRunningService + } else { + return false + } + } +} diff --git a/ios/flutter_foreground_task/Sources/flutter_foreground_task/service/ForegroundTask.swift b/ios/flutter_foreground_task/Sources/flutter_foreground_task/service/ForegroundTask.swift new file mode 100644 index 00000000..20c5b585 --- /dev/null +++ b/ios/flutter_foreground_task/Sources/flutter_foreground_task/service/ForegroundTask.swift @@ -0,0 +1,193 @@ +// +// ForegroundTask.swift +// flutter_foreground_task +// +// Created by Woo Jin Hwang on 9/24/24. +// + +import Foundation +import Flutter + +private let BG_ISOLATE_NAME = "flutter_foreground_task/backgroundIsolate" +private let BG_CHANNEL_NAME = "flutter_foreground_task/background" + +private let ACTION_TASK_START = "onStart" +private let ACTION_TASK_REPEAT_EVENT = "onRepeatEvent" +private let ACTION_TASK_DESTROY = "onDestroy" + +class ForegroundTask { + private let serviceStatus: BackgroundServiceStatus + private let taskData: ForegroundTaskData + private var taskEventAction: ForegroundTaskEventAction + private let taskLifecycleListener: FlutterForegroundTaskLifecycleListener + + private var flutterEngine: FlutterEngine? = nil + private var backgroundChannel: FlutterMethodChannel? = nil + private var repeatTask: Timer? = nil + private var isDestroyed: Bool = false + + init( + serviceStatus: BackgroundServiceStatus, + taskData: ForegroundTaskData, + taskEventAction: ForegroundTaskEventAction, + taskLifecycleListener: FlutterForegroundTaskLifecycleListener + ) { + self.serviceStatus = serviceStatus + self.taskData = taskData + self.taskEventAction = taskEventAction + self.taskLifecycleListener = taskLifecycleListener + initialize() + } + + private func initialize() { + guard let registerPlugins = SwiftFlutterForegroundTaskPlugin.registerPlugins else { + print("Please register the registerPlugins function using the SwiftFlutterForegroundTaskPlugin.setPluginRegistrantCallback.") + return + } + + guard let callbackHandle = taskData.callbackHandle else { + // no callback -> Unlike Android, the flutter engine does not start. + return + } + + // lookup callback + let callbackInfo = FlutterCallbackCache.lookupCallbackInformation(callbackHandle) + guard let entrypoint = callbackInfo?.callbackName else { + print("Entrypoint not found in callback information.") + return + } + guard let libraryURI = callbackInfo?.callbackLibraryPath else { + print("LibraryURI not found in callback information.") + return + } + + // create flutter engine & execute callback + let flutterEngine = FlutterEngine(name: BG_ISOLATE_NAME, project: nil, allowHeadlessExecution: true) + let isRunningEngine = flutterEngine.run(withEntrypoint: entrypoint, libraryURI: libraryURI) + + if isRunningEngine { + // register plugins + registerPlugins(flutterEngine) + taskLifecycleListener.onEngineCreate(flutterEngine: flutterEngine) + + // create background channel + let messenger = flutterEngine.binaryMessenger + let backgroundChannel = FlutterMethodChannel(name: BG_CHANNEL_NAME, binaryMessenger: messenger) + backgroundChannel.setMethodCallHandler(onMethodCall) + + self.flutterEngine = flutterEngine + self.backgroundChannel = backgroundChannel + } + } + + func onMethodCall(call: FlutterMethodCall, result: @escaping FlutterResult) { + switch call.method { + case "start": + start() + default: + result(FlutterMethodNotImplemented) + } + } + + private func start() { + runIfNotDestroyed { + runIfCallbackHandleExists { + let serviceAction = serviceStatus.action + let starter: FlutterForegroundTaskStarter + if serviceAction == .API_START || serviceAction == .API_RESTART || serviceAction == .API_UPDATE { + starter = .DEVELOPER + } else { + starter = .SYSTEM + } + + backgroundChannel?.invokeMethod(ACTION_TASK_START, arguments: starter.rawValue) { _ in + self.runIfNotDestroyed { + self.startRepeatTask() + } + } + taskLifecycleListener.onTaskStart(starter: starter) + } + } + } + + private func invokeTaskRepeatEvent() { + backgroundChannel?.invokeMethod(ACTION_TASK_REPEAT_EVENT, arguments: nil) + taskLifecycleListener.onTaskRepeatEvent() + } + + private func startRepeatTask() { + stopRepeatTask() + + let type = taskEventAction.type + let interval = TimeInterval(Double(taskEventAction.interval) / 1000) + + if type == .NOTHING { + return + } + + if type == .ONCE { + invokeTaskRepeatEvent() + return + } + + repeatTask = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { _ in + self.invokeTaskRepeatEvent() + } + } + + private func stopRepeatTask() { + repeatTask?.invalidate() + repeatTask = nil + } + + func invokeMethod(_ method: String, arguments: Any?) { + runIfNotDestroyed { + backgroundChannel?.invokeMethod(method, arguments: arguments) + } + } + + func update(taskEventAction: ForegroundTaskEventAction) { + runIfNotDestroyed { + runIfCallbackHandleExists { + self.taskEventAction = taskEventAction + startRepeatTask() + } + } + } + + func destroy() { + runIfNotDestroyed { + stopRepeatTask() + + backgroundChannel?.setMethodCallHandler(nil) + if taskData.callbackHandle == nil { + taskLifecycleListener.onEngineWillDestroy() + flutterEngine?.destroyContext() + flutterEngine = nil + } else { + backgroundChannel?.invokeMethod(ACTION_TASK_DESTROY, arguments: nil) { _ in + self.flutterEngine?.destroyContext() + self.flutterEngine = nil + } + taskLifecycleListener.onTaskDestroy() + taskLifecycleListener.onEngineWillDestroy() + } + + isDestroyed = true + } + } + + private func runIfCallbackHandleExists(call: () -> Void) { + if taskData.callbackHandle == nil { + return + } + call() + } + + private func runIfNotDestroyed(call: () -> Void) { + if isDestroyed { + return + } + call() + } +} diff --git a/ios/flutter_foreground_task/Sources/flutter_foreground_task/service/ForegroundTaskLifecycleListeners.swift b/ios/flutter_foreground_task/Sources/flutter_foreground_task/service/ForegroundTaskLifecycleListeners.swift new file mode 100644 index 00000000..7724b559 --- /dev/null +++ b/ios/flutter_foreground_task/Sources/flutter_foreground_task/service/ForegroundTaskLifecycleListeners.swift @@ -0,0 +1,55 @@ +// +// ForegroundTaskLifecycleListeners.swift +// flutter_foreground_task +// +// Created by Woo Jin Hwang on 9/24/24. +// + +import Foundation +import Flutter + +class ForegroundTaskLifecycleListeners : FlutterForegroundTaskLifecycleListener { + private var listeners: Array = [] + + func addListener(_ listener: FlutterForegroundTaskLifecycleListener) { + if listeners.contains(where: { $0 === listener }) == false { + listeners.append(listener) + } + } + + func removeListener(_ listener: FlutterForegroundTaskLifecycleListener) { + if let index = listeners.firstIndex(where: { $0 === listener }) { + listeners.remove(at: index) + } + } + + func onEngineCreate(flutterEngine: FlutterEngine?) { + for listener in listeners { + listener.onEngineCreate(flutterEngine: flutterEngine) + } + } + + func onTaskStart(starter: FlutterForegroundTaskStarter) { + for listener in listeners { + listener.onTaskStart(starter: starter) + } + } + + func onTaskRepeatEvent() { + for listener in listeners { + listener.onTaskRepeatEvent() + } + } + + func onTaskDestroy() { + for listener in listeners { + listener.onTaskDestroy() + } + } + + func onEngineWillDestroy() { + for listener in listeners { + listener.onEngineWillDestroy() + } + } +} diff --git a/ios/flutter_foreground_task/Sources/flutter_foreground_task/service/NotificationPermissionManager.swift b/ios/flutter_foreground_task/Sources/flutter_foreground_task/service/NotificationPermissionManager.swift new file mode 100644 index 00000000..6df0b3f1 --- /dev/null +++ b/ios/flutter_foreground_task/Sources/flutter_foreground_task/service/NotificationPermissionManager.swift @@ -0,0 +1,39 @@ +// +// NotificationPermissionManager.swift +// flutter_foreground_task +// +// Created by Woo Jin Hwang on 8/6/24. +// + +import Foundation +import UserNotifications + +class NotificationPermissionManager { + func checkPermission(completion: @escaping (NotificationPermission) -> Void) { + UNUserNotificationCenter.current().getNotificationSettings { settings in + switch settings.authorizationStatus { + case .authorized: + completion(NotificationPermission.GRANTED) + case .denied, .ephemeral, .notDetermined, .provisional: + completion(NotificationPermission.DENIED) + @unknown default: + completion(NotificationPermission.DENIED) + } + } + } + + func requestPermission(completion: @escaping (NotificationPermission) -> Void) { + let options = UNAuthorizationOptions(arrayLiteral: .alert, .sound) + UNUserNotificationCenter.current().requestAuthorization(options: options) { granted, error in + if error != nil { + completion(NotificationPermission.DENIED) + } else { + if (granted) { + completion(NotificationPermission.GRANTED) + } else { + completion(NotificationPermission.DENIED) + } + } + } + } +} diff --git a/pubspec.yaml b/pubspec.yaml index 23772e83..4a9249c3 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -26,4 +26,4 @@ flutter: package: com.pravera.flutter_foreground_task pluginClass: FlutterForegroundTaskPlugin ios: - pluginClass: FlutterForegroundTaskPlugin + pluginClass: SwiftFlutterForegroundTaskPlugin