diff --git a/Sources/Spezi/Capabilities/ApplicationPropertyWrapper.swift b/Sources/Spezi/Capabilities/ApplicationPropertyWrapper.swift index d8408252..3f173bf0 100644 --- a/Sources/Spezi/Capabilities/ApplicationPropertyWrapper.swift +++ b/Sources/Spezi/Capabilities/ApplicationPropertyWrapper.swift @@ -7,20 +7,30 @@ // +/// Refer to the documentation of ``Module/Application``. @propertyWrapper public class _ApplicationPropertyWrapper { // swiftlint:disable:this type_name private let keyPath: KeyPath private weak var spezi: Spezi? + /// Some KeyPaths are declared to copy the value upon injection and not query them every time. + private var shadowCopy: Value? + /// Access the application property. public var wrappedValue: Value { + if let shadowCopy { + return shadowCopy + } + guard let spezi else { preconditionFailure("Underlying Spezi instance was not yet injected. @Application cannot be accessed within the initializer!") } return spezi[keyPath: keyPath] } + /// Initialize a new `@Application` property wrapper + /// - Parameter keyPath: The property to access. public init(_ keyPath: KeyPath) { self.keyPath = keyPath } @@ -30,10 +40,33 @@ public class _ApplicationPropertyWrapper { // swiftlint:disable:this type extension _ApplicationPropertyWrapper: SpeziPropertyWrapper { func inject(spezi: Spezi) { self.spezi = spezi + if spezi.createsCopy(keyPath) { + self.shadowCopy = spezi[keyPath: keyPath] + } } } extension Module { + /// Access a property or action of the application. + /// + /// The `@Application` property wrapper can be used inside your `Module` to + /// access a property or action of your application. + /// + /// - Note: You can access the contents of `@Application` once your ``Module/configure()-5pa83`` method is called + /// (e.g., it must not be used in the `init`). + /// + /// Below is a short code example: + /// + /// ```swift + /// class ExampleModule: Module { + /// @Application(\.logger) + /// var logger + /// + /// func configure() { + /// logger.info("Module is being configured ...") + /// } + /// } + /// ``` public typealias Application = _ApplicationPropertyWrapper } diff --git a/Sources/Spezi/Capabilities/Lifecycle/LifecycleHandler.swift b/Sources/Spezi/Capabilities/Lifecycle/LifecycleHandler.swift index 49f9a4d2..f72cd1c7 100644 --- a/Sources/Spezi/Capabilities/Lifecycle/LifecycleHandler.swift +++ b/Sources/Spezi/Capabilities/Lifecycle/LifecycleHandler.swift @@ -59,7 +59,7 @@ public protocol LifecycleHandler { deprecated, message: """ Using UISceneDelegate is deprecated. \ - Use the SwiftUI onReceive(_:perform:) modifier with the UIScene.willEnterForegroundNotification publisher on iOS \ + Use the SwiftUI ScenePhase environment property or the UIScene.willEnterForegroundNotification publisher on iOS \ or other platform-specific mechanisms as a replacement. """ ) @@ -73,10 +73,10 @@ public protocol LifecycleHandler { *, deprecated, message: """ - Using UISceneDelegate is deprecated. \ - Use the SwiftUI onReceive(_:perform:) modifier with the UIScene.didActivateNotification publisher on iOS \ - or other platform-specific mechanisms as a replacement. - """ + Using UISceneDelegate is deprecated. \ + Use the SwiftUI ScenePhase environment property or the UIScene.didActivateNotification publisher on iOS \ + or other platform-specific mechanisms as a replacement. + """ ) func sceneDidBecomeActive(_ scene: UIScene) @@ -88,10 +88,10 @@ public protocol LifecycleHandler { *, deprecated, message: """ - Using UISceneDelegate is deprecated. \ - Use the SwiftUI onReceive(_:perform:) modifier with the UIScene.willDeactivateNotification publisher on iOS \ - or other platform-specific mechanisms as a replacement. - """ + Using UISceneDelegate is deprecated. \ + Use the SwiftUI ScenePhase environment property or the UIScene.willDeactivateNotification publisher on iOS \ + or other platform-specific mechanisms as a replacement. + """ ) func sceneWillResignActive(_ scene: UIScene) @@ -103,10 +103,10 @@ public protocol LifecycleHandler { *, deprecated, message: """ - Using UISceneDelegate is deprecated. \ - Use the SwiftUI onReceive(_:perform:) modifier with the UIScene.didEnterBackgroundNotification publisher on iOS \ - or other platform-specific mechanisms as a replacement. - """ + Using UISceneDelegate is deprecated. \ + Use the SwiftUI ScenePhase environment property or the UIScene.didEnterBackgroundNotification publisher on iOS \ + or other platform-specific mechanisms as a replacement. + """ ) func sceneDidEnterBackground(_ scene: UIScene) @@ -169,9 +169,9 @@ extension LifecycleHandler { *, deprecated, message: """ - Please use the new @Application property wrapper to access delegate functionality. \ - Otherwise use the SwiftUI onReceive(_:perform:) for UI related notifications. - """ + Please use the new @Application property wrapper to access delegate functionality. \ + Otherwise use the SwiftUI onReceive(_:perform:) for UI related notifications. + """ ) extension Array: LifecycleHandler where Element == LifecycleHandler { #if os(iOS) || os(visionOS) || os(tvOS) @@ -193,10 +193,10 @@ extension Array: LifecycleHandler where Element == LifecycleHandler { *, deprecated, message: """ - Using UISceneDelegate is deprecated. \ - Use the SwiftUI onReceive(_:perform:) modifier with the UIScene.willEnterForegroundNotification publisher on iOS \ - or other platform-specific mechanisms as a replacement. - """ + Using UISceneDelegate is deprecated. \ + Use the SwiftUI ScenePhase environment property or the UIScene.willEnterForegroundNotification publisher on iOS \ + or other platform-specific mechanisms as a replacement. + """ ) public func sceneWillEnterForeground(_ scene: UIScene) { for lifecycleHandler in self { @@ -208,10 +208,10 @@ extension Array: LifecycleHandler where Element == LifecycleHandler { *, deprecated, message: """ - Using UISceneDelegate is deprecated. \ - Use the SwiftUI onReceive(_:perform:) modifier with the UIScene.didActivateNotification publisher on iOS \ - or other platform-specific mechanisms as a replacement. - """ + Using UISceneDelegate is deprecated. \ + Use the SwiftUI ScenePhase environment property or the UIScene.didActivateNotification publisher on iOS \ + or other platform-specific mechanisms as a replacement. + """ ) public func sceneDidBecomeActive(_ scene: UIScene) { for lifecycleHandler in self { @@ -223,10 +223,10 @@ extension Array: LifecycleHandler where Element == LifecycleHandler { *, deprecated, message: """ - Using UISceneDelegate is deprecated. \ - Use the SwiftUI onReceive(_:perform:) modifier with the UIScene.willDeactivateNotification publisher on iOS \ - or other platform-specific mechanisms as a replacement. - """ + Using UISceneDelegate is deprecated. \ + Use the SwiftUI ScenePhase environment property or the UIScene.willDeactivateNotification publisher on iOS \ + or other platform-specific mechanisms as a replacement. + """ ) public func sceneWillResignActive(_ scene: UIScene) { for lifecycleHandler in self { @@ -238,10 +238,10 @@ extension Array: LifecycleHandler where Element == LifecycleHandler { *, deprecated, message: """ - Using UISceneDelegate is deprecated. \ - Use the SwiftUI onReceive(_:perform:) modifier with the UIScene.didEnterBackgroundNotification publisher on iOS \ - or other platform-specific mechanisms as a replacement. - """ + Using UISceneDelegate is deprecated. \ + Use the SwiftUI ScenePhase environment property or the UIScene.didEnterBackgroundNotification publisher on iOS \ + or other platform-specific mechanisms as a replacement. + """ ) public func sceneDidEnterBackground(_ scene: UIScene) { for lifecycleHandler in self { diff --git a/Sources/Spezi/Capabilities/Notifications/NotificationHandler.swift b/Sources/Spezi/Capabilities/Notifications/NotificationHandler.swift new file mode 100644 index 00000000..8d9abc9a --- /dev/null +++ b/Sources/Spezi/Capabilities/Notifications/NotificationHandler.swift @@ -0,0 +1,81 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import UserNotifications + + +/// Get notified about receiving notifications. +public protocol NotificationHandler { + /// Handle user-selected notification action. + /// + /// This method is called with your app in the background to handle the selected user action. + /// + /// For more information refer to [Handle user-selected actions](https://developer.apple.com/documentation/usernotifications/handling-notifications-and-notification-related-actions#Handle-user-selected-actions) + /// and [`userNotificationCenter(_:didReceive:withCompletionHandler:)`](https://developer.apple.com/documentation/usernotifications/unusernotificationcenterdelegate/usernotificationcenter(_:didreceive:withcompletionhandler:)). + /// + /// - Parameter response: The user's response to the notification. + func handleNotificationAction(_ response: UNNotificationResponse) async + + /// Handle incoming notification when the app is running in foreground. + /// + /// This method is called when there is a incoming notification while the app was running in foreground. + /// + /// For more information refer to + /// [Handle notifications while your app runs in the foreground](https://developer.apple.com/documentation/usernotifications/handling-notifications-and-notification-related-actions#Handle-notifications-while-your-app-runs-in-the-foreground) + /// and [`userNotificationCenter(_:willPresent:withCompletionHandler:)`](https://developer.apple.com/documentation/usernotifications/unusernotificationcenterdelegate/usernotificationcenter(_:willpresent:withcompletionhandler:)). + /// + /// - Parameter notification: The notification that is about to be delivered. + /// - Returns: The option for notifying the user. Use `[]` to silence the notification. + func receiveIncomingNotification(_ notification: UNNotification) async -> UNNotificationPresentationOptions + +#if !os(macOS) + /// Handle remote notification when the app is running in background. + /// + /// This method is called when there is a remote notification arriving while the app is running in background. + /// You can use this method to download additional content. + /// + /// For more information refer to + /// [`application(_:didReceiveRemoteNotification:fetchCompletionHandler:)`](https://developer.apple.com/documentation/uikit/uiapplicationdelegate/1623013-application) + /// or [`didReceiveRemoteNotification(_:fetchCompletionHandler:)`](https://developer.apple.com/documentation/watchkit/wkextensiondelegate/3152235-didreceiveremotenotification). + /// + /// - Parameter remoteNotification: The data of the notification payload. + /// - Returns: Return the respective ``BackgroundFetchResult``. + func receiveRemoteNotification(_ remoteNotification: [AnyHashable: Any]) async -> BackgroundFetchResult +#else + /// Handle remote notification when the app is running in background. + /// + /// This method is called when there is a remote notification arriving while the app is running in background. + /// You can use this method to download additional content. + /// + /// For more information refer to + /// [`application(_:didReceiveRemoteNotification:)`](https://developer.apple.com/documentation/appkit/nsapplicationdelegate/1428430-application). + /// + /// - Parameter remoteNotification: The data of the notification payload. + func receiveRemoteNotification(_ remoteNotification: [AnyHashable: Any]) +#endif +} + + +extension NotificationHandler { + /// Empty default implementation. + func handleNotificationAction(_ response: UNNotificationResponse) async {} + + /// Empty default implementation. + func receiveIncomingNotification(_ notification: UNNotification) async -> UNNotificationPresentationOptions { + // TODO: is there are better default? + [.badge, .badge, .list, .sound] // default is to fully present the notification + } + +#if !os(macOS) + func receiveRemoteNotification(_ remoteNotification: [AnyHashable: Any]) async -> BackgroundFetchResult { + .noData + } +#else + func receiveRemoteNotification(_ remoteNotification: [AnyHashable: Any]) {} +#endif +} diff --git a/Sources/Spezi/Capabilities/Notifications/NotificationTokenHandler.swift b/Sources/Spezi/Capabilities/Notifications/NotificationTokenHandler.swift new file mode 100644 index 00000000..151aaf25 --- /dev/null +++ b/Sources/Spezi/Capabilities/Notifications/NotificationTokenHandler.swift @@ -0,0 +1,28 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import Foundation + + +/// Get notified about device token updates for APNs. +/// +/// Use this protocol when your Module needs to be notified about an updated device tokens +/// for the Apple Push Notifications service. +public protocol NotificationTokenHandler { + /// Receive an updated device token for APNs. + /// + /// User this method to be notified about a changed device token for interaction + /// with the Apple Push Notifications service. + /// Use this method to send the updated token to your server-side infrastructure. + /// + /// - Note: Fore more information refer to the documentation of + /// [`application(_:didRegisterForRemoteNotificationsWithDeviceToken:)`](https://developer.apple.com/documentation/uikit/uiapplicationdelegate/1622958-application). + /// - Parameter deviceToken: The globally unique token that identifies this device to APNs. + @MainActor + func receiveUpdatedDeviceToken(_ deviceToken: Data) +} diff --git a/Sources/Spezi/Capabilities/Notifications/RegisterRemoteNotificationsAction.swift b/Sources/Spezi/Capabilities/Notifications/RegisterRemoteNotificationsAction.swift new file mode 100644 index 00000000..ed4ea5ad --- /dev/null +++ b/Sources/Spezi/Capabilities/Notifications/RegisterRemoteNotificationsAction.swift @@ -0,0 +1,147 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import SpeziFoundation +import SwiftUI + + +private class RemoteNotificationContinuation: DefaultProvidingKnowledgeSource { + typealias Anchor = SpeziAnchor + + static let defaultValue = RemoteNotificationContinuation() + + @MainActor + var continuation: CheckedContinuation? + + init() {} +} + + +/// Registers to receive remote notifications through Apple Push Notification service. +/// +/// For more information refer to the [`registerForRemoteNotifications()`](https://developer.apple.com/documentation/uikit/uiapplication/1623078-registerforremotenotifications) +/// documentation for `UIApplication` or for the respective equivalent for your current platform. +/// +/// - Note: For more information on the general topic on how to register your app with APNs, +/// refer to the [Registering your app with APNs](https://developer.apple.com/documentation/usernotifications/registering-your-app-with-apns) +/// article. +/// +/// Below is a short code example on how to use this action within your ``Module``. +/// +/// ```swift +/// class ExampleModule: Module { +/// @Application(\.registerRemoteNotifications) +/// var registerRemoteNotifications +/// +/// func handleNotificationsAllowed() async throws { +/// let deviceToken = try await registerRemoteNotifications() +/// // .. send the device token to your remote server that generates push notifications +/// } +/// } +/// ``` +public struct RegisterRemoteNotificationsAction { + /// Errors occurring when registering for remote notifications. + public enum ActionError: Error { + /// The action was called while we were still waiting to receive a response from the previous one. + case concurrentAccess + } + + private let application: _Application + private weak var spezi: Spezi? + + init(_ spezi: Spezi) { + #if os(watchOS) + self.application = _Application.shared() + #else + self.application = _Application.shared + #endif + self.spezi = spezi + } + + /// Registers to receive remote notifications through Apple Push Notification service. + /// + /// - Returns: A globally unique token that identifies this device to APNs. + /// Send this token to the server that you use to generate remote notifications. + /// Your server must pass this token unmodified back to APNs when sending those remote notifications. + /// For more information refer to the documentation of + /// [`application(_:didRegisterForRemoteNotificationsWithDeviceToken:)`](https://developer.apple.com/documentation/uikit/uiapplicationdelegate/1622958-application). + /// - Throws: Registration might fail if the user's device isn't connected to the network or + /// if your app is not properly configured for remote notifications. It might also throw in the + /// rare circumstance where you make a call to this method while another one is still ongoing. + /// Try again to register at a later point in time. + @MainActor + @discardableResult + public func callAsFunction() async throws -> Data { + guard let spezi else { + preconditionFailure("RegisterRemoteNotificationsAction was used in a scope where Spezi was not available anymore!") + } + + let registration = spezi.storage[RemoteNotificationContinuation.self] + if registration.continuation != nil { + throw ActionError.concurrentAccess + } + + return try await withCheckedThrowingContinuation { continuation in + registration.continuation = continuation + application.registerForRemoteNotifications() + } + } +} + + +extension Spezi { + /// Registers to receive remote notifications through Apple Push Notification service. + /// + /// For more information refer to the [`registerForRemoteNotifications()`](https://developer.apple.com/documentation/uikit/uiapplication/1623078-registerforremotenotifications) + /// documentation for `UIApplication` or for the respective equivalent for your current platform. + /// + /// Below is a short code example on how to use this action within your ``Module``. + /// + /// ```swift + /// class ExampleModule: Module { + /// @Application(\.registerRemoteNotifications) + /// var registerRemoteNotifications + /// + /// func handleNotificationsAllowed() async throws { + /// let deviceToken = try await registerRemoteNotifications() + /// // .. send the device token to your remote server that generates push notifications + /// } + /// } + /// ``` + /// + /// ## Topics + /// ### Action + /// - ``RegisterRemoteNotificationsAction`` + public var registerRemoteNotifications: RegisterRemoteNotificationsAction { + RegisterRemoteNotificationsAction(self) + } +} + + +extension RegisterRemoteNotificationsAction { + @MainActor + static func handleDeviceTokenUpdate(_ spezi: Spezi, _ deviceToken: Data) { + let registration = spezi.storage[RemoteNotificationContinuation.self] + guard let continuation = registration.continuation else { + // might also be called if, e.g., app is restored from backup and is automatically registered for remote notifications. + // This can be handled through the `NotificationHandler` protocol. + return + } + continuation.resume(returning: deviceToken) + } + + @MainActor + static func handleFailedRegistration(_ spezi: Spezi, _ error: Error) { + let registration = spezi.storage[RemoteNotificationContinuation.self] + guard let continuation = registration.continuation else { + spezi.logger.warning("Received a call to \(#function) while we were not waiting for a notifications registration request.") + return + } + continuation.resume(throwing: error) + } +} diff --git a/Sources/Spezi/Capabilities/Notifications/UnregisterRemoteNotificationsAction.swift b/Sources/Spezi/Capabilities/Notifications/UnregisterRemoteNotificationsAction.swift new file mode 100644 index 00000000..5728c9ae --- /dev/null +++ b/Sources/Spezi/Capabilities/Notifications/UnregisterRemoteNotificationsAction.swift @@ -0,0 +1,76 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import SwiftUI + + +/// Unregisters for all remote notifications received through Apple Push Notification service. +/// +/// For more information refer to the [`unregisterForRemoteNotifications()`](https://developer.apple.com/documentation/uikit/uiapplication/1623093-unregisterforremotenotifications) +/// documentation for `UIApplication` or for the respective equivalent for your current platform. +/// +/// Below is a short code example on how to use this action within your ``Module``. +/// +/// ```swift +/// class ExampleModule: Module { +/// @Application(\.unregisterRemoteNotifications) +/// var unregisterRemoteNotifications +/// +/// func onAccountLogout() { +/// // handling your cleanup ... +/// unregisterRemoteNotifications() +/// } +/// } +/// ``` +public struct UnregisterRemoteNotificationsAction { + private let application: _Application + + + init() { + #if os(watchOS) + self.application = _Application.shared() + #else + self.application = _Application.shared + #endif + } + + + /// Unregisters for all remote notifications received through Apple Push Notification service. + public func callAsFunction() { + application.unregisterForRemoteNotifications() + } +} + + +extension Spezi { + /// Unregisters for all remote notifications received through Apple Push Notification service. + /// + /// For more information refer to the [`unregisterForRemoteNotifications()`](https://developer.apple.com/documentation/uikit/uiapplication/1623093-unregisterforremotenotifications) + /// documentation for `UIApplication` or for the respective equivalent for your current platform. + /// + /// Below is a short code example on how to use this action within your ``Module``. + /// + /// ```swift + /// class ExampleModule: Module { + /// @Application(\.unregisterRemoteNotifications) + /// var unregisterRemoteNotifications + /// + /// func onAccountLogout() { + /// // handling your cleanup ... + /// unregisterRemoteNotifications() + /// } + /// } + /// ``` + /// + /// ## Topics + /// ### Action + /// - ``UnregisterRemoteNotificationsAction`` + public var unregisterRemoteNotifications: UnregisterRemoteNotificationsAction { + UnregisterRemoteNotificationsAction() + } +} diff --git a/Sources/Spezi/Capabilities/Observable/ModelPropertyWrapper.swift b/Sources/Spezi/Capabilities/Observable/ModelPropertyWrapper.swift index 4da54ccd..1a5bb4a7 100644 --- a/Sources/Spezi/Capabilities/Observable/ModelPropertyWrapper.swift +++ b/Sources/Spezi/Capabilities/Observable/ModelPropertyWrapper.swift @@ -20,6 +20,7 @@ public class _ModelPropertyWrapper { /// Access the store model. + /// /// - Note: You cannot access the value once it was collected. public var wrappedValue: Model { get { diff --git a/Sources/Spezi/Dependencies/Module+Dependencies.swift b/Sources/Spezi/Dependencies/Module+Dependencies.swift index bfebd3f7..f4e78136 100644 --- a/Sources/Spezi/Dependencies/Module+Dependencies.swift +++ b/Sources/Spezi/Dependencies/Module+Dependencies.swift @@ -12,8 +12,7 @@ extension Module { /// /// You can use this property wrapper inside your `Module` to define dependencies to other ``Module``s. /// - /// - Note: You can access the contents of `@Dependency` once your ``Module/configure()-5pa83`` method is called (e.g., it must not be used in the `init`) - /// and can continue to access the dependency in methods like ``LifecycleHandler/willFinishLaunchingWithOptions(_:launchOptions:)-8jatp``. + /// - Note: You can access the contents of `@Dependency` once your ``Module/configure()-5pa83`` method is called (e.g., it must not be used in the `init`). /// /// The below code sample demonstrates a simple, singular dependence on the `ExampleModuleDependency` module. /// diff --git a/Sources/Spezi/Spezi.docc/Module/Interactions with Application.md b/Sources/Spezi/Spezi.docc/Module/Interactions with Application.md new file mode 100644 index 00000000..9a14cf50 --- /dev/null +++ b/Sources/Spezi/Spezi.docc/Module/Interactions with Application.md @@ -0,0 +1,40 @@ +# Interactions with Application + +Interact with the Application. + + + +## Overview + +Text + +### Section header + +## Topics + +### Application Interaction + +- ``Module/Application`` + +### Properties + +- ``Spezi/logger`` +- ``Spezi/launchOptions`` + +### Actions + +- ``Spezi/registerRemoteNotifications`` +- ``Spezi/unregisterRemoteNotifications`` + +### Platform-agnostic type-aliases + +- ``ApplicationDelegateAdaptor`` +- ``BackgroundFetchResult`` diff --git a/Sources/Spezi/Spezi.docc/Module/Module.md b/Sources/Spezi/Spezi.docc/Module/Module.md index 8e2dcdb6..950dcd0c 100644 --- a/Sources/Spezi/Spezi.docc/Module/Module.md +++ b/Sources/Spezi/Spezi.docc/Module/Module.md @@ -68,5 +68,7 @@ class ExampleModule: Module { ### Capabilities - +- - - +- diff --git a/Sources/Spezi/Spezi.docc/Module/Notifications.md b/Sources/Spezi/Spezi.docc/Module/Notifications.md new file mode 100644 index 00000000..0ddc3be1 --- /dev/null +++ b/Sources/Spezi/Spezi.docc/Module/Notifications.md @@ -0,0 +1,29 @@ +# User Notifications + +Manage and respond to User Notifications within your App and Modules. + + + +## Overview + +Text + +## Topics + +### Notifications + +- ``NotificationTokenHandler`` +- ``NotificationHandler`` + +### Remote Notification Registration + +- ``Spezi/registerRemoteNotifications`` +- ``Spezi/unregisterRemoteNotifications`` diff --git a/Sources/Spezi/Spezi/KnowledgeSources/LaunchOptionsKey.swift b/Sources/Spezi/Spezi/KnowledgeSources/LaunchOptionsKey.swift index 0d4eaafa..c31b2687 100644 --- a/Sources/Spezi/Spezi/KnowledgeSources/LaunchOptionsKey.swift +++ b/Sources/Spezi/Spezi/KnowledgeSources/LaunchOptionsKey.swift @@ -26,14 +26,40 @@ struct LaunchOptionsKey: DefaultProvidingKnowledgeSource { extension Spezi { #if os(iOS) || os(visionOS) || os(tvOS) + /// The launch options of the application. + /// + /// You can access the launch options within your `configure()` method of your ``Module`` or ``Standard``. + /// + /// - Note: For more information refer to the documentation of + /// [`application(_:willFinishLaunchingWithOptions:)`](https://developer.apple.com/documentation/uikit/uiapplicationdelegate/1623032-application). + /// + /// Below is a short code example on how to access the launch options within your `Module`. + /// + /// ```swift + /// class ExampleModule: Module { + /// @Application(\.launchOptions) + /// var launchOptions + /// + /// func configure() { + /// // interact with your launchOptions upon application launch + /// } + /// } + /// ``` public var launchOptions: [UIApplication.LaunchOptionsKey: Any] { storage[LaunchOptionsKey.self] } #elseif os(macOS) + /// The launch options of the application. + /// + /// You can access the launch options within your `configure()` method of your ``Module`` or ``Standard``. + /// + /// - Note: For more information refer to the documentation of + /// [`applicationWillFinishLaunching(_:)`](https://developer.apple.com/documentation/appkit/nsapplicationdelegate/1428623-applicationwillfinishlaunching). public var launchOptions: [AnyHashable: Any] { storage[LaunchOptionsKey.self] } #else // os(watchOS) + /// The launch options of the application on platforms that don't support launch options. public var launchOptions: [Never: Any] { storage[LaunchOptionsKey.self] } diff --git a/Sources/Spezi/Spezi/KnowledgeSources/SpeziLogger.swift b/Sources/Spezi/Spezi/KnowledgeSources/SpeziLogger.swift deleted file mode 100644 index 334fe1a2..00000000 --- a/Sources/Spezi/Spezi/KnowledgeSources/SpeziLogger.swift +++ /dev/null @@ -1,23 +0,0 @@ -// -// This source file is part of the Stanford Spezi open-source project -// -// SPDX-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md) -// -// SPDX-License-Identifier: MIT -// - -import os -import SpeziFoundation - - -struct SpeziLogger: ComputedKnowledgeSource { - typealias Anchor = SpeziAnchor - typealias StoragePolicy = Store - - typealias Value = Logger - - - static func compute>(from repository: Repository) -> Logger { - Logger(subsystem: "edu.stanford.spezi", category: "spezi") - } -} diff --git a/Sources/Spezi/Spezi/Spezi+Logger.swift b/Sources/Spezi/Spezi/Spezi+Logger.swift new file mode 100644 index 00000000..89e0a942 --- /dev/null +++ b/Sources/Spezi/Spezi/Spezi+Logger.swift @@ -0,0 +1,43 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import os +import SpeziFoundation + + +extension Module { + fileprivate var loggerCategory: String { + "\(Self.self)" + } +} + + +extension Spezi { + /// The global Spezi logger. + /// + /// Access the global Spezi Logger. If used with ``Module/Application`` property wrapper you can create and access your module-specific `Logger`. + /// + /// Below is a short code example on how to create and access your module-specific `Logger`. + /// + /// ```swift + /// class ExampleModule: Module { + /// @Application(\.logger) + /// var logger + /// + /// func configure() { + /// logger.info("\(Self.self) is getting configured...") + /// } + /// } + /// ``` + public var logger: Logger { + if let module = Spezi.moduleInitContext { + return Logger(subsystem: "edu.stanford.spezi.modules", category: module.loggerCategory) + } + return Spezi.logger + } +} diff --git a/Sources/Spezi/Spezi/Spezi.swift b/Sources/Spezi/Spezi/Spezi.swift index 96cd75fd..cd17ee1a 100644 --- a/Sources/Spezi/Spezi/Spezi.swift +++ b/Sources/Spezi/Spezi/Spezi.swift @@ -57,13 +57,25 @@ public typealias SpeziStorage = HeapRepository /// /// The ``Module`` documentation provides more information about the structure of modules. /// Refer to the ``Configuration`` documentation to learn more about the Spezi configuration. +/// +/// ## Topics +/// +/// ### Properties +/// - ``logger`` +/// - ``launchOptions`` +/// +/// ### Actions +/// - ``registerRemoteNotifications`` +/// - ``unregisterRemoteNotifications`` public class Spezi { + static let logger = Logger(subsystem: "edu.stanford.spezi", category: "Spezi") + + @TaskLocal static var moduleInitContext: (any Module)? + /// A shared repository to store any ``KnowledgeSource``s restricted to the ``SpeziAnchor``. /// /// Every `Module` automatically conforms to `KnowledgeSource` and is stored within this storage object. fileprivate(set) var storage: SpeziStorage - /// Logger used to log events in the ``Spezi/Spezi`` instance. - public let logger: Logger /// Array of all SwiftUI `ViewModifiers` collected using ``_ModifierPropertyWrapper`` from the configured ``Module``s. var viewModifiers: [any ViewModifier] @@ -77,10 +89,20 @@ public class Spezi { Otherwise use the SwiftUI onReceive(_:perform:) for UI related notifications. """ ) - nonisolated var lifecycleHandler: [LifecycleHandler] { + + + var lifecycleHandler: [LifecycleHandler] { storage.collect(allOf: LifecycleHandler.self) } + var notificationTokenHandler: [NotificationTokenHandler] { + storage.collect(allOf: NotificationTokenHandler.self) + } + + var notificationHandler: [NotificationHandler] { + storage.collect(allOf: NotificationHandler.self) + } + convenience init(from configuration: Configuration, storage: consuming SpeziStorage = SpeziStorage()) { self.init(standard: configuration.standard, modules: configuration.modules.elements, storage: storage) @@ -95,8 +117,6 @@ public class Spezi { var storage = consume storage var collectedModifiers: [any ViewModifier] = [] - self.logger = storage[SpeziLogger.self] - let dependencyManager = DependencyManager(modules + [standard]) dependencyManager.resolve() @@ -109,25 +129,32 @@ public class Spezi { self.viewModifiers = [] // init all properties, we will store the final result later on for module in dependencyManager.sortedModules { - module.inject(standard: standard) - module.inject(spezi: self) + Self.$moduleInitContext.withValue(module) { + module.inject(standard: standard) + module.inject(spezi: self) - // supply modules values to all @Collect - module.injectModuleValues(from: self.storage) + // supply modules values to all @Collect + module.injectModuleValues(from: self.storage) - module.configure() - module.storeModule(into: self) + module.configure() + module.storeModule(into: self) - collectedModifiers.append(contentsOf: module.viewModifiers) + collectedModifiers.append(contentsOf: module.viewModifiers) - // If a module is @Observable, we automatically inject it view the `ModelModifier` into the environment. - if let observable = module as? EnvironmentAccessible { - collectedModifiers.append(observable.viewModifier) + // If a module is @Observable, we automatically inject it view the `ModelModifier` into the environment. + if let observable = module as? EnvironmentAccessible { + collectedModifiers.append(observable.viewModifier) + } } } self.viewModifiers = collectedModifiers } + + + func createsCopy(_ keyPath: KeyPath) -> Bool { + keyPath == \.logger // loggers are created per Module. + } } diff --git a/Sources/Spezi/Spezi/SpeziAppDelegate.swift b/Sources/Spezi/Spezi/SpeziAppDelegate.swift index 39f2d8c6..80fec210 100644 --- a/Sources/Spezi/Spezi/SpeziAppDelegate.swift +++ b/Sources/Spezi/Spezi/SpeziAppDelegate.swift @@ -50,6 +50,7 @@ import SwiftUI /// Refer to the ``Configuration`` documentation to learn more about the Spezi configuration. open class SpeziAppDelegate: NSObject, ApplicationDelegate { private(set) static weak var appDelegate: SpeziAppDelegate? + static var notificationDelegate: SpeziNotificationCenterDelegate? // swiftlint:disable:this weak_delegate private var _spezi: Spezi? @@ -85,6 +86,8 @@ open class SpeziAppDelegate: NSObject, ApplicationDelegate { Configuration { } } + // MARK: - Will Finish Launching + #if os(iOS) || os(visionOS) || os(tvOS) @available(*, deprecated, message: "Propagate deprecation warning.") open func application( @@ -93,33 +96,128 @@ open class SpeziAppDelegate: NSObject, ApplicationDelegate { // swiftlint:disable:next discouraged_optional_collection willFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil ) -> Bool { - if !ProcessInfo.processInfo.isPreviewSimulator { + guard !ProcessInfo.processInfo.isPreviewSimulator else { // If you are running an Xcode Preview and you have your global SwiftUI `App` defined with // the `@UIApplicationDelegateAdaptor` property wrapper, it will still instantiate the App Delegate // and call this willFinishLaunchingWithOptions delegate method. This results in an instantiation of Spezi // and configuration of the respective modules. This might and will cause troubles with Modules that // are only meant to be instantiated once. Therefore, we skip execution of this if running inside the PreviewSimulator. // This is also not a problem, as there is no way to set up an application delegate within a Xcode preview. + return true + } - precondition(_spezi == nil, "\(#function) was called when Spezi was already initialized. Unable to pass options!") + precondition(_spezi == nil, "\(#function) was called when Spezi was already initialized. Unable to pass options!") - var storage = SpeziStorage() - storage[LaunchOptionsKey.self] = launchOptions - self._spezi = Spezi(from: configuration, storage: storage) + var storage = SpeziStorage() + storage[LaunchOptionsKey.self] = launchOptions + self._spezi = Spezi(from: configuration, storage: storage) - // backwards compatibility - spezi.lifecycleHandler.willFinishLaunchingWithOptions(application, launchOptions: launchOptions ?? [:]) - } + // backwards compatibility + spezi.lifecycleHandler.willFinishLaunchingWithOptions(application, launchOptions: launchOptions ?? [:]) + + setupNotificationDelegate() return true } - +#elseif os(macOS) + open func applicationWillFinishLaunching(_ notification: Notification) { + guard !ProcessInfo.processInfo.isPreviewSimulator else { + return // see note above for why we don't launch this within the preview simulator! + } + + precondition(_spezi == nil, "\(#function) was called when Spezi was already initialized. Unable to pass options!") + + var storage = SpeziStorage() + storage[LaunchOptionsKey.self] = notification.userInfo + self._spezi = Spezi(from: configuration, storage: storage) + + setupNotificationDelegate() + } +#elseif os(watchOS) + public func applicationDidFinishLaunching() { + guard !ProcessInfo.processInfo.isPreviewSimulator else { + return // see note above for why we don't launch this within the preview simulator! + } + + precondition(_spezi == nil, "\(#function) was called when Spezi was already initialized. Unable to pass options!") + setupNotificationDelegate() + } +#endif + + // MARK: - Notifications + + public func application(_ application: _Application, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { + MainActor.assumeIsolated { // on macOS there is a missing MainActor annotation + RegisterRemoteNotificationsAction.handleDeviceTokenUpdate(spezi, deviceToken) + + // notify all notification handlers of an updated token + for handler in spezi.notificationTokenHandler { + handler.receiveUpdatedDeviceToken(deviceToken) + } + } + } + + public func application(_ application: _Application, didFailToRegisterForRemoteNotificationsWithError error: Error) { + MainActor.assumeIsolated { // on macOS there is a missing MainActor annotation + RegisterRemoteNotificationsAction.handleFailedRegistration(spezi, error) + } + } + + private func handleReceiveRemoteNotification(_ userInfo: [AnyHashable: Any]) async -> BackgroundFetchResult { + let handlers = spezi.notificationHandler + guard !handlers.isEmpty else { + return .noData + } + + let result: Set = await withTaskGroup(of: BackgroundFetchResult.self) { group in + for handler in handlers { + group.addTask { + await handler.receiveRemoteNotification(userInfo) + } + } + + return await group.reduce(into: []) { result, backgroundFetchResult in + result.insert(backgroundFetchResult) + } + } + + if result.contains(.failed) { + return .failed + } else if result.contains(.newData) { + return .newData + } else { + return .noData + } + } + +#if os(iOS) || os(visionOS) || os(tvOS) + public func application( + _ application: UIApplication, + didReceiveRemoteNotification userInfo: [AnyHashable: Any] + ) async -> UIBackgroundFetchResult { + await handleReceiveRemoteNotification(userInfo) + } +#elseif os(macOS) + public func application(_ application: NSApplication, didReceiveRemoteNotification userInfo: [String: Any]) { + for handler in spezi.notificationHandler { + handler.receiveRemoteNotification(userInfo) + } + } +#elseif os(watchOS) + public func didReceiveRemoteNotification(_ userInfo: [AnyHashable: Any]) async -> WKBackgroundFetchResult { + await handleReceiveRemoteNotification(userInfo) + } +#endif + + // MARK: - Legacy UIScene Integration + +#if os(iOS) || os(visionOS) || os(tvOS) open func application( _ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions ) -> UISceneConfiguration { let sceneConfig = UISceneConfiguration(name: nil, sessionRole: connectingSceneSession.role) - SpeziAppDelegate.appDelegate = self + Self.appDelegate = self sceneConfig.delegateClass = SpeziSceneDelegate.self return sceneConfig } @@ -128,37 +226,5 @@ open class SpeziAppDelegate: NSObject, ApplicationDelegate { open func applicationWillTerminate(_ application: UIApplication) { spezi.lifecycleHandler.applicationWillTerminate(application) } - - @available(*, deprecated, message: "Propagate deprecation warning.") - open func sceneWillEnterForeground(_ scene: UIScene) { - spezi.lifecycleHandler.sceneWillEnterForeground(scene) - } - - @available(*, deprecated, message: "Propagate deprecation warning.") - open func sceneDidBecomeActive(_ scene: UIScene) { - spezi.lifecycleHandler.sceneDidBecomeActive(scene) - } - - @available(*, deprecated, message: "Propagate deprecation warning.") - open func sceneWillResignActive(_ scene: UIScene) { - spezi.lifecycleHandler.sceneWillResignActive(scene) - } - - @available(*, deprecated, message: "Propagate deprecation warning.") - open func sceneDidEnterBackground(_ scene: UIScene) { - spezi.lifecycleHandler.sceneDidEnterBackground(scene) - } -#elseif os(macOS) - open func applicationWillFinishLaunching(_ notification: Notification) { - if !ProcessInfo.processInfo.isPreviewSimulator { - // see note above for why we don't launch this within the preview simulator! - - precondition(_spezi == nil, "\(#function) was called when Spezi was already initialized. Unable to pass options!") - - var storage = SpeziStorage() - storage[LaunchOptionsKey.self] = notification.userInfo - self._spezi = Spezi(from: configuration, storage: storage) - } - } #endif } diff --git a/Sources/Spezi/Spezi/SpeziNotificationCenterDelegate.swift b/Sources/Spezi/Spezi/SpeziNotificationCenterDelegate.swift new file mode 100644 index 00000000..f7d39112 --- /dev/null +++ b/Sources/Spezi/Spezi/SpeziNotificationCenterDelegate.swift @@ -0,0 +1,68 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import UserNotifications + + +class SpeziNotificationCenterDelegate: NSObject, UNUserNotificationCenterDelegate { + @MainActor + func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse) async { + guard let delegate = SpeziAppDelegate.appDelegate else { + return + } + + await withTaskGroup(of: Void.self) { group in + for handler in delegate.spezi.notificationHandler { + group.addTask { + await handler.handleNotificationAction(response) + } + } + + for await _ in group {} + } + } + + @MainActor + func userNotificationCenter( + _ center: UNUserNotificationCenter, + willPresent notification: UNNotification + ) async -> UNNotificationPresentationOptions { + guard let delegate = SpeziAppDelegate.appDelegate else { + return [] + } + + + return await withTaskGroup(of: UNNotificationPresentationOptions.self) { group in + for handler in delegate.spezi.notificationHandler { + group.addTask { + await handler.receiveIncomingNotification(notification) + } + } + + // TODO: fine to just merge all options? (this doesn't work with the empty default implementation!) + return await group.reduce(into: []) { result, options in + result.formUnion(options) + } + } + } +} + + +extension SpeziAppDelegate { + func setupNotificationDelegate() { + guard !spezi.notificationHandler.isEmpty else { + return + } + + let center = UNUserNotificationCenter.current() + + let delegate = SpeziNotificationCenterDelegate() + Self.notificationDelegate = delegate // maintain reference + center.delegate = delegate + } +} diff --git a/Sources/Spezi/Spezi/SpeziSceneDelegate.swift b/Sources/Spezi/Spezi/SpeziSceneDelegate.swift index 5b14c7ac..c8d3a818 100644 --- a/Sources/Spezi/Spezi/SpeziSceneDelegate.swift +++ b/Sources/Spezi/Spezi/SpeziSceneDelegate.swift @@ -16,7 +16,7 @@ class SpeziSceneDelegate: NSObject, UISceneDelegate { guard let delegate = SpeziAppDelegate.appDelegate else { return } - delegate.sceneWillEnterForeground(scene) + delegate.spezi.lifecycleHandler.sceneWillEnterForeground(scene) } @available(*, deprecated, message: "Propagate deprecation warning.") @@ -24,7 +24,7 @@ class SpeziSceneDelegate: NSObject, UISceneDelegate { guard let delegate = SpeziAppDelegate.appDelegate else { return } - delegate.sceneDidBecomeActive(scene) + delegate.spezi.lifecycleHandler.sceneDidBecomeActive(scene) } @available(*, deprecated, message: "Propagate deprecation warning.") @@ -32,7 +32,7 @@ class SpeziSceneDelegate: NSObject, UISceneDelegate { guard let delegate = SpeziAppDelegate.appDelegate else { return } - delegate.sceneWillResignActive(scene) + delegate.spezi.lifecycleHandler.sceneWillResignActive(scene) } @available(*, deprecated, message: "Propagate deprecation warning.") @@ -40,7 +40,7 @@ class SpeziSceneDelegate: NSObject, UISceneDelegate { guard let delegate = SpeziAppDelegate.appDelegate else { return } - delegate.sceneDidEnterBackground(scene) + delegate.spezi.lifecycleHandler.sceneDidEnterBackground(scene) } } #endif diff --git a/Sources/Spezi/Utilities/Application.swift b/Sources/Spezi/Utilities/Application.swift new file mode 100644 index 00000000..c2a6f0ea --- /dev/null +++ b/Sources/Spezi/Utilities/Application.swift @@ -0,0 +1,27 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import SwiftUI + + +#if os(iOS) || os(visionOS) || os(tvOS) +/// Platform agnostic Application. +/// +/// Type-alias for the `UIApplication`. +public typealias _Application = UIApplication // swiftlint:disable:this type_name +#elseif os(macOS) +/// Platform agnostic Application. +/// +/// Type-alias for the `NSApplication`. +public typealias _Application = NSApplication // swiftlint:disable:this type_name +#elseif os(watchOS) +/// Platform agnostic Application. +/// +/// Type-alias for the `WKApplication`. +public typealias _Application = WKApplication // swiftlint:disable:this type_name +#endif diff --git a/Sources/Spezi/Utilities/ApplicationDelegate.swift b/Sources/Spezi/Utilities/ApplicationDelegate.swift index e589b4c0..941787aa 100644 --- a/Sources/Spezi/Utilities/ApplicationDelegate.swift +++ b/Sources/Spezi/Utilities/ApplicationDelegate.swift @@ -10,7 +10,7 @@ import SwiftUI #if os(iOS) || os(visionOS) || os(tvOS) -typealias ApplicationDelegate = UIApplicationDelegate & UISceneDelegate // swiftlint:disable:this file_types_order +typealias ApplicationDelegate = UIApplicationDelegate // swiftlint:disable:this file_types_order #elseif os(macOS) typealias ApplicationDelegate = NSApplicationDelegate #elseif os(watchOS) diff --git a/Sources/Spezi/Utilities/BackgroundFetchResult.swift b/Sources/Spezi/Utilities/BackgroundFetchResult.swift new file mode 100644 index 00000000..308042c8 --- /dev/null +++ b/Sources/Spezi/Utilities/BackgroundFetchResult.swift @@ -0,0 +1,22 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import SwiftUI + + +#if os(iOS) || os(visionOS) || os(tvOS) +/// Platform-agnostic `BackgroundFetchResult`. +/// +/// Refer to [`UIBackgroundFetchResult`](https://developer.apple.com/documentation/uikit/uibackgroundfetchresult). +public typealias BackgroundFetchResult = UIBackgroundFetchResult // swiftlint:disable:this file_types_order +#elseif os(watchOS) +/// Platform-agnostic `BackgroundFetchResult`. +/// +/// Refer to [`WKBackgroundFetchResult`](https://developer.apple.com/documentation/watchkit/wkbackgroundfetchresult). +public typealias BackgroundFetchResult = WKBackgroundFetchResult +#endif diff --git a/Tests/SpeziTests/CapabilityTests/LifecycleTests/LifecycleTests.swift b/Tests/SpeziTests/CapabilityTests/LifecycleTests/LifecycleTests.swift index 6bd5bc94..d5015d5b 100644 --- a/Tests/SpeziTests/CapabilityTests/LifecycleTests/LifecycleTests.swift +++ b/Tests/SpeziTests/CapabilityTests/LifecycleTests/LifecycleTests.swift @@ -89,7 +89,7 @@ final class LifecycleTests: XCTestCase { XCTAssertTrue(module.launchOptions.keys.allSatisfy { launchOptions[$0] != nil }) #elseif os(macOS) - let launchOptions: [AnyHashable: Any] = [:] // TODO: put in some values + let launchOptions: [AnyHashable: Any] = [UUID(): "Some value"] testApplicationDelegate.applicationWillFinishLaunching( Notification(name: NSApplication.willFinishLaunchingNotification, userInfo: launchOptions) )