From de6084d709f823e223d0411da76c2b6b22e05388 Mon Sep 17 00:00:00 2001 From: Michael Date: Mon, 12 Oct 2026 15:02:59 +0200 Subject: [PATCH] Wait for FCM token before replying to push enable and add ORNotificationHandler On a fresh install the push provider replied to PROVIDER_ENABLE with the cached FCM token, which Firebase had not delivered yet. The console registration then had no token and push notifications only worked after an app restart (#39). Like the Android console, the provider now registers for remote notifications straight away and waits for the token before replying. The permission prompt is shown in parallel, so its duration does not count against the web console's 5 second provider timeout. The wait is capped at 4.5 seconds. A token that arrives later, or a rotated token, is sent the next time the console enables push, which happens on every page load. ORNotificationHandler moves the notification and FCM token handling that every console app duplicated into ORLib (#21). Apps forward their UNUserNotificationCenterDelegate and MessagingDelegate callbacks to it; it stores the token and notifies a waiting push provider. It finds the console view controller through the connected scenes, which also makes "open in app" work for scene based apps. --- ORLib.xcodeproj/project.pbxproj | 16 ++ .../ORNotificationHandler.swift | 237 ++++++++++++++++++ .../PushNotificationProvider.swift | 155 +++++++++--- ORLib/UI/ORViewController.swift | 5 +- ORLib/Utils/Constants.swift | 2 + README.md | 35 +++ Tests/ESPProvisionURLResolverTest.swift | 16 +- Tests/ORNotificationHandlerTest.swift | 194 ++++++++++++++ Tests/PushNotificationProviderTest.swift | 190 ++++++++++++++ Tests/TemporaryUserDefaults.swift | 36 +++ 10 files changed, 831 insertions(+), 55 deletions(-) create mode 100644 ORLib/ConsoleProviders/ORNotificationHandler.swift create mode 100644 Tests/ORNotificationHandlerTest.swift create mode 100644 Tests/PushNotificationProviderTest.swift create mode 100644 Tests/TemporaryUserDefaults.swift diff --git a/ORLib.xcodeproj/project.pbxproj b/ORLib.xcodeproj/project.pbxproj index fc72785..404598a 100644 --- a/ORLib.xcodeproj/project.pbxproj +++ b/ORLib.xcodeproj/project.pbxproj @@ -85,6 +85,10 @@ 91F2166C2D887C53008F9CA7 /* ORConfigChannelTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91F216672D887C53008F9CA7 /* ORConfigChannelTest.swift */; }; 91F2166D2D887C53008F9CA7 /* ORESPProvisionManagerMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91F2166E2D887C53008F9CA7 /* ORESPProvisionManagerMock.swift */; }; 91F216722D887C53008F9CA7 /* TestTimeSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91F216732D887C53008F9CA7 /* TestTimeSource.swift */; }; + AA0E1F022F8C000000A1B2C3 /* ORNotificationHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0E1F012F8C000000A1B2C3 /* ORNotificationHandler.swift */; }; + AA0E1F042F8C000000A1B2C3 /* PushNotificationProviderTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0E1F032F8C000000A1B2C3 /* PushNotificationProviderTest.swift */; }; + AA0E1F062F8C000000A1B2C3 /* ORNotificationHandlerTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0E1F052F8C000000A1B2C3 /* ORNotificationHandlerTest.swift */; }; + AA0E1F082F8C000000A1B2C3 /* TemporaryUserDefaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0E1F072F8C000000A1B2C3 /* TemporaryUserDefaults.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -192,6 +196,10 @@ 91F216682D887C53008F9CA7 /* ORESPDeviceMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ORESPDeviceMock.swift; sourceTree = ""; }; 91F2166E2D887C53008F9CA7 /* ORESPProvisionManagerMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ORESPProvisionManagerMock.swift; sourceTree = ""; }; 91F216732D887C53008F9CA7 /* TestTimeSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestTimeSource.swift; sourceTree = ""; }; + AA0E1F012F8C000000A1B2C3 /* ORNotificationHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ORNotificationHandler.swift; sourceTree = ""; }; + AA0E1F032F8C000000A1B2C3 /* PushNotificationProviderTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushNotificationProviderTest.swift; sourceTree = ""; }; + AA0E1F052F8C000000A1B2C3 /* ORNotificationHandlerTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ORNotificationHandlerTest.swift; sourceTree = ""; }; + AA0E1F072F8C000000A1B2C3 /* TemporaryUserDefaults.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemporaryUserDefaults.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -261,6 +269,7 @@ 4CF5D05227143F1F00D705BE /* StorageProvider.swift */, 4CF5D05327143F1F00D705BE /* GeofenceProvider.swift */, 4CF5D05427143F1F00D705BE /* PushNotificationProvider.swift */, + AA0E1F012F8C000000A1B2C3 /* ORNotificationHandler.swift */, 4C0E4A11274BE411008CB352 /* QrScannerProvider.swift */, 4C649E6729C251A300FFBEA4 /* BleProvider.swift */, ); @@ -364,6 +373,9 @@ 9154E29F2D9EB0D50055E565 /* StringUtilsTest.swift */, 9154E2A12D9EB3220055E565 /* URLTest.swift */, 91E8A0022F07700000E5BF01 /* ESPProvisionURLResolverTest.swift */, + AA0E1F052F8C000000A1B2C3 /* ORNotificationHandlerTest.swift */, + AA0E1F032F8C000000A1B2C3 /* PushNotificationProviderTest.swift */, + AA0E1F072F8C000000A1B2C3 /* TemporaryUserDefaults.swift */, ); path = Tests; sourceTree = ""; @@ -544,6 +556,7 @@ 4CBDF2BD2AE2869D00C7D94C /* StorageProvider.swift in Sources */, 4CBDF2BE2AE2869D00C7D94C /* GeofenceProvider.swift in Sources */, 4CBDF2BF2AE2869D00C7D94C /* PushNotificationProvider.swift in Sources */, + AA0E1F022F8C000000A1B2C3 /* ORNotificationHandler.swift in Sources */, 4CBDF2C02AE2869D00C7D94C /* QrScannerProvider.swift in Sources */, 4CBDF2C12AE2869D00C7D94C /* BleProvider.swift in Sources */, 4CBDF2C22AE2869D00C7D94C /* ORLinkConfig.swift in Sources */, @@ -602,6 +615,9 @@ 91A9A90128BF6EA000DF8928 /* FileApiManager.swift in Sources */, 9154E2A22D9EB3220055E565 /* URLTest.swift in Sources */, 91E8A0032F07700000E5BF01 /* ESPProvisionURLResolverTest.swift in Sources */, + AA0E1F062F8C000000A1B2C3 /* ORNotificationHandlerTest.swift in Sources */, + AA0E1F042F8C000000A1B2C3 /* PushNotificationProviderTest.swift in Sources */, + AA0E1F082F8C000000A1B2C3 /* TemporaryUserDefaults.swift in Sources */, 9154E2A02D9EB0D50055E565 /* StringUtilsTest.swift in Sources */, 91A9A8FB28BF6A4900DF8928 /* ConfigManagerTest.swift in Sources */, 91AA79F328D628E9005B9913 /* Fixture.swift in Sources */, diff --git a/ORLib/ConsoleProviders/ORNotificationHandler.swift b/ORLib/ConsoleProviders/ORNotificationHandler.swift new file mode 100644 index 0000000..00000ba --- /dev/null +++ b/ORLib/ConsoleProviders/ORNotificationHandler.swift @@ -0,0 +1,237 @@ +/* + * Copyright 2026, OpenRemote Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import UIKit +import UserNotifications + +/// Handles push notification related callbacks on behalf of console apps. +/// +/// The app owns the Firebase setup: call `FirebaseApp.configure()` and set `Messaging.messaging().delegate` on every +/// launch path, and set `Messaging.messaging().apnsToken` in `didRegisterForRemoteNotificationsWithDeviceToken` +/// when Firebase method swizzling is disabled. Without an APNs token Firebase never delivers an FCM token. +/// +/// Apps forward their `UNUserNotificationCenterDelegate` and Firebase `MessagingDelegate` callbacks to the shared instance: +/// ```swift +/// func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, +/// withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) { +/// completionHandler(ORNotificationHandler.shared.willPresent(notification)) +/// } +/// +/// func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, +/// withCompletionHandler completionHandler: @escaping () -> Void) { +/// ORNotificationHandler.shared.didReceive(response) +/// completionHandler() +/// } +/// +/// func messaging(_ messaging: Messaging, didReceiveRegistrationToken fcmToken: String?) { +/// ORNotificationHandler.shared.didReceiveRegistrationToken(fcmToken) +/// } +/// ``` +public final class ORNotificationHandler { + + public static let shared = ORNotificationHandler() + + typealias Delivered = (_ notificationId: Int64, _ consoleId: String) -> Void + typealias Acknowledge = (_ notificationId: Int64, _ consoleId: String, _ acknowledgement: String) -> Void + typealias Open = (_ url: URL, _ inBrowser: Bool) -> Void + typealias Send = (_ request: URLRequest) -> Void + + static let declineActionIdentifier = "declineAction" + + // Resolved on each use as apps can change DefaultsKey.groupEntitlement at launch + private let defaults: () -> UserDefaults? + private let center: NotificationCenter + private let delivered: Delivered + private let acknowledge: Acknowledge + private let open: Open + private let send: Send + + init(defaults: @escaping () -> UserDefaults? = { UserDefaults(suiteName: DefaultsKey.groupEntitlement) }, + center: NotificationCenter = .default, + delivered: @escaping Delivered = { ORNotificationResource.sharedInstance.notificationDelivered(notificationId: $0, targetId: $1) }, + acknowledge: @escaping Acknowledge = { + ORNotificationResource.sharedInstance.notificationAcknowledged(notificationId: $0, targetId: $1, acknowledgement: $2) + }, + open: @escaping Open = ORNotificationHandler.openURL, + send: @escaping Send = ORNotificationHandler.sendRequest) { + self.defaults = defaults + self.center = center + self.delivered = delivered + self.acknowledge = acknowledge + self.open = open + self.send = send + } + + // MARK: - MessagingDelegate + + /// Stores the FCM token and notifies a push provider waiting for it. + /// A rotated token reaches the backend the next time the console enables push (on every page load). + public func didReceiveRegistrationToken(_ token: String?) { + guard let token = token else { + ORLogger.providers.info("No FCM token") + return + } + ORLogger.providers.info("FCM registration token: \(token)") + if let defaults = defaults() { + defaults.set(token, forKey: DefaultsKey.fcmTokenKey) + defaults.synchronize() + } + center.post(name: Notifications.pushTokenUpdated, object: nil, userInfo: [PushNotificationProvider.tokenKey: token]) + } + + // MARK: - UNUserNotificationCenterDelegate + + public func willPresent(_ notification: UNNotification) -> UNNotificationPresentationOptions { + handleWillPresent(userInfo: notification.request.content.userInfo) + return [.banner, .sound] + } + + public func didReceive(_ response: UNNotificationResponse) { + handleResponse(userInfo: response.notification.request.content.userInfo, actionIdentifier: response.actionIdentifier) + } + + func handleWillPresent(userInfo: [AnyHashable: Any]) { + if let notificationId = notificationId(in: userInfo), let consoleId = consoleId() { + delivered(notificationId, consoleId) + } + } + + func handleResponse(userInfo: [AnyHashable: Any], actionIdentifier: String) { + ORLogger.providers.info("Notification action chosen: \(actionIdentifier)") + + switch actionIdentifier { + case UNNotificationDefaultActionIdentifier: + handleDefault(userInfo: userInfo) + case UNNotificationDismissActionIdentifier, ORNotificationHandler.declineActionIdentifier: + acknowledgeIfPossible(userInfo: userInfo, actionIdentifier: actionIdentifier) + default: + acknowledgeIfPossible(userInfo: userInfo, actionIdentifier: actionIdentifier) + handleButton(userInfo: userInfo, actionIdentifier: actionIdentifier) + } + } + + // MARK: - Actions + + private func handleDefault(userInfo: [AnyHashable: Any]) { + guard let path = userInfo[ActionType.appUrl] as? String, !path.isEmpty, + let url = resolveURL(path, project: selectedProject()) else { + return + } + open(url, userInfo[ActionType.openInBrowser] as? Bool ?? false) + } + + private func handleButton(userInfo: [AnyHashable: Any], actionIdentifier: String) { + guard let buttonsData = (userInfo[DefaultsKey.buttonsKey] as? String)?.data(using: .utf8), + let buttons = try? JSONDecoder().decode([ORPushNotificationButton].self, from: buttonsData), + let action = buttons.first(where: { $0.title == actionIdentifier })?.action, + let url = resolveURL(action.url, project: selectedProject()) else { + return + } + perform(action, url: url) + } + + private func perform(_ action: ORPushNotificationAction, url: URL) { + if action.silent { + var request = URLRequest(url: url) + request.httpMethod = action.httpMethod ?? "GET" + if let body = action.data { + request.httpBody = body.data(using: .utf8) + request.addValue("application/json", forHTTPHeaderField: "Content-Type") + } + send(request) + } else { + open(url, action.openInBrowser) + } + } + + private func acknowledgeIfPossible(userInfo: [AnyHashable: Any], actionIdentifier: String) { + if let notificationId = notificationId(in: userInfo), let consoleId = consoleId() { + acknowledge(notificationId, consoleId, actionIdentifier) + } + } + + // MARK: - Helpers + + private func resolveURL(_ path: String, project: ProjectConfig?) -> URL? { + if path.hasPrefix("http") { + return URL(string: path) + } + guard let baseURL = project?.baseURL else { return nil } + return URL(string: "\(baseURL)/console/\(path)") + } + + private func selectedProject() -> ProjectConfig? { + guard let defaults = defaults(), + let projectsData = defaults.data(forKey: DefaultsKey.projectsConfigurationKey), + let projects = try? JSONDecoder().decode([ProjectConfig].self, from: projectsData) else { + return nil + } + let selectedProjectId = defaults.string(forKey: DefaultsKey.projectKey) + return projects.first(where: { $0.id == selectedProjectId }) + } + + private func notificationId(in userInfo: [AnyHashable: Any]) -> Int64? { + guard let notificationIdString = userInfo[ActionType.notificationId] as? String else { return nil } + return Int64(notificationIdString) + } + + // TODO: geofence provider should also be adapted to store "per project" + private func consoleId() -> String? { + defaults()?.string(forKey: GeofenceProvider.consoleIdKey) + } + + static func openURL(_ url: URL, inBrowser: Bool) { + DispatchQueue.main.async { + if inBrowser { + UIApplication.shared.open(url) + } else { + activeORViewController()?.loadURL(url: url) + } + } + } + + static func sendRequest(_ request: URLRequest) { + URLSession.shared.dataTask(with: request) { _, _, error in + if let error = error { + ORLogger.network.error("Notification action request failed: \(error.localizedDescription)") + } + }.resume() + } + + /// Finds the console view controller across all scenes, works for both scene based and window based apps + static func activeORViewController() -> ORViewcontroller? { + let windows = UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .flatMap { $0.windows } + let keyWindowsFirst = windows.filter { $0.isKeyWindow } + windows.filter { !$0.isKeyWindow } + return topORViewController(in: keyWindowsFirst.compactMap { $0.rootViewController }) + } + + /// Returns the top-most console view controller presented from the first root that has one, + /// non-console screens (offline, QR scanner) can be presented on top of it + static func topORViewController(in roots: [UIViewController]) -> ORViewcontroller? { + for root in roots { + let presented = sequence(first: root) { $0.presentedViewController } + if let found = presented.compactMap({ $0 as? ORViewcontroller }).last { + return found + } + } + return nil + } +} diff --git a/ORLib/ConsoleProviders/PushNotificationProvider.swift b/ORLib/ConsoleProviders/PushNotificationProvider.swift index 781a167..38a6516 100644 --- a/ORLib/ConsoleProviders/PushNotificationProvider.swift +++ b/ORLib/ConsoleProviders/PushNotificationProvider.swift @@ -22,18 +22,44 @@ import UserNotifications public class PushNotificationProvider: NSObject { - let userdefaults = UserDefaults(suiteName: DefaultsKey.groupEntitlement) + typealias AuthorizationStatus = (@escaping (UNAuthorizationStatus) -> Void) -> Void + typealias RequestAuthorization = (@escaping (Bool) -> Void) -> Void + typealias Register = () -> Void + + let userdefaults: UserDefaults? + let center: NotificationCenter + private let authorizationStatus: AuthorizationStatus + private let requestAuthorization: RequestAuthorization + private let register: Register let version = "fcm" public var consoleId: String? public static let pushDisabledKey = "pushDisabled" + public static let tokenKey = "token" + + /// How long to wait for the FCM token before replying to an enable request, + /// the web console stops waiting for a provider reply after 5 seconds (`sendProviderMessage` in openremote `console.ts`) + static let tokenWaitTimeout: TimeInterval = 4.5 + + public override convenience init() { + self.init(center: .default, userdefaults: UserDefaults(suiteName: DefaultsKey.groupEntitlement)) + } - public override init() { + init(center: NotificationCenter, + userdefaults: UserDefaults?, + authorizationStatus: @escaping AuthorizationStatus = PushNotificationProvider.currentAuthorizationStatus, + requestAuthorization: @escaping RequestAuthorization = PushNotificationProvider.requestUserAuthorization, + register: @escaping Register = PushNotificationProvider.registerForRemoteNotifications) { + self.center = center + self.userdefaults = userdefaults + self.authorizationStatus = authorizationStatus + self.requestAuthorization = requestAuthorization + self.register = register super.init() } public func initialize(callback: @escaping ([String: Any?]) -> Void) { - UNUserNotificationCenter.current().getNotificationSettings { (settings) in - switch settings.authorizationStatus { + authorizationStatus { status in + switch status { case .authorized, .provisional: callback( [ @@ -74,6 +100,8 @@ public class PushNotificationProvider: NSObject { } } + /// Replies once the FCM token is available (or the wait times out), so the console registration includes it. + /// On first launch the token is only delivered by Firebase after registering for remote notifications. public func enable(consoleId: String?, callback: @escaping ([String: Any]) -> Void) { self.consoleId = consoleId if self.consoleId != nil { @@ -81,49 +109,30 @@ public class PushNotificationProvider: NSObject { userdefaults?.removeObject(forKey: PushNotificationProvider.pushDisabledKey) userdefaults?.synchronize() } - UNUserNotificationCenter.current().getNotificationSettings { (settings) in + let deadline = Date().addingTimeInterval(PushNotificationProvider.tokenWaitTimeout) + authorizationStatus { status in - switch settings.authorizationStatus { + switch status { case .authorized, .provisional: - DispatchQueue.main.async { - UIApplication.shared.registerForRemoteNotifications() + self.register() + self.waitForToken(until: deadline) { token in + callback(self.enableResponse(hasPermission: true, token: token)) } - callback([ - DefaultsKey.actionKey: Actions.providerEnable, - DefaultsKey.providerKey: Providers.push, - DefaultsKey.hasPermissionKey: true, - DefaultsKey.successKey: true, - DefaultsKey.dataKey: ["token": self.userdefaults?.string(forKey: DefaultsKey.fcmTokenKey)] - ]) case .denied: - callback([ - DefaultsKey.actionKey: Actions.providerEnable, - DefaultsKey.providerKey: Providers.push, - DefaultsKey.hasPermissionKey: false, - DefaultsKey.successKey: true, - DefaultsKey.dataKey: ["token": self.userdefaults?.string(forKey: DefaultsKey.fcmTokenKey)] - ]) + callback(self.enableResponse(hasPermission: false, token: self.cachedToken)) default: - UNUserNotificationCenter.current().requestAuthorization( - options: [.alert, .badge, .sound], - completionHandler: {granted, _ in - if granted { - DispatchQueue.main.async { - UIApplication.shared.registerForRemoteNotifications() - } - } - - callback([ - DefaultsKey.actionKey: Actions.providerEnable, - DefaultsKey.providerKey: Providers.push, - DefaultsKey.hasPermissionKey: granted, - DefaultsKey.successKey: true, - DefaultsKey.dataKey: ["token": self.userdefaults?.string(forKey: DefaultsKey.fcmTokenKey)] - ]) - }) - + // The token does not depend on the user's answer, so don't make the reply wait for the prompt + // (which could exceed the web console timeout). Same as the Android console. + var granted: Bool? + self.register() + self.requestAuthorization { isGranted in + DispatchQueue.main.async { granted = isGranted } + } + self.waitForToken(until: deadline) { token in + callback(self.enableResponse(hasPermission: granted ?? true, token: token)) + } } } } @@ -136,4 +145,70 @@ public class PushNotificationProvider: NSObject { DefaultsKey.providerKey: Providers.push ] } + + /// Completes on the main thread with the cached token, or the token posted through `Notifications.pushTokenUpdated`, + /// or whatever is cached once the deadline is reached + func waitForToken(until deadline: Date, completion: @escaping (String?) -> Void) { + DispatchQueue.main.async { + if let token = self.cachedToken { + completion(token) + return + } + + var observer: NSObjectProtocol? + var finished = false + let finish: (String?) -> Void = { token in + guard !finished else { return } + finished = true + if let observer = observer { + self.center.removeObserver(observer) + } + completion(token) + } + + observer = self.center.addObserver(forName: Notifications.pushTokenUpdated, object: nil, queue: .main) { notification in + finish(notification.userInfo?[PushNotificationProvider.tokenKey] as? String ?? self.cachedToken) + } + // The token might have been stored between the first check and adding the observer + if let token = self.cachedToken { + finish(token) + return + } + DispatchQueue.main.asyncAfter(deadline: .now() + max(0, deadline.timeIntervalSinceNow)) { + finish(self.cachedToken) + } + } + } + + private var cachedToken: String? { + userdefaults?.string(forKey: DefaultsKey.fcmTokenKey) + } + + static func currentAuthorizationStatus(_ completion: @escaping (UNAuthorizationStatus) -> Void) { + UNUserNotificationCenter.current().getNotificationSettings { settings in + completion(settings.authorizationStatus) + } + } + + static func requestUserAuthorization(_ completion: @escaping (Bool) -> Void) { + UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .badge, .sound]) { granted, _ in + completion(granted) + } + } + + static func registerForRemoteNotifications() { + DispatchQueue.main.async { + UIApplication.shared.registerForRemoteNotifications() + } + } + + private func enableResponse(hasPermission: Bool, token: String?) -> [String: Any] { + return [ + DefaultsKey.actionKey: Actions.providerEnable, + DefaultsKey.providerKey: Providers.push, + DefaultsKey.hasPermissionKey: hasPermission, + DefaultsKey.successKey: true, + DefaultsKey.dataKey: [PushNotificationProvider.tokenKey: token] + ] + } } diff --git a/ORLib/UI/ORViewController.swift b/ORLib/UI/ORViewController.swift index 492bdc4..f1c923e 100644 --- a/ORLib/UI/ORViewController.swift +++ b/ORLib/UI/ORViewController.swift @@ -252,8 +252,9 @@ extension ORViewcontroller: WKScriptMessageHandler { }) case Actions.providerEnable: let consoleId = postMessageDict[GeofenceProvider.consoleIdKey] as? String - pushProvider?.enable(consoleId: consoleId, callback: { enableData in - self.sendData(data: enableData) + // The reply can wait seconds for the FCM token, don't keep the view controller alive for it + pushProvider?.enable(consoleId: consoleId, callback: { [weak self] enableData in + self?.sendData(data: enableData) }) case Actions.providerDisable: diff --git a/ORLib/Utils/Constants.swift b/ORLib/Utils/Constants.swift index b25e9c7..a8cdc3a 100644 --- a/ORLib/Utils/Constants.swift +++ b/ORLib/Utils/Constants.swift @@ -93,6 +93,8 @@ public enum Notifications { public static let fullScreenShown = Notification.Name("fullScreenShown") public static let fullScreenHidden = Notification.Name("fullScreenHidden") public static let idleTimerFired = Notification.Name("idleTimerFired") + /// Posted when a new FCM token is available, the token is in userInfo under `PushNotificationProvider.tokenKey` + public static let pushTokenUpdated = Notification.Name("pushTokenUpdated") } public enum ActionType { diff --git a/README.md b/README.md index ff4a3b0..3f1461a 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,41 @@ import ORLib // Your code using ORLib ``` +### Push notifications + +ORLib does not depend on Firebase, the app sets it up. On every launch path, including launches from a notification, call `FirebaseApp.configure()` and set `Messaging.messaging().delegate` before the console enables push. Firebase only issues the FCM token once it has the APNs token. With method swizzling disabled (`FirebaseAppDelegateProxyEnabled` set to `NO`), forward it yourself: + +```swift +func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { + Messaging.messaging().apnsToken = deviceToken +} +``` + +Then forward the notification and Firebase messaging callbacks from your `AppDelegate` to `ORNotificationHandler`: + +```swift +extension AppDelegate: UNUserNotificationCenterDelegate { + func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, + withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) { + completionHandler(ORNotificationHandler.shared.willPresent(notification)) + } + + func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, + withCompletionHandler completionHandler: @escaping () -> Void) { + ORNotificationHandler.shared.didReceive(response) + completionHandler() + } +} + +extension AppDelegate: MessagingDelegate { + func messaging(_ messaging: Messaging, didReceiveRegistrationToken fcmToken: String?) { + ORNotificationHandler.shared.didReceiveRegistrationToken(fcmToken) + } +} +``` + +If push is enabled before Firebase delivers the token (first launch), the push provider waits for it before replying, so the token is included in the console registration. The wait is capped at 4.5 s, just under the web console's 5 s provider timeout. A token that arrives later, or a rotated token, reaches the backend the next time the console enables push. + ## License ORLib is available under the AGPL-3.0 license. See the LICENSE.txt file for more info. diff --git a/Tests/ESPProvisionURLResolverTest.swift b/Tests/ESPProvisionURLResolverTest.swift index 8f12516..a54671b 100644 --- a/Tests/ESPProvisionURLResolverTest.swift +++ b/Tests/ESPProvisionURLResolverTest.swift @@ -24,9 +24,8 @@ import Testing struct ESPProvisionURLResolverTest { @Test func urlRealmWinsOverStoredRealm() throws { - let (suiteName, userDefaults) = try makeUserDefaults() + let userDefaults = try #require(TemporaryUserDefaults()) userDefaults.set("master", forKey: DefaultsKey.realmKey) - defer { userDefaults.removePersistentDomain(forName: suiteName) } let resolver = ESPProvisionURLResolver(userDefaults: userDefaults) @@ -36,9 +35,8 @@ struct ESPProvisionURLResolverTest { } @Test func currentURLWinsOverTargetUrlAndBaseUrl() throws { - let (suiteName, userDefaults) = try makeUserDefaults() + let userDefaults = try #require(TemporaryUserDefaults()) userDefaults.set("stored", forKey: DefaultsKey.realmKey) - defer { userDefaults.removePersistentDomain(forName: suiteName) } let resolver = ESPProvisionURLResolver(userDefaults: userDefaults) @@ -48,9 +46,8 @@ struct ESPProvisionURLResolverTest { } @Test func blankUrlRealmFallsBackToStoredRealm() throws { - let (suiteName, userDefaults) = try makeUserDefaults() + let userDefaults = try #require(TemporaryUserDefaults()) userDefaults.set("stored", forKey: DefaultsKey.realmKey) - defer { userDefaults.removePersistentDomain(forName: suiteName) } let resolver = ESPProvisionURLResolver(userDefaults: userDefaults) @@ -103,11 +100,4 @@ struct ESPProvisionURLResolverTest { #expect(apiURL.absoluteString == "http://localhost:8080/api/tenantA") } - - private func makeUserDefaults() throws -> (suiteName: String, userDefaults: UserDefaults) { - let suiteName = "ESPProvisionURLResolverTest.\(UUID().uuidString)" - let userDefaults = try #require(UserDefaults(suiteName: suiteName)) - userDefaults.removePersistentDomain(forName: suiteName) - return (suiteName, userDefaults) - } } diff --git a/Tests/ORNotificationHandlerTest.swift b/Tests/ORNotificationHandlerTest.swift new file mode 100644 index 0000000..96f1507 --- /dev/null +++ b/Tests/ORNotificationHandlerTest.swift @@ -0,0 +1,194 @@ +/* + * Copyright 2026, OpenRemote Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import Foundation +import Testing +import UIKit +import UserNotifications +@testable import ORLib + +struct ORNotificationHandlerTest { + + private final class Recorder { + var delivered: [(Int64, String)] = [] + var acknowledged: [(Int64, String, String)] = [] + var opened: [(URL, Bool)] = [] + var sent: [URLRequest] = [] + var postedTokens: [String] = [] + } + + /// Stands in for a console with a non-console screen (offline, QR scanner) presented on top of it + private final class PresentingConsole: ORViewcontroller { + let presented: UIViewController + + init(presenting presented: UIViewController) { + self.presented = presented + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { nil } + + override var presentedViewController: UIViewController? { presented } + } + + // MARK: - Token + + @Test func tokenIsStoredAndPosted() throws { + let (handler, recorder, userDefaults) = try makeHandler() + + handler.didReceiveRegistrationToken("token1") + + #expect(userDefaults.string(forKey: DefaultsKey.fcmTokenKey) == "token1") + #expect(recorder.postedTokens == ["token1"]) + } + + // MARK: - Will present + + @Test func willPresentMarksDelivered() throws { + let (handler, recorder, userDefaults) = try makeHandler() + userDefaults.set("console1", forKey: GeofenceProvider.consoleIdKey) + + handler.handleWillPresent(userInfo: [ActionType.notificationId: "42"]) + + #expect(recorder.delivered.count == 1) + #expect(recorder.delivered.first?.0 == 42) + #expect(recorder.delivered.first?.1 == "console1") + } + + // MARK: - Responses + + @Test(arguments: [false, true]) + func defaultActionOpensURL(inBrowser: Bool) throws { + let (handler, recorder, _) = try makeHandler() + var userInfo: [AnyHashable: Any] = [ActionType.appUrl: "https://example.com/page"] + if inBrowser { + userInfo[ActionType.openInBrowser] = true + } + + handler.handleResponse(userInfo: userInfo, actionIdentifier: UNNotificationDefaultActionIdentifier) + + #expect(recorder.opened.count == 1) + #expect(recorder.opened.first?.0.absoluteString == "https://example.com/page") + #expect(recorder.opened.first?.1 == inBrowser) + #expect(recorder.acknowledged.isEmpty) + } + + @Test func relativeURLWithoutProjectOpensNothing() throws { + let (handler, recorder, _) = try makeHandler() + + handler.handleResponse(userInfo: [ActionType.appUrl: "page"], actionIdentifier: UNNotificationDefaultActionIdentifier) + + #expect(recorder.opened.isEmpty) + } + + @Test(arguments: [UNNotificationDismissActionIdentifier, ORNotificationHandler.declineActionIdentifier]) + func dismissIsAcknowledged(actionIdentifier: String) throws { + let (handler, recorder, userDefaults) = try makeHandler() + userDefaults.set("console1", forKey: GeofenceProvider.consoleIdKey) + + handler.handleResponse(userInfo: [ActionType.notificationId: "42", ActionType.appUrl: "https://example.com/page"], + actionIdentifier: actionIdentifier) + + #expect(recorder.acknowledged.count == 1) + #expect(recorder.acknowledged.first?.2 == actionIdentifier) + #expect(recorder.opened.isEmpty) + } + + @Test func silentButtonSendsRequest() throws { + let (handler, recorder, userDefaults) = try makeHandler() + userDefaults.set("console1", forKey: GeofenceProvider.consoleIdKey) + let buttons = """ + [{"title": "Turn off", "action": {"url": "https://example.com/api", "httpMethod": "PUT", "data": "{}", \ + "silent": true, "openInBrowser": false}}] + """ + + handler.handleResponse(userInfo: [ActionType.notificationId: "42", DefaultsKey.buttonsKey: buttons], + actionIdentifier: "Turn off") + + #expect(recorder.acknowledged.first?.2 == "Turn off") + #expect(recorder.sent.count == 1) + let request = try #require(recorder.sent.first) + #expect(request.url?.absoluteString == "https://example.com/api") + #expect(request.httpMethod == "PUT") + #expect(request.httpBody == Data("{}".utf8)) + #expect(request.value(forHTTPHeaderField: "Content-Type") == "application/json") + #expect(recorder.opened.isEmpty) + } + + @Test func relativeButtonOpensInBrowserOnSelectedProject() throws { + let (handler, recorder, userDefaults) = try makeHandler() + let projectA = ProjectConfig(domain: "https://a.example.com", app: "manager", realm: nil) + let projectB = ProjectConfig(domain: "https://b.example.com", app: "manager", realm: nil) + try store([projectA, projectB], selected: projectB, in: userDefaults) + let buttons = """ + [{"title": "Other", "action": {"url": "other", "silent": false, "openInBrowser": false}}, \ + {"title": "Open", "action": {"url": "page", "silent": false, "openInBrowser": true}}] + """ + + handler.handleResponse(userInfo: [DefaultsKey.buttonsKey: buttons], actionIdentifier: "Open") + + #expect(recorder.opened.count == 1) + #expect(recorder.opened.first?.0.absoluteString == "https://b.example.com/console/page") + #expect(recorder.opened.first?.1 == true) + #expect(recorder.sent.isEmpty) + } + + // MARK: - Console lookup + + @MainActor + @Test func consoleUnderPresentedScreenIsFound() { + let console = PresentingConsole(presenting: UIViewController()) + + #expect(ORNotificationHandler.topORViewController(in: [console]) === console) + } + + @MainActor + @Test func firstRootWithConsoleIsUsed() { + let console = ORViewcontroller() + + #expect(ORNotificationHandler.topORViewController(in: [UIViewController(), console, ORViewcontroller()]) === console) + } + + // MARK: - Setup + + private func store(_ projects: [ProjectConfig], selected: ProjectConfig, in userDefaults: UserDefaults) throws { + userDefaults.set(try JSONEncoder().encode(projects), forKey: DefaultsKey.projectsConfigurationKey) + userDefaults.set(selected.id, forKey: DefaultsKey.projectKey) + } + + private func makeHandler() throws -> (ORNotificationHandler, Recorder, UserDefaults) { + let userDefaults = try #require(TemporaryUserDefaults()) + let recorder = Recorder() + let center = NotificationCenter() + center.addObserver(forName: Notifications.pushTokenUpdated, object: nil, queue: nil) { notification in + if let token = notification.userInfo?[PushNotificationProvider.tokenKey] as? String { + recorder.postedTokens.append(token) + } + } + + let handler = ORNotificationHandler( + defaults: { userDefaults }, + center: center, + delivered: { recorder.delivered.append(($0, $1)) }, + acknowledge: { recorder.acknowledged.append(($0, $1, $2)) }, + open: { recorder.opened.append(($0, $1)) }, + send: { recorder.sent.append($0) }) + return (handler, recorder, userDefaults) + } +} diff --git a/Tests/PushNotificationProviderTest.swift b/Tests/PushNotificationProviderTest.swift new file mode 100644 index 0000000..a5484b9 --- /dev/null +++ b/Tests/PushNotificationProviderTest.swift @@ -0,0 +1,190 @@ +/* + * Copyright 2026, OpenRemote Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import Foundation +import Testing +import UserNotifications +@testable import ORLib + +@MainActor +struct PushNotificationProviderTest { + + @Test func cachedTokenCompletesImmediately() async throws { + let (provider, _, userDefaults) = try makeProvider() + userDefaults.set("rotated", forKey: DefaultsKey.fcmTokenKey) + let start = Date() + + let token = await waitForToken(provider, timeout: 5) + + #expect(token == "rotated") + #expect(Date().timeIntervalSince(start) < 1) + } + + @Test func postedTokenCompletesBeforeDeadline() async throws { + let (provider, center, _) = try makeProvider() + let start = Date() + postToken("token1", on: center, after: 0.1) + + let token = await waitForToken(provider, timeout: 5) + + #expect(token == "token1") + #expect(Date().timeIntervalSince(start) < 1) + } + + @Test func noTokenCompletesWithNilAtDeadline() async throws { + let (provider, _, _) = try makeProvider() + + let token = await waitForToken(provider, timeout: 0.1) + + #expect(token == nil) + } + + /// Apps that still store the token in their own MessagingDelegate never post `Notifications.pushTokenUpdated` + @Test func tokenStoredWithoutNotificationIsUsedAtDeadline() async throws { + let (provider, _, userDefaults) = try makeProvider() + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { + userDefaults.set("token2", forKey: DefaultsKey.fcmTokenKey) + } + + let token = await waitForToken(provider, timeout: 0.2) + + #expect(token == "token2") + } + + @Test func completesOnlyOnce() async throws { + let (provider, center, _) = try makeProvider() + var completions: [String?] = [] + + provider.waitForToken(until: Date().addingTimeInterval(0.1)) { completions.append($0) } + try await Task.sleep(nanoseconds: 300_000_000) + center.post(name: Notifications.pushTokenUpdated, object: nil, userInfo: [PushNotificationProvider.tokenKey: "late"]) + try await Task.sleep(nanoseconds: 100_000_000) + + #expect(completions.count == 1) + #expect(completions.first == .some(nil)) + } + + @Test func authorizedRegistersAndRepliesWithToken() async throws { + let authorization = FakeAuthorization(status: .authorized) + let (provider, center, _) = try makeProvider(authorization) + postToken("token1", on: center, after: 0.1) + + let response = await enable(provider) + + #expect(authorization.registerCount == 1) + #expect(authorization.requestCount == 0) + #expect(response[DefaultsKey.hasPermissionKey] as? Bool == true) + #expect(token(in: response) == "token1") + } + + @Test func deniedRepliesImmediatelyWithCachedTokenWithoutRegistering() async throws { + let authorization = FakeAuthorization(status: .denied) + let (provider, _, userDefaults) = try makeProvider(authorization) + userDefaults.set("cached", forKey: DefaultsKey.fcmTokenKey) + + let response = await enable(provider) + + #expect(authorization.registerCount == 0) + #expect(authorization.requestCount == 0) + #expect(response[DefaultsKey.hasPermissionKey] as? Bool == false) + #expect(token(in: response) == "cached") + } + + @Test func notDeterminedRepliesWithTokenBeforeThePromptIsAnswered() async throws { + // The user never answers the prompt + let authorization = FakeAuthorization(status: .notDetermined, grant: nil) + let (provider, center, _) = try makeProvider(authorization) + postToken("token1", on: center, after: 0.1) + + let response = await enable(provider) + + #expect(authorization.registerCount == 1) + #expect(authorization.requestCount == 1) + #expect(response[DefaultsKey.hasPermissionKey] as? Bool == true) + #expect(token(in: response) == "token1") + } + + @Test func notDeterminedReportsDenialGivenBeforeTheToken() async throws { + let authorization = FakeAuthorization(status: .notDetermined, grant: false) + let (provider, center, _) = try makeProvider(authorization) + postToken("token1", on: center, after: 0.1) + + let response = await enable(provider) + + #expect(authorization.registerCount == 1) + #expect(response[DefaultsKey.hasPermissionKey] as? Bool == false) + #expect(token(in: response) == "token1") + } + + private func enable(_ provider: PushNotificationProvider) async -> [String: Any] { + await withCheckedContinuation { continuation in + provider.enable(consoleId: nil) { continuation.resume(returning: $0) } + } + } + + private func token(in response: [String: Any]) -> String? { + (response[DefaultsKey.dataKey] as? [String: String?])?[PushNotificationProvider.tokenKey] ?? nil + } + + private func postToken(_ token: String, on center: NotificationCenter, after delay: TimeInterval) { + DispatchQueue.main.asyncAfter(deadline: .now() + delay) { + center.post(name: Notifications.pushTokenUpdated, object: nil, userInfo: [PushNotificationProvider.tokenKey: token]) + } + } + + private func waitForToken(_ provider: PushNotificationProvider, timeout: TimeInterval) async -> String? { + await withCheckedContinuation { continuation in + provider.waitForToken(until: Date().addingTimeInterval(timeout)) { continuation.resume(returning: $0) } + } + } + + private func makeProvider( + _ authorization: FakeAuthorization = FakeAuthorization(status: .notDetermined) + ) throws -> (PushNotificationProvider, NotificationCenter, UserDefaults) { + let userDefaults = try #require(TemporaryUserDefaults()) + let center = NotificationCenter() + let provider = PushNotificationProvider( + center: center, + userdefaults: userDefaults, + authorizationStatus: { $0(authorization.status) }, + requestAuthorization: { completion in + authorization.requestCount += 1 + if let grant = authorization.grant { + completion(grant) + } + }, + register: { authorization.registerCount += 1 } + ) + return (provider, center, userDefaults) + } +} + +/// Stands in for the system notification permission and remote notification registration +private final class FakeAuthorization { + let status: UNAuthorizationStatus + /// The user's answer to the permission prompt, nil when they never answer + let grant: Bool? + var requestCount = 0 + var registerCount = 0 + + init(status: UNAuthorizationStatus, grant: Bool? = nil) { + self.status = status + self.grant = grant + } +} diff --git a/Tests/TemporaryUserDefaults.swift b/Tests/TemporaryUserDefaults.swift new file mode 100644 index 0000000..380cbca --- /dev/null +++ b/Tests/TemporaryUserDefaults.swift @@ -0,0 +1,36 @@ +/* + * Copyright 2026, OpenRemote Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import Foundation + +/// A UserDefaults suite private to one test, removed from disk once nothing references it anymore +final class TemporaryUserDefaults: UserDefaults { + private let suiteName: String + + // Labelled because a failable init() can't override UserDefaults.init() + init?(uniqueId: String = UUID().uuidString) { + let suiteName = "ORLibTests.\(uniqueId)" + self.suiteName = suiteName + super.init(suiteName: suiteName) + } + + deinit { + removePersistentDomain(forName: suiteName) + } +}