diff --git a/CITATION.cff b/CITATION.cff index 6da2013..f5689b6 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -1,7 +1,7 @@ # # This source file is part of the SpeziNotifications open source project # -# SPDX-FileCopyrightText: 2022 Stanford University and the project authors (see CONTRIBUTORS.md) +# SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) # # SPDX-License-Identifier: MIT # diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index 79bf643..a53f7fe 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -3,7 +3,7 @@ # # This source file is part of the SpeziNotifications open source project # -# SPDX-FileCopyrightText: 2022 Stanford University and the project authors (see CONTRIBUTORS.md) +# SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) # # SPDX-License-Identifier: MIT # diff --git a/LICENSE.md b/LICENSE.md index 6998b5f..f127054 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -1,9 +1,9 @@ MIT License -Copyright (c) 2022 Stanford University and the project authors (see CONTRIBUTORS.md) +Copyright (c) 2024 Stanford University and the project authors (see CONTRIBUTORS.md) Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/Package.swift b/Package.swift index a77d4be..6740cd9 100644 --- a/Package.swift +++ b/Package.swift @@ -3,7 +3,7 @@ // // This source file is part of the SpeziNotifications open source project // -// SPDX-FileCopyrightText: 2022 Stanford University and the project authors (see CONTRIBUTORS.md) +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) // // SPDX-License-Identifier: MIT // @@ -14,6 +14,7 @@ import PackageDescription let package = Package( name: "SpeziNotifications", + defaultLocalization: "en", platforms: [ .iOS(.v17), .watchOS(.v10), @@ -22,10 +23,13 @@ let package = Package( .macOS(.v14) ], products: [ - .library(name: "SpeziNotifications", targets: ["SpeziNotifications"]) + .library(name: "SpeziNotifications", targets: ["SpeziNotifications"]), + .library(name: "XCTSpeziNotifications", targets: ["XCTSpeziNotifications"]), + .library(name: "XCTSpeziNotificationsUI", targets: ["XCTSpeziNotificationsUI"]) ], dependencies: [ - .package(url: "https://github.com/StanfordSpezi/Spezi.git", branch: "feature/application-for-swiftui") + .package(url: "https://github.com/StanfordSpezi/Spezi.git", branch: "feature/application-for-swiftui"), + .package(url: "https://github.com/StanfordSpezi/SpeziViews.git", branch: "feature/additional-infrastructure") ] + swiftLintPackage(), targets: [ .target( @@ -35,6 +39,21 @@ let package = Package( ], plugins: [] + swiftLintPlugin() ), + .target( + name: "XCTSpeziNotifications", + dependencies: [ + .target(name: "SpeziNotifications") + ], + plugins: [] + swiftLintPlugin() + ), + .target( + name: "XCTSpeziNotificationsUI", + dependencies: [ + .target(name: "SpeziNotifications"), + .product(name: "SpeziViews", package: "SpeziViews") + ], + plugins: [] + swiftLintPlugin() + ), .testTarget( name: "SpeziNotificationsTests", dependencies: [ diff --git a/README.md b/README.md index 0fc5b0f..a3a2703 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ This source file is part of the SpeziNotifications open source project -SPDX-FileCopyrightText: 2022 Stanford University and the project authors (see CONTRIBUTORS.md) +SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) SPDX-License-Identifier: MIT diff --git a/Sources/SpeziNotifications/Actions/RegisterForRemoteNotificationsAction.swift b/Sources/SpeziNotifications/Actions/RegisterForRemoteNotificationsAction.swift index 619ee63..af11bbc 100644 --- a/Sources/SpeziNotifications/Actions/RegisterForRemoteNotificationsAction.swift +++ b/Sources/SpeziNotifications/Actions/RegisterForRemoteNotificationsAction.swift @@ -6,38 +6,17 @@ // SPDX-License-Identifier: MIT // +@_spi(APISupport) import Spezi import SpeziFoundation import SwiftUI -@MainActor -private final class RemoteNotificationContinuation: KnowledgeSource, Sendable { - typealias Anchor = SpeziAnchor - - fileprivate(set) var continuation: CheckedContinuation? - fileprivate(set) var access = AsyncSemaphore() - - - init() {} - - - @MainActor - func resume(with result: Result) { - if let continuation { - self.continuation = nil - access.signal() - continuation.resume(with: result) - } - } -} - - extension Spezi { /// Registers to receive remote notifications through Apple Push Notification service. /// /// Refer to the documentation of ``Spezi/registerRemoteNotifications``. - public struct RegisterForRemoteNotificationsAction: Sendable { + public struct RegisterForRemoteNotificationsAction { private weak var spezi: Spezi? fileprivate init(_ spezi: Spezi) { @@ -61,28 +40,7 @@ extension Spezi { preconditionFailure("RegisterRemoteNotificationsAction was used in a scope where Spezi was not available anymore!") } - let registration: RemoteNotificationContinuation - if let existing = spezi.storage[RemoteNotificationContinuation.self] { - registration = existing - } else { - registration = RemoteNotificationContinuation() - spezi.storage[RemoteNotificationContinuation.self] = registration - } - - try await registration.access.waitCheckingCancellation() - -#if targetEnvironment(simulator) - async let _ = withTimeout(of: .seconds(5)) { @MainActor in - spezi.logger.warning("Registering for remote notifications seems to be not possible on this simulator device. Timing out ...") - spezi.storage[RemoteNotificationContinuation.self]?.resume(with: .failure(TimeoutError())) - } -#endif - - return try await withCheckedThrowingContinuation { continuation in - assert(registration.continuation == nil, "continuation wasn't nil") - registration.continuation = continuation - _Application.shared.registerForRemoteNotifications() - } + return try await spezi.remoteNotificationRegistrationSupport() } } @@ -109,8 +67,7 @@ extension Spezi { /// var registerRemoteNotifications /// /// func handleNotificationsPermissions() async throws { - /// // Make sure to request notifications permissions before registering for remote notifications - /// try await UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .badge, .sound]) + /// // Make sure to request notifications permissions before registering for remote notifications ... /// /// /// do { @@ -128,41 +85,74 @@ extension Spezi { /// } /// ``` /// - /// > Tip: Make sure to request authorization by calling [`requestAuthorization(options:completionHandler:)`](https://developer.apple.com/documentation/usernotifications/unusernotificationcenter/requestauthorization(options:completionhandler:)) + /// > Tip: Make sure to request authorization by calling ``requestNotificationAuthorization`` /// to have your remote notifications be able to display alerts, badges or use sound. Otherwise, all remote notifications will be delivered silently. /// /// ## Topics /// ### Action - /// - ``RegisterRemoteNotificationsAction`` + /// - ``RegisterForRemoteNotificationsAction`` public var registerRemoteNotifications: RegisterForRemoteNotificationsAction { RegisterForRemoteNotificationsAction(self) } } -extension Spezi.RegisterForRemoteNotificationsAction { - @MainActor - static func handleDeviceTokenUpdate(_ spezi: Spezi, _ deviceToken: Data) { - guard let registration = spezi.storage[RemoteNotificationContinuation.self] else { - return +extension EnvironmentValues { + /// 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``. + /// + /// - Warning: Registering for Remote Notifications on Simulator devices might not be possible if your are not signed into an Apple ID on the host machine. + /// The method might throw a [`TimeoutError`](https://swiftpackageindex.com/stanfordspezi/spezifoundation/documentation/spezifoundation/timeouterror) + /// in such a case. + /// + /// ```swift + /// import SpeziFoundation + /// + /// struct ExampleView: View { + /// @Environment(\.registerRemoteNotifications) + /// private var registerRemoteNotifications + /// + /// var body: some View { + /// // ... + /// } + /// + /// private func handleNotificationsPermissions() async throws { + /// // Make sure to request notifications permissions before registering for remote notifications + /// try await UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .badge, .sound]) + /// + /// + /// do { + /// let deviceToken = try await registerRemoteNotifications() + /// } catch let error as TimeoutError { + /// #if targetEnvironment(simulator) + /// return // override logic when running within a simulator + /// #else + /// throw error + /// #endif + /// } + /// + /// // .. send the device token to your remote server that generates push notifications + /// } + /// } + /// ``` + /// + /// > Tip: Make sure to request authorization by calling ``requestNotificationAuthorization`` + /// to have your remote notifications be able to display alerts, badges or use sound. Otherwise, all remote notifications will be delivered silently. + @MainActor public var registerRemoteNotifications: Spezi.RegisterForRemoteNotificationsAction { + guard let spezi = SpeziAppDelegate.spezi else { + preconditionFailure("@Environment(\\.registerRemoteNotifications) can only be accessed within a Spezi application.") } - - // 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. - - registration.resume(with: .success(deviceToken)) + return Spezi.RegisterForRemoteNotificationsAction(spezi) } +} - @MainActor - static func handleFailedRegistration(_ spezi: Spezi, _ error: Error) { - guard let registration = spezi.storage[RemoteNotificationContinuation.self] else { - return - } - - if registration.continuation == nil { - spezi.logger.warning("Received a call to \(#function) while we were not waiting for a notifications registration request.") - } - registration.resume(with: .failure(error)) - } -} +extension Spezi.RegisterForRemoteNotificationsAction: Sendable {} diff --git a/Sources/SpeziNotifications/Actions/RequestNotificationAuthorization.swift b/Sources/SpeziNotifications/Actions/RequestNotificationAuthorization.swift index 4eef434..0d0dd44 100644 --- a/Sources/SpeziNotifications/Actions/RequestNotificationAuthorization.swift +++ b/Sources/SpeziNotifications/Actions/RequestNotificationAuthorization.swift @@ -16,7 +16,7 @@ extension Spezi { /// /// Refer to ``Spezi/requestNotificationAuthorization`` for documentation. public struct RequestNotificationAuthorizationAction { - fileprivate init() {} // TODO: apply this everywhere! + fileprivate init() {} /// Request notification authorization. /// - Parameter options: The authorization options your app is requesting. diff --git a/Sources/SpeziNotifications/Actions/UnregisterForRemoteNotificationsAction.swift b/Sources/SpeziNotifications/Actions/UnregisterForRemoteNotificationsAction.swift index 5c9fd85..c1caa73 100644 --- a/Sources/SpeziNotifications/Actions/UnregisterForRemoteNotificationsAction.swift +++ b/Sources/SpeziNotifications/Actions/UnregisterForRemoteNotificationsAction.swift @@ -14,7 +14,7 @@ extension Spezi { /// Unregisters for all remote notifications received through Apple Push Notification service. /// /// Refer to the documentation of ``Spezi/unregisterRemoteNotifications``. - public struct UnregisterForRemoteNotificationsAction: Sendable { + public struct UnregisterForRemoteNotificationsAction { fileprivate init() {} @@ -46,8 +46,39 @@ extension Spezi { /// /// ## Topics /// ### Action - /// - ``UnregisterRemoteNotificationsAction`` + /// - ``UnregisterForRemoteNotificationsAction`` public var unregisterRemoteNotifications: UnregisterForRemoteNotificationsAction { UnregisterForRemoteNotificationsAction() } } + + +extension EnvironmentValues { + /// 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 `View`. + /// + /// ```swift + /// struct ExampleView: View { + /// @Environment(\.unregisterRemoteNotifications) + /// private var unregisterRemoteNotifications + /// + /// var body: some View { + /// Button("Disable Notifications") { + /// Task { + /// try await unregisterRemoteNotifications() + /// } + /// } + /// } + /// } + /// ``` + public var unregisterRemoteNotifications: Spezi.UnregisterForRemoteNotificationsAction { + Spezi.UnregisterForRemoteNotificationsAction() + } +} + + +extension Spezi.UnregisterForRemoteNotificationsAction: Sendable {} diff --git a/Sources/SpeziNotifications/Extensions/UNAuthorizationStatus+Description.swift b/Sources/SpeziNotifications/Extensions/UNAuthorizationStatus+Description.swift new file mode 100644 index 0000000..0060b1c --- /dev/null +++ b/Sources/SpeziNotifications/Extensions/UNAuthorizationStatus+Description.swift @@ -0,0 +1,34 @@ +// +// 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 +import UserNotifications + + +extension UNAuthorizationStatus: @retroactive CustomStringConvertible, @retroactive CustomDebugStringConvertible { + public var description: String { + switch self { + case .notDetermined: + "notDetermined" + case .denied: + "denied" + case .authorized: + "authorized" + case .provisional: + "provisional" + case .ephemeral: + "ephemeral" + @unknown default: + "unknown(\(rawValue))" + } + } + + public var debugDescription: String { + description + } +} diff --git a/Sources/SpeziNotifications/Extensions/UNNotificationInterruptionLevel+Description.swift b/Sources/SpeziNotifications/Extensions/UNNotificationInterruptionLevel+Description.swift new file mode 100644 index 0000000..43e4ac1 --- /dev/null +++ b/Sources/SpeziNotifications/Extensions/UNNotificationInterruptionLevel+Description.swift @@ -0,0 +1,32 @@ +// +// 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 +import UserNotifications + + +extension UNNotificationInterruptionLevel: @retroactive CustomStringConvertible, @retroactive CustomDebugStringConvertible { + public var description: String { + switch self { + case .passive: + "passive" + case .active: + "active" + case .timeSensitive: + "timeSensitive" + case .critical: + "critical" + @unknown default: + "unknown(\(rawValue))" + } + } + + public var debugDescription: String { + description + } +} diff --git a/Sources/SpeziNotifications/Handler/NotificationHandler.swift b/Sources/SpeziNotifications/Handler/NotificationHandler.swift index b40a4dd..17427a5 100644 --- a/Sources/SpeziNotifications/Handler/NotificationHandler.swift +++ b/Sources/SpeziNotifications/Handler/NotificationHandler.swift @@ -6,4 +6,5 @@ // SPDX-License-Identifier: MIT // +// NotificationHandler protocol is currently defined in Spezi. Once Spezi removes it and makes a breaking change, we can move it to this package. @_exported import protocol Spezi.NotificationHandler diff --git a/Sources/SpeziNotifications/Handler/NotificationTokenHandler.swift b/Sources/SpeziNotifications/Handler/NotificationTokenHandler.swift index 0f874e2..356f69c 100644 --- a/Sources/SpeziNotifications/Handler/NotificationTokenHandler.swift +++ b/Sources/SpeziNotifications/Handler/NotificationTokenHandler.swift @@ -6,4 +6,5 @@ // SPDX-License-Identifier: MIT // +// NotificationTokenHandler protocol is currently defined in Spezi. Once Spezi removes it and makes a breaking change, we can move it to this package. @_exported import protocol Spezi.NotificationTokenHandler diff --git a/Sources/SpeziNotifications/Misc/BackgroundFetchResult.swift b/Sources/SpeziNotifications/Misc/BackgroundFetchResult.swift index f658c2c..0184743 100644 --- a/Sources/SpeziNotifications/Misc/BackgroundFetchResult.swift +++ b/Sources/SpeziNotifications/Misc/BackgroundFetchResult.swift @@ -6,4 +6,5 @@ // SPDX-License-Identifier: MIT // +// BackgroundFetchResult type-alias is currently defined in Spezi. Once Spezi removes it and makes a breaking change, we can move it to this package. @_exported import typealias Spezi.BackgroundFetchResult diff --git a/Sources/SpeziNotifications/Notifications.swift b/Sources/SpeziNotifications/Notifications.swift index 4081f41..ed5cae7 100644 --- a/Sources/SpeziNotifications/Notifications.swift +++ b/Sources/SpeziNotifications/Notifications.swift @@ -20,21 +20,21 @@ import Spezi /// - ``init()`` /// /// ### Badge Count -/// - ``setBadgeCount(_:)`` +/// - ``setBadgeCount(isolation:_:)`` /// /// ### Add a Notification Request -/// - ``add(request:)`` +/// - ``add(isolation:request:)`` /// /// ### Notification Limits /// - ``pendingNotificationsLimit`` -/// - ``remainingNotificationLimit()`` +/// - ``remainingNotificationLimit(isolation:)`` /// /// ### Fetching Notifications -/// - ``pendingNotificationRequests()`` -/// - ``deliveredNotifications()`` +/// - ``pendingNotificationRequests(isolation:)`` +/// - ``deliveredNotifications(isolation:)`` /// /// ### Categories -/// - ``add(categories:)`` +/// - ``add(isolation:categories:)`` public final class Notifications: Module, DefaultInitializable, EnvironmentAccessible { /// The total limit of simultaneously scheduled notifications. /// @@ -50,7 +50,6 @@ public final class Notifications: Module, DefaultInitializable, EnvironmentAcces /// Configure the local notifications module. public init() {} -#if compiler(>=6) /// Updates the badge count for your app’s icon. /// - Parameters: /// - isolation: Inherits the current isolation. @@ -62,14 +61,7 @@ public final class Notifications: Module, DefaultInitializable, EnvironmentAcces ) async throws { try await UNUserNotificationCenter.current().setBadgeCount(badgeCount) } -#else - @available(watchOS, unavailable) - public func setBadgeCount(_ badgeCount: Int) async throws { - try await UNUserNotificationCenter.current().setBadgeCount(badgeCount) - } -#endif -#if compiler(>=6) /// Schedule a new notification request. /// - Parameters: /// - isolation: Inherits the current isolation. @@ -80,15 +72,7 @@ public final class Notifications: Module, DefaultInitializable, EnvironmentAcces ) async throws { try await UNUserNotificationCenter.current().add(request) } -#else - /// Schedule a new notification request. - /// - Parameter request: The notification request. - public func add(request: UNNotificationRequest) async throws { - try await UNUserNotificationCenter.current().add(request) - } -#endif -#if compiler(>=6) /// Retrieve the amount of notifications that can be scheduled for the app. /// /// An application has a total limit of ``pendingNotificationsLimit`` that can be scheduled (pending). This method retrieve the reaming notifications that can be scheduled. @@ -100,35 +84,14 @@ public final class Notifications: Module, DefaultInitializable, EnvironmentAcces let pendingRequests = await UNUserNotificationCenter.current().pendingNotificationRequests() return max(0, Self.pendingNotificationsLimit - pendingRequests.count) } -#else - /// Retrieve the amount of notifications that can be scheduled for the app. - /// - /// An application has a total limit of ``pendingNotificationsLimit`` that can be scheduled (pending). This method retrieve the reaming notifications that can be scheduled. - /// - /// - Note: Already delivered notifications do not count towards this limit. - /// - Returns: Returns the remaining amount of notifications that can be scheduled for the application. - public func remainingNotificationLimit() async -> Int { - let pendingRequests = await UNUserNotificationCenter.current().pendingNotificationRequests() - return max(0, Self.pendingNotificationsLimit - pendingRequests.count) - } -#endif -#if compiler(>=6) /// Fetch all notification requests that are pending delivery. /// - Parameter isolation: Inherits the current isolation. /// - Returns: The array of pending notifications requests. public func pendingNotificationRequests(isolation: isolated (any Actor)? = #isolation) async -> sending [UNNotificationRequest] { await UNUserNotificationCenter.current().pendingNotificationRequests() } -#else - /// Fetch all notification requests that are pending delivery. - /// - Returns: The array of pending notifications requests. - public func pendingNotificationRequests() async -> [UNNotificationRequest] { - await UNUserNotificationCenter.current().pendingNotificationRequests() - } -#endif -#if compiler(>=6) /// Fetch all delivered notifications that are still shown in the notification center. /// - Parameter isolation: Inherits the current isolation. /// - Returns: The array of local and remote notifications that have been delivered and are still show in the notification center. @@ -136,16 +99,7 @@ public final class Notifications: Module, DefaultInitializable, EnvironmentAcces public func deliveredNotifications(isolation: isolated (any Actor)? = #isolation) async -> sending [UNNotification] { await UNUserNotificationCenter.current().deliveredNotifications() } -#else - /// Fetch all delivered notifications that are still shown in the notification center. - /// - Returns: The array of local and remote notifications that have been delivered and are still show in the notification center. - @available(tvOS, unavailable) - public func deliveredNotifications() async -> [UNNotification] { - await UNUserNotificationCenter.current().deliveredNotifications() - } -#endif -#if compiler(>=6) /// Add additional notification categories. /// /// This method adds additional notification categories. Call this method within your configure method of your Module to ensure that categories are configured @@ -167,22 +121,4 @@ public final class Notifications: Module, DefaultInitializable, EnvironmentAcces let previousCategories = await UNUserNotificationCenter.current().notificationCategories() UNUserNotificationCenter.current().setNotificationCategories(categories.union(previousCategories)) } -#else - /// Add additional notification categories. - /// - /// This method adds additional notification categories. Call this method within your configure method of your Module to ensure that categories are configured - /// as early as possible. - /// - /// To receive the action that are performed for your category, implement the ``NotificationHandler/handleNotificationAction(_:)`` method of the - /// ``NotificationHandler`` protocol. - /// - /// - Note: Aim to only call this method once at startup. - /// - /// - Parameter categories: The notification categories you support. - @available(tvOS, unavailable) - public func add(categories: Set) async { - let previousCategories = await UNUserNotificationCenter.current().notificationCategories() - UNUserNotificationCenter.current().setNotificationCategories(categories.union(previousCategories)) - } -#endif } diff --git a/Sources/SpeziNotifications/SpeziNotifications.docc/SpeziNotifications.md b/Sources/SpeziNotifications/SpeziNotifications.docc/SpeziNotifications.md index 63146ff..b688658 100644 --- a/Sources/SpeziNotifications/SpeziNotifications.docc/SpeziNotifications.md +++ b/Sources/SpeziNotifications/SpeziNotifications.docc/SpeziNotifications.md @@ -1,13 +1,13 @@ # ``SpeziNotifications`` The template repository contains a template Swift Package, including a continuous integration setup. @@ -16,8 +16,22 @@ The template repository contains a template Swift Package, including a continuou Please follow the steps in the README.md file to customize the code to your needs. -## Types +## Topics + +### Notifications +- ``Notifications`` +- ``NotificationHandler`` +- ``BackgroundFetchResult`` -### Template Package +### Notification Authorization +- ``Spezi/Spezi/notificationSettings`` +- ``SwiftUICore/EnvironmentValues/notificationSettings`` +- ``Spezi/Spezi/requestNotificationAuthorization`` +- ``SwiftUICore/EnvironmentValues/requestNotificationAuthorization`` -- ``SpeziNotifications`` +### Remote Notifications +- ``NotificationTokenHandler`` +- ``Spezi/Spezi/registerRemoteNotifications`` +- ``SwiftUICore/EnvironmentValues/registerRemoteNotifications`` +- ``Spezi/Spezi/unregisterRemoteNotifications`` +- ``SwiftUICore/EnvironmentValues/unregisterRemoteNotifications`` diff --git a/Sources/XCTSpeziNotifications/XCTSpeziNotifications.docc/XCTSpeziNotifications.md b/Sources/XCTSpeziNotifications/XCTSpeziNotifications.docc/XCTSpeziNotifications.md new file mode 100644 index 0000000..61bf36f --- /dev/null +++ b/Sources/XCTSpeziNotifications/XCTSpeziNotifications.docc/XCTSpeziNotifications.md @@ -0,0 +1,23 @@ +# ``XCTSpeziNotifications`` + +XCTest extensions for testing notification-related actions. + + + +## Topics + +### Notification Authorization + +- ``XCTest/XCUIApplication/NotificationAuthorizationAction`` +- ``XCTest/XCUIApplication/confirmNotificationAuthorization(action:)`` + +### Notification Requests +- ``XCTest/XCUIApplication/assertNotificationDetails(identifier:title:subtitle:body:category:thread:sound:interruption:type:nextTrigger:nextTriggerExistenceTimeout:)`` diff --git a/Sources/XCTSpeziNotifications/XCUIApplication+AuthorizationAlert.swift b/Sources/XCTSpeziNotifications/XCUIApplication+AuthorizationAlert.swift new file mode 100644 index 0000000..a176b9a --- /dev/null +++ b/Sources/XCTSpeziNotifications/XCUIApplication+AuthorizationAlert.swift @@ -0,0 +1,33 @@ +// +// 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 SpeziNotifications +import UserNotifications +import XCTest + + +extension XCUIApplication { + /// Action of the notification authorization alert. + public enum NotificationAuthorizationAction: String { + case allow = "Allow" + case doNotAllow = "Don’t Allow" + } + + /// Confirm the notification authorization dialog. + /// - Parameter action: The action to confirm the alert with. + public func confirmNotificationAuthorization(action: NotificationAuthorizationAction = .allow) { + let springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard") + + let predicate = NSPredicate(format: "label CONTAINS 'Would Like to Send You Notifications'") + let alert = springboard.alerts.element(matching: predicate) + XCTAssert(alert.waitForExistence(timeout: 5.0)) + print(springboard.alerts.element(matching: predicate).debugDescription) + XCTAssert(alert.buttons[action.rawValue].exists) + alert.buttons[action.rawValue].tap() + } +} diff --git a/Sources/XCTSpeziNotifications/XCUIApplication+NotificationDetails.swift b/Sources/XCTSpeziNotifications/XCUIApplication+NotificationDetails.swift new file mode 100644 index 0000000..ec8a20d --- /dev/null +++ b/Sources/XCTSpeziNotifications/XCUIApplication+NotificationDetails.swift @@ -0,0 +1,67 @@ +// +// 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 SpeziNotifications +import UserNotifications +import XCTest + + +extension XCUIApplication { + /// Assert the contents of a pending notification visualized with the `NotificationRequestView`. + /// - Parameters: + /// - identifier: The identifier to assert. + /// - title: The title to assert. + /// - subtitle: The optional subtitle to assert. + /// - body: The body to assert. + /// - category: The optional category identifier to assert. + /// - thread: The optional thread identifier to assert. + /// - sound: Assert if there is sound played for the notification. + /// - interruption: The interruption level to assert. + /// - type: The trigger type to assert. + /// - nextTrigger: The next trigger label to assert. + /// - nextTriggerExistenceTimeout: The time to await for the trigger label to appear. + public func assertNotificationDetails( // swiftlint:disable:this function_default_parameter_at_end + identifier: String, + title: String, + subtitle: String? = nil, + body: String, + category: String? = nil, + thread: String? = nil, + sound: Bool = false, + interruption: UNNotificationInterruptionLevel = .active, + type: String? = nil, + nextTrigger: String? = nil, + nextTriggerExistenceTimeout: TimeInterval = 60 + ) { + XCTAssert(navigationBars.staticTexts[title].waitForExistence(timeout: 2.0)) + XCTAssert(staticTexts["Title, \(title)"].exists) + if let subtitle { + XCTAssert(staticTexts["Subtitle, \(subtitle)"].exists) + } + XCTAssert(staticTexts["Body, \(body)"].exists) + if let category { + XCTAssert(staticTexts["Category, \(category)"].exists) + } + if let thread { + XCTAssert(staticTexts["Thread, \(thread)"].exists) + } + + XCTAssert(staticTexts["Sound, \(sound ? "Yes" : "No")"].exists) + XCTAssert(staticTexts["Interruption, \(interruption.description)"].exists) + + if let type { + XCTAssert(staticTexts["Type, \(type)"].exists) + } + + XCTAssert(staticTexts["Identifier, \(identifier)"].exists) + + if let nextTrigger { + XCTAssert(staticTexts["Next Trigger, \(nextTrigger)"].waitForExistence(timeout: nextTriggerExistenceTimeout)) + } + } +} diff --git a/Sources/XCTSpeziNotificationsUI/NotificationRequestLabel.swift b/Sources/XCTSpeziNotificationsUI/NotificationRequestLabel.swift new file mode 100644 index 0000000..b0907f0 --- /dev/null +++ b/Sources/XCTSpeziNotificationsUI/NotificationRequestLabel.swift @@ -0,0 +1,42 @@ +// +// 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 SpeziViews +import SwiftUI +import UserNotifications + + +@available(iOS 18.0, macOS 15.0, tvOS 18.0, watchOS 11.0, visionOS 2.0, *) +struct NotificationRequestLabel: View { + private let request: UNNotificationRequest + + @ManagedViewUpdate private var viewUpdate + + var body: some View { + NavigationLink { + NotificationRequestView(request) + } label: { + VStack(alignment: .leading) { + Text(request.content.title) + .bold() + if let trigger = request.trigger, + let nextDate = trigger.nextDate() { + NotificationTriggerLabel(nextDate) + .foregroundStyle(.secondary) + .onAppear { + viewUpdate.schedule(at: nextDate) + } + } + } + } + } + + init(_ request: UNNotificationRequest) { + self.request = request + } +} diff --git a/Sources/XCTSpeziNotificationsUI/NotificationRequestView.swift b/Sources/XCTSpeziNotificationsUI/NotificationRequestView.swift new file mode 100644 index 0000000..97c2db1 --- /dev/null +++ b/Sources/XCTSpeziNotificationsUI/NotificationRequestView.swift @@ -0,0 +1,141 @@ +// +// 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 SpeziViews +import SwiftUI +import UserNotifications + + +/// Present the details of a notification request. +@available(iOS 18.0, macOS 15.0, tvOS 18.0, watchOS 11.0, visionOS 2.0, *) +public struct NotificationRequestView: View { + private let request: UNNotificationRequest + + @ManagedViewUpdate private var viewUpdate + + public var body: some View { + List { + content + + delivery + + trigger + + Section { + LabeledContent { + Text(request.identifier) + } label: { + Text("Identifier", bundle: .module) + } + .accessibilityElement(children: .combine) + } + } + .navigationTitle(request.content.title) +#if !os(macOS) + .navigationBarTitleDisplayMode(.inline) +#endif + } + + @ViewBuilder private var content: some View { + Section { // swiftlint:disable:this closure_body_length + LabeledContent { + Text(request.content.title) + } label: { + Text("Title", bundle: .module) + } + .accessibilityElement(children: .combine) + + if !request.content.subtitle.isEmpty { + LabeledContent { + Text(request.content.subtitle) + } label: { + Text("Subtitle", bundle: .module) + } + .accessibilityElement(children: .combine) + } + + LabeledContent { + Text(request.content.body) + } label: { + Text("Body", bundle: .module) + } + .accessibilityElement(children: .combine) + + if !request.content.categoryIdentifier.isEmpty { + LabeledContent { + Text(request.content.categoryIdentifier) + } label: { + Text("Category", bundle: .module) + } + .accessibilityElement(children: .combine) + } + + if !request.content.threadIdentifier.isEmpty { + LabeledContent { + Text(request.content.threadIdentifier) + } label: { + Text("Thread", bundle: .module) + } + .accessibilityElement(children: .combine) + } + } header: { + Text("Content", bundle: .module) + } + } + + @ViewBuilder private var delivery: some View { + Section { + LabeledContent { + Text(request.content.sound != nil ? "Yes" : "No", bundle: .module) + } label: { + Text("Sound", bundle: .module) + } + .accessibilityElement(children: .combine) + + LabeledContent { + Text(request.content.interruptionLevel.description) + } label: { + Text("Interruption", bundle: .module) + } + .accessibilityElement(children: .combine) + } header: { + Text("Delivery", bundle: .module) + } + } + + @ViewBuilder private var trigger: some View { + if let trigger = request.trigger { + Section { + LabeledContent { + Text(trigger.type) + } label: { + Text("Type", bundle: .module) + } + .accessibilityElement(children: .combine) + + if let nextDate = trigger.nextDate() { + LabeledContent("Next Trigger") { + NotificationTriggerLabel(nextDate) + } + .accessibilityElement(children: .combine) + .onAppear { + viewUpdate.schedule(at: nextDate) + } + } + } header: { + Text("Trigger", bundle: .module) + } + } + } + + /// Create a new notification request details view. + /// - Parameter request: The notification request. + public init(_ request: UNNotificationRequest) { + self.request = request + } +} diff --git a/Sources/XCTSpeziNotificationsUI/NotificationTriggerLabel.swift b/Sources/XCTSpeziNotificationsUI/NotificationTriggerLabel.swift new file mode 100644 index 0000000..a53510e --- /dev/null +++ b/Sources/XCTSpeziNotificationsUI/NotificationTriggerLabel.swift @@ -0,0 +1,36 @@ +// +// 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 Spezi +import SpeziViews +import SwiftUI + + +@available(iOS 18.0, macOS 15.0, tvOS 18.0, watchOS 11.0, visionOS 2.0, *) +struct NotificationTriggerLabel: View { + private let nextTriggerDate: Date + + @ManagedViewUpdate private var viewUpdate + + var body: some View { + Group { + if nextTriggerDate > .now { + Text("in \(Text(.currentDate, format: SystemFormatStyle.DateOffset(to: nextTriggerDate, sign: .never)))", bundle: .module) + } else { + Text("\(Text(.currentDate, format: SystemFormatStyle.DateOffset(to: nextTriggerDate, sign: .never))) ago", bundle: .module) + } + } + .onAppear { + viewUpdate.schedule(at: nextTriggerDate) + } + } + + init(_ nextTriggerDate: Date) { + self.nextTriggerDate = nextTriggerDate + } +} diff --git a/Sources/XCTSpeziNotificationsUI/NotificationsView.swift b/Sources/XCTSpeziNotificationsUI/NotificationsView.swift new file mode 100644 index 0000000..706b4bd --- /dev/null +++ b/Sources/XCTSpeziNotificationsUI/NotificationsView.swift @@ -0,0 +1,65 @@ +// +// 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 OSLog +import SpeziNotifications +import SpeziViews +import SwiftUI + + +/// Fully integrated notifications view that shows the list of pending notifications. +@available(iOS 18.0, macOS 15.0, tvOS 18.0, watchOS 11.0, visionOS 2.0, *) +public struct NotificationsView: View { + private let logger = Logger(subsystem: "edu.stanford.spezi.SpeziNotifications", category: "NotificationsView") + private let authorizationAction: () -> Void + + @Environment(\.notificationSettings) + private var notificationSettings + @Environment(\.requestNotificationAuthorization) + private var requestNotificationAuthorization + + @State private var requestAuthorization = false + @State private var viewState: ViewState = .idle + + public var body: some View { + PendingNotificationsList() + .toolbar { + ToolbarItem(placement: .topBarLeading) { + if requestAuthorization { + AsyncButton(state: $viewState) { + try await requestNotificationAuthorization(options: [.alert, .sound, .badge]) + await queryAuthorization() + authorizationAction() + } label: { + Label("Request Notification Authorization", systemImage: "alarm.waves.left.and.right.fill") + } + } + } + } + .task { + await queryAuthorization() + } + } + + /// Create a new notifications view. + public init() { + self.init {} + } + + /// Create a new notification view a action that is called after requesting authorization. + /// - Parameter authorizationAction: The action that is executed once the user confirms the notification authorization. + public init(authorizationAction: @escaping () -> Void) { + self.authorizationAction = authorizationAction + } + + private func queryAuthorization() async { + let status = await notificationSettings().authorizationStatus + requestAuthorization = status != .authorized && status != .denied + logger.debug("Notification authorization is now \(status.description)") + } +} diff --git a/Sources/XCTSpeziNotificationsUI/PendingNotificationsList.swift b/Sources/XCTSpeziNotificationsUI/PendingNotificationsList.swift new file mode 100644 index 0000000..b04c947 --- /dev/null +++ b/Sources/XCTSpeziNotificationsUI/PendingNotificationsList.swift @@ -0,0 +1,83 @@ +// +// 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 SpeziNotifications +import SpeziViews +import SwiftUI +import UserNotifications + + +/// Present a list of pending notifications. +@available(iOS 18.0, macOS 15.0, tvOS 18.0, watchOS 11.0, visionOS 2.0, *) +public struct PendingNotificationsList: View { + @Environment(Notifications.self) + private var localNotifications + + @State private var viewState: ViewState = .idle + @State private var pendingNotifications: [UNNotificationRequest] = [] + + public var body: some View { + Group { + if viewState == .processing { + ProgressView() + } else if pendingNotifications.isEmpty { + ContentUnavailableView { + Label { + Text("No Notifications", bundle: .module) + } icon: { + Image(systemName: "mail") // swiftlint:disable:this accessibility_label_for_image + } + } description: { + Text("No pending notification requests.", bundle: .module) + } actions: { + refreshButton + .labelStyle(.titleOnly) + } + } else { + List { + ForEach(pendingNotifications, id: \.identifier) { request in + NotificationRequestLabel(request) + } + } + .toolbar { + refreshButton + } + } + } + .navigationTitle(Text("Pending Notifications", bundle: .module)) + .task { + await refreshList() + } + } + + @ViewBuilder private var refreshButton: some View { + AsyncButton(state: $viewState) { + await refreshList() + } label: { + Label { + Text("Refresh", bundle: .module) + } icon: { + Image(systemName: "arrow.clockwise") // swiftlint:disable:this accessibility_label_for_image + } + } + } + + /// Create a new list of pending notifications + public init() {} + + + private func refreshList() async { + viewState = .processing + defer { + viewState = .idle + } + + pendingNotifications.removeAll() + pendingNotifications = await localNotifications.pendingNotificationRequests() + } +} diff --git a/Sources/XCTSpeziNotificationsUI/Resources/Localizable.xcstrings b/Sources/XCTSpeziNotificationsUI/Resources/Localizable.xcstrings new file mode 100644 index 0000000..e399d8f --- /dev/null +++ b/Sources/XCTSpeziNotificationsUI/Resources/Localizable.xcstrings @@ -0,0 +1,324 @@ +{ + "sourceLanguage" : "en", + "strings" : { + "%@ ago" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ ago" + } + } + } + }, + "active" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "active" + } + } + } + }, + "Body" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Body" + } + } + } + }, + "Calendar" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Calendar" + } + } + } + }, + "Category" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Category" + } + } + } + }, + "Content" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Content" + } + } + } + }, + "critical" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "critical" + } + } + } + }, + "Delivery" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Delivery" + } + } + } + }, + "Identifier" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Identifier" + } + } + } + }, + "in %@" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "in %@" + } + } + } + }, + "Interruption" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Interruption" + } + } + } + }, + "Interval" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Interval" + } + } + } + }, + "Location" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Location" + } + } + } + }, + "Next Trigger" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Next Trigger" + } + } + } + }, + "No" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No" + } + } + } + }, + "No Notifications" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No Notifications" + } + } + } + }, + "No pending notification requests." : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No pending notification requests." + } + } + } + }, + "passive" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "passive" + } + } + } + }, + "Pending Notifications" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pending Notifications" + } + } + } + }, + "Push" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Push" + } + } + } + }, + "Refresh" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Refresh" + } + } + } + }, + "Request Notification Authorization" : { + + }, + "Sound" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sound" + } + } + } + }, + "Subtitle" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Subtitle" + } + } + } + }, + "Thread" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Thread" + } + } + } + }, + "timeSensitive" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "timeSensitive" + } + } + } + }, + "Title" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Title" + } + } + } + }, + "Trigger" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Trigger" + } + } + } + }, + "Type" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Type" + } + } + } + }, + "Unknown" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Unknown" + } + } + } + }, + "unknown(%llu)" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "unknown(%llu)" + } + } + } + }, + "Yes" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Yes" + } + } + } + } + }, + "version" : "1.0" +} \ No newline at end of file diff --git a/Sources/XCTSpeziNotificationsUI/Resources/Localizable.xcstrings.license b/Sources/XCTSpeziNotificationsUI/Resources/Localizable.xcstrings.license new file mode 100644 index 0000000..a648e99 --- /dev/null +++ b/Sources/XCTSpeziNotificationsUI/Resources/Localizable.xcstrings.license @@ -0,0 +1,5 @@ +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 diff --git a/Sources/XCTSpeziNotificationsUI/UNNotificationTrigger+Extensions.swift b/Sources/XCTSpeziNotificationsUI/UNNotificationTrigger+Extensions.swift new file mode 100644 index 0000000..45bacfc --- /dev/null +++ b/Sources/XCTSpeziNotificationsUI/UNNotificationTrigger+Extensions.swift @@ -0,0 +1,43 @@ +// +// 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 +import UserNotifications + + +extension UNNotificationTrigger { + var type: LocalizedStringResource { + if self is UNCalendarNotificationTrigger { + LocalizedStringResource("Calendar", bundle: .atURL(from: .module)) + } else if self is UNTimeIntervalNotificationTrigger { + LocalizedStringResource("Interval", bundle: .atURL(from: .module)) + } else if self is UNPushNotificationTrigger { + LocalizedStringResource("Push", bundle: .atURL(from: .module)) + } else { +#if !os(visionOS) && !os(macOS) + if self is UNLocationNotificationTrigger { + LocalizedStringResource("Location", bundle: .atURL(from: .module)) + } else { + LocalizedStringResource("Unknown", bundle: .atURL(from: .module)) + } +#else + LocalizedStringResource("Unknown", bundle: .atURL(from: .module)) +#endif + } + } + + func nextDate() -> Date? { + if let calendarTrigger = self as? UNCalendarNotificationTrigger { + calendarTrigger.nextTriggerDate() + } else if let intervalTrigger = self as? UNTimeIntervalNotificationTrigger { + intervalTrigger.nextTriggerDate() + } else { + nil + } + } +} diff --git a/Sources/XCTSpeziNotificationsUI/XCTSpeziNotificationsUI.docc/XCTSpeziNotificationsUI.md b/Sources/XCTSpeziNotificationsUI/XCTSpeziNotificationsUI.docc/XCTSpeziNotificationsUI.md new file mode 100644 index 0000000..510e2a4 --- /dev/null +++ b/Sources/XCTSpeziNotificationsUI/XCTSpeziNotificationsUI.docc/XCTSpeziNotificationsUI.md @@ -0,0 +1,21 @@ +# ``XCTSpeziNotificationsUI`` + +Utilities and view components useful when testing notifications + + + +## Topics + +### Visualize Pending Notifications + +- ``NotificationsView`` +- ``PendingNotificationsList`` +- ``NotificationRequestView`` diff --git a/Tests/SpeziNotificationsTests/SpeziNotificationTests.swift b/Tests/SpeziNotificationsTests/SpeziNotificationTests.swift index f0a7e19..536864a 100644 --- a/Tests/SpeziNotificationsTests/SpeziNotificationTests.swift +++ b/Tests/SpeziNotificationsTests/SpeziNotificationTests.swift @@ -6,7 +6,6 @@ // SPDX-License-Identifier: MIT // -import struct Spezi.Application import protocol Spezi.Module import class Spezi.SpeziAppDelegate @testable import struct Spezi.Configuration diff --git a/Tests/UITests/TestApp.xctestplan.license b/Tests/UITests/TestApp.xctestplan.license index f2f23af..1f1a4cd 100644 --- a/Tests/UITests/TestApp.xctestplan.license +++ b/Tests/UITests/TestApp.xctestplan.license @@ -1,5 +1,5 @@ This source file is part of the SpeziNotifications open-source project -SPDX-FileCopyrightText: 2022 Stanford University and the project authors (see CONTRIBUTORS.md) +SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) -SPDX-License-Identifier: MIT \ No newline at end of file +SPDX-License-Identifier: MIT diff --git a/Tests/UITests/TestApp/ControlsView.swift b/Tests/UITests/TestApp/ControlsView.swift new file mode 100644 index 0000000..ce0c8e1 --- /dev/null +++ b/Tests/UITests/TestApp/ControlsView.swift @@ -0,0 +1,164 @@ +// +// This source file is part of the SpeziNotifications open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import SpeziNotifications +import SpeziViews +import SwiftUI +import UserNotifications + + +struct ControlsView: View { + @Environment(\.notificationSettings) + private var notificationSettings + @Environment(\.requestNotificationAuthorization) + private var requestNotificationAuthorization + + @Environment(\.registerRemoteNotifications) + private var registerRemoteNotifications + @Environment(\.unregisterRemoteNotifications) + private var unregisterRemoteNotifications + + @Environment(Notifications.self) + private var notifications + + @State private var token: Data? + @State private var tokenError: Error? + @State private var authorizationStatus: UNAuthorizationStatus? + @State private var viewState: ViewState = .idle + + @State private var task: Task? { + willSet { + task?.cancel() + } + } + + + var body: some View { + List { // swiftlint:disable:this closure_body_length + Section { + if let authorizationStatus { + LabeledContent("Authorization", value: authorizationStatus.description) + } + LabeledContent("Token") { + if let token { + Text(token.description) + .foregroundStyle(.green) + } else if let error = tokenError as? LocalizedError, + let description = error.errorDescription ?? error.failureReason { + Text(verbatim: description) + .foregroundStyle(.red) + } else if tokenError != nil { + Text(verbatim: "failed") + .foregroundStyle(.red) + } else { + Text(verbatim: "none") + .foregroundStyle(.secondary) + } + } + .accessibilityElement(children: .combine) + .accessibilityIdentifier("token-field") + } + + Section("Actions") { + Button("Register") { + task = Task { + do { + token = try await registerRemoteNotifications() + } catch { + print("Registration failed with \(error)") + self.tokenError = error + } + } + } + Button("Unregister") { + unregisterRemoteNotifications() + token = nil + tokenError = nil + } + if authorizationStatus != .authorized { + AsyncButton("Request Authorization", state: $viewState) { + do { + try await requestNotificationAuthorization(options: [.alert, .badge, .sound]) + authorizationStatus = await notificationSettings().authorizationStatus + } catch { + authorizationStatus = await notificationSettings().authorizationStatus + throw error + } + } + } + if authorizationStatus != .denied { + AsyncButton("Schedule Notifications", state: $viewState) { + try await scheduleNotifications() + } + } + } + } + .navigationTitle("Notifications") + .viewStateAlert(state: $viewState) + .task { + authorizationStatus = await notificationSettings().authorizationStatus + } + .onDisappear { + task?.cancel() + } + } + + private func scheduleNotifications() async throws { + let settings = await notificationSettings() + if settings.authorizationStatus == .notDetermined { + try await requestNotificationAuthorization(options: [.alert, .badge, .sound, .provisional]) + authorizationStatus = await notificationSettings().authorizationStatus + } + + try await notifications.add(request: .calendarRequest) + try await notifications.add(request: .intervalRequest) + } +} + + +extension UNNotificationContent { + static func content(type: String, interruption: UNNotificationInterruptionLevel = .active) -> UNNotificationContent { + let content = UNMutableNotificationContent() + content.title = "\(type) Notification" + content.subtitle = "Test Notification" + content.body = "This is a \(type.lowercased()) notification" + + content.categoryIdentifier = "\(type.lowercased())-test-notification" + content.threadIdentifier = "SpeziNotifications" + content.interruptionLevel = interruption + content.sound = .default + + return content + } +} + + +extension UNNotificationTrigger { + static var calendarTrigger: UNNotificationTrigger { + UNCalendarNotificationTrigger(dateMatching: DateComponents(hour: 8, minute: 0, second: 0), repeats: true) + } + + static var intervalTrigger: UNNotificationTrigger { + UNTimeIntervalNotificationTrigger(timeInterval: 3600, repeats: false) + } +} + + +extension UNNotificationRequest { + static var calendarRequest: UNNotificationRequest { + UNNotificationRequest( + identifier: "calendar-request", + content: .content(type: "Calendar", interruption: .timeSensitive), + trigger: .calendarTrigger + ) + } + + static var intervalRequest: UNNotificationRequest { + UNNotificationRequest(identifier: "interval-request", content: .content(type: "Interval", interruption: .critical), trigger: .intervalTrigger) + } +} diff --git a/Tests/UITests/TestApp/OperatingSystem.swift b/Tests/UITests/TestApp/OperatingSystem.swift deleted file mode 100644 index ea2b7a3..0000000 --- a/Tests/UITests/TestApp/OperatingSystem.swift +++ /dev/null @@ -1,20 +0,0 @@ -// -// This source file is part of the SpeziNotifications open-source project -// -// SPDX-FileCopyrightText: 2022 Stanford University and the project authors (see CONTRIBUTORS.md) -// -// SPDX-License-Identifier: MIT -// - - -#if os(macOS) -let operatingSystem = "macOS" -#elseif os(iOS) -let operatingSystem = "iOS" -#elseif os(watchOS) -let operatingSystem = "watchOS" -#elseif os(visionOS) -let operatingSystem = "visionOS" -#elseif os(tvOS) -let operatingSystem = "tvOS" -#endif diff --git a/Tests/UITests/TestApp/TestApp.entitlements b/Tests/UITests/TestApp/TestApp.entitlements new file mode 100644 index 0000000..a28da91 --- /dev/null +++ b/Tests/UITests/TestApp/TestApp.entitlements @@ -0,0 +1,10 @@ + + + + + aps-environment + development + com.apple.developer.aps-environment + development + + diff --git a/Tests/UITests/TestAppWatchApp.xctestplan.license b/Tests/UITests/TestApp/TestApp.entitlements.license similarity index 52% rename from Tests/UITests/TestAppWatchApp.xctestplan.license rename to Tests/UITests/TestApp/TestApp.entitlements.license index f2f23af..1f1a4cd 100644 --- a/Tests/UITests/TestAppWatchApp.xctestplan.license +++ b/Tests/UITests/TestApp/TestApp.entitlements.license @@ -1,5 +1,5 @@ This source file is part of the SpeziNotifications open-source project -SPDX-FileCopyrightText: 2022 Stanford University and the project authors (see CONTRIBUTORS.md) +SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) -SPDX-License-Identifier: MIT \ No newline at end of file +SPDX-License-Identifier: MIT diff --git a/Tests/UITests/TestApp/TestApp.swift b/Tests/UITests/TestApp/TestApp.swift index 486ec91..e742224 100644 --- a/Tests/UITests/TestApp/TestApp.swift +++ b/Tests/UITests/TestApp/TestApp.swift @@ -1,21 +1,38 @@ // // This source file is part of the SpeziNotifications open-source project // -// SPDX-FileCopyrightText: 2022 Stanford University and the project authors (see CONTRIBUTORS.md) +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) // // SPDX-License-Identifier: MIT // +import Spezi import SwiftUI import SpeziNotifications +import XCTSpeziNotificationsUI @main struct UITestsApp: App { + @UIApplicationDelegateAdaptor(TestAppDelegate.self) + private var appDelegate + var body: some Scene { WindowGroup { - Text(SpeziNotifications().stanford) - Text(operatingSystem) + TabView { + Tab("Controls", systemImage: "switch.2") { + NavigationStack { + ControlsView() + } + } + + Tab("Notifications", systemImage: "mail") { + NavigationStack { + NotificationsView() + } + } + } + .spezi(appDelegate) } } } diff --git a/Tests/UITests/TestApp/TestAppDelegate.swift b/Tests/UITests/TestApp/TestAppDelegate.swift new file mode 100644 index 0000000..7278743 --- /dev/null +++ b/Tests/UITests/TestApp/TestAppDelegate.swift @@ -0,0 +1,19 @@ +// +// This source file is part of the SpeziNotifications open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import Spezi +import SpeziNotifications + + +final class TestAppDelegate: SpeziAppDelegate { + override var configuration: Configuration { + Configuration { + Notifications() + } + } +} diff --git a/Tests/UITests/TestAppUITests/NotificationAuthorizationTests.swift b/Tests/UITests/TestAppUITests/NotificationAuthorizationTests.swift new file mode 100644 index 0000000..6460182 --- /dev/null +++ b/Tests/UITests/TestAppUITests/NotificationAuthorizationTests.swift @@ -0,0 +1,53 @@ +// +// 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 XCTest +import XCTestExtensions +import XCTSpeziNotifications + + +final class NotificationAuthorizationTests: XCTestCase { + @MainActor + override func setUp() async throws { + continueAfterFailure = false + } + + @MainActor + func testNotificationAuthorizationAllow() { + let app = XCUIApplication() + app.deleteAndLaunch(withSpringboardAppName: "TestApp") + + XCTAssertTrue(app.wait(for: .runningForeground, timeout: 2.0)) + XCTAssert(app.navigationBars.staticTexts["Notifications"].waitForExistence(timeout: 2.0)) + + XCTAssert(app.staticTexts["Authorization, notDetermined"].exists) + XCTAssert(app.buttons["Request Authorization"].exists) + app.buttons["Request Authorization"].tap() + + app.confirmNotificationAuthorization() + + XCTAssert(app.staticTexts["Authorization, authorized"].waitForExistence(timeout: 0.5)) + } + + @MainActor + func testNotificationAuthorizationNotAllow() { + let app = XCUIApplication() + app.deleteAndLaunch(withSpringboardAppName: "TestApp") + + XCTAssertTrue(app.wait(for: .runningForeground, timeout: 2.0)) + XCTAssert(app.navigationBars.staticTexts["Notifications"].waitForExistence(timeout: 2.0)) + + XCTAssert(app.staticTexts["Authorization, notDetermined"].exists) + XCTAssert(app.buttons["Request Authorization"].exists) + app.buttons["Request Authorization"].tap() + + app.confirmNotificationAuthorization(action: .doNotAllow) + + XCTAssert(app.staticTexts["Authorization, denied"].waitForExistence(timeout: 0.5)) + } +} diff --git a/Tests/UITests/TestAppUITests/PendingNotificationsTests.swift b/Tests/UITests/TestAppUITests/PendingNotificationsTests.swift new file mode 100644 index 0000000..f8c1b70 --- /dev/null +++ b/Tests/UITests/TestAppUITests/PendingNotificationsTests.swift @@ -0,0 +1,73 @@ +// +// 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 XCTest +import XCTestExtensions +import XCTSpeziNotifications + + +final class PendingNotificationsTests: XCTestCase { + @MainActor + override func setUp() async throws { + continueAfterFailure = false + } + + @MainActor + func testPendingNotifications() { + let app = XCUIApplication() + app.deleteAndLaunch(withSpringboardAppName: "TestApp") + + XCTAssertTrue(app.wait(for: .runningForeground, timeout: 2.0)) + XCTAssert(app.navigationBars.staticTexts["Notifications"].waitForExistence(timeout: 2.0)) + + XCTAssert(app.staticTexts["Authorization, notDetermined"].exists) + XCTAssert(app.buttons["Schedule Notifications"].exists) + app.buttons["Schedule Notifications"].tap() + + XCTAssert(app.staticTexts["Authorization, provisional"].waitForExistence(timeout: 0.5)) + + XCTAssert(app.tabBars.buttons["Notifications"].exists) + app.tabBars.buttons["Notifications"].tap() + + XCTAssert(app.navigationBars.staticTexts["Pending Notifications"].waitForExistence(timeout: 2.0)) + + XCTAssert(app.staticTexts["Calendar Notification"].exists) + XCTAssert(app.staticTexts["Interval Notification"].exists) + + app.staticTexts["Calendar Notification"].tap() + XCTAssert(app.navigationBars.staticTexts["Calendar Notification"].waitForExistence(timeout: 2.0)) + app.assertNotificationDetails( + identifier: "calendar-request", + title: "Calendar Notification", + subtitle: "Test Notification", + body: "This is a calendar notification", + category: "calendar-test-notification", + thread: "SpeziNotifications", + sound: true, + interruption: .timeSensitive, + type: "Calendar" + ) + XCTAssert(app.navigationBars.buttons["Pending Notifications"].exists) + app.navigationBars.buttons["Pending Notifications"].tap() + + XCTAssert(app.staticTexts["Interval Notification"].waitForExistence(timeout: 2.0)) + app.staticTexts["Interval Notification"].tap() + XCTAssert(app.navigationBars.staticTexts["Interval Notification"].waitForExistence(timeout: 2.0)) + app.assertNotificationDetails( + identifier: "interval-request", + title: "Interval Notification", + subtitle: "Test Notification", + body: "This is a interval notification", + category: "interval-test-notification", + thread: "SpeziNotifications", + sound: true, + interruption: .critical, + type: "Interval" + ) + } +} diff --git a/Tests/UITests/TestAppUITests/RemoteNotificationsTests.swift b/Tests/UITests/TestAppUITests/RemoteNotificationsTests.swift new file mode 100644 index 0000000..7fa7c98 --- /dev/null +++ b/Tests/UITests/TestAppUITests/RemoteNotificationsTests.swift @@ -0,0 +1,44 @@ +// +// 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 XCTest + + +final class RemoteNotificationsTests: XCTestCase { + @MainActor + override func setUp() async throws { + continueAfterFailure = false + } + + @MainActor + func testRegistrationOnSimulator() throws { + let app = XCUIApplication() + app.launch() + + XCTAssertTrue(app.wait(for: .runningForeground, timeout: 2.0)) + + XCTAssertTrue(app.navigationBars.staticTexts["Notifications"].waitForExistence(timeout: 2.0)) + XCTAssertTrue(app.staticTexts["Token, none"].exists) + XCTAssertTrue(app.buttons["Register"].exists) + XCTAssertTrue(app.buttons["Unregister"].exists) + + app.buttons["Register"].tap() + + if !(app.staticTexts["Token, 80 bytes"].waitForExistence(timeout: 1.0) + || app.staticTexts["Token, 60 bytes"].exists) { + XCTAssertFalse(app.staticTexts["Token, failed"].exists) + XCTAssertTrue(app.staticTexts["Token, Timeout"].waitForExistence(timeout: 10)) + } + + // the unit test accepts both success and failure states. Therefore, print the content of the field to have it visible in the logs + print("Read token field as: \(app.staticTexts.matching(identifier: "token-field").firstMatch.debugDescription)") + + app.buttons["Unregister"].tap() + XCTAssertTrue(app.staticTexts["Token, none"].waitForExistence(timeout: 1.0)) + } +} diff --git a/Tests/UITests/TestAppUITests/TestAppUITests.swift b/Tests/UITests/TestAppUITests/TestAppUITests.swift deleted file mode 100644 index 9ffc473..0000000 --- a/Tests/UITests/TestAppUITests/TestAppUITests.swift +++ /dev/null @@ -1,28 +0,0 @@ -// -// This source file is part of the SpeziNotifications open-source project -// -// SPDX-FileCopyrightText: 2022 Stanford University and the project authors (see CONTRIBUTORS.md) -// -// SPDX-License-Identifier: MIT -// - -import OSLog -import XCTest - - -class TestAppUITests: XCTestCase { - override func setUpWithError() throws { - try super.setUpWithError() - - continueAfterFailure = false - } - - - func testSpeziNotifications() throws { - let app = XCUIApplication() - app.launch() - - XCTAssert(app.staticTexts["Stanford University"].waitForExistence(timeout: 0.1)) - XCTAssert(app.staticTexts[operatingSystem].exists) - } -} diff --git a/Tests/UITests/TestAppWatchApp.xctestplan b/Tests/UITests/TestAppWatchApp.xctestplan deleted file mode 100644 index 044ccd6..0000000 --- a/Tests/UITests/TestAppWatchApp.xctestplan +++ /dev/null @@ -1,37 +0,0 @@ -{ - "configurations" : [ - { - "id" : "B8537494-39D3-45EC-98D4-B3C417844ADD", - "name" : "Default", - "options" : { - - } - } - ], - "defaultOptions" : { - "codeCoverage" : { - "targets" : [ - { - "containerPath" : "container:..\/..", - "identifier" : "SpeziNotifications", - "name" : "SpeziNotifications" - } - ] - }, - "targetForVariableExpansion" : { - "containerPath" : "container:UITests.xcodeproj", - "identifier" : "2F9CBEA52A76C40E009818FF", - "name" : "TestAppWatchApp" - } - }, - "testTargets" : [ - { - "target" : { - "containerPath" : "container:UITests.xcodeproj", - "identifier" : "2F9CBEBE2A76C412009818FF", - "name" : "TestAppWatchAppUITests" - } - } - ], - "version" : 1 -} diff --git a/Tests/UITests/UITests.xcodeproj/project.pbxproj b/Tests/UITests/UITests.xcodeproj/project.pbxproj index e4d66ee..29eb495 100644 --- a/Tests/UITests/UITests.xcodeproj/project.pbxproj +++ b/Tests/UITests/UITests.xcodeproj/project.pbxproj @@ -9,10 +9,15 @@ /* Begin PBXBuildFile section */ 2F68C3C8292EA52000B3E12C /* SpeziNotifications in Frameworks */ = {isa = PBXBuildFile; productRef = 2F68C3C7292EA52000B3E12C /* SpeziNotifications */; }; 2F6D139A28F5F386007C25D6 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 2F6D139928F5F386007C25D6 /* Assets.xcassets */; }; - 2F8A431329130A8C005D2B8F /* TestAppUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F8A431229130A8C005D2B8F /* TestAppUITests.swift */; }; + 2F8A431329130A8C005D2B8F /* RemoteNotificationsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F8A431229130A8C005D2B8F /* RemoteNotificationsTests.swift */; }; 2FA7382C290ADFAA007ACEB9 /* TestApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FA7382B290ADFAA007ACEB9 /* TestApp.swift */; }; - 2FB5B6E02C2F6C50009162E6 /* OperatingSystem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FB5B6DF2C2F6C50009162E6 /* OperatingSystem.swift */; }; - 2FB5B6E22C2F707A009162E6 /* OperatingSystem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FB5B6DF2C2F6C50009162E6 /* OperatingSystem.swift */; }; + A902FE552CAAB75800C80383 /* TestAppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = A902FE542CAAB75800C80383 /* TestAppDelegate.swift */; }; + A902FE572CAAC04300C80383 /* PendingNotificationsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A902FE562CAAC03800C80383 /* PendingNotificationsTests.swift */; }; + A95C45EB2CAAB34600EBB08D /* XCTSpeziNotificationsUI in Frameworks */ = {isa = PBXBuildFile; productRef = A95C45EA2CAAB34600EBB08D /* XCTSpeziNotificationsUI */; }; + A9E0315E2CAAA8F600E13BD9 /* ControlsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9E0315D2CAAA8F400E13BD9 /* ControlsView.swift */; }; + A9E031602CAAAA9F00E13BD9 /* NotificationAuthorizationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9E0315F2CAAAA8B00E13BD9 /* NotificationAuthorizationTests.swift */; }; + A9E031662CAAACF900E13BD9 /* XCTestExtensions in Frameworks */ = {isa = PBXBuildFile; productRef = A9E031652CAAACF900E13BD9 /* XCTestExtensions */; }; + A9E031682CAAAF6800E13BD9 /* XCTSpeziNotifications in Frameworks */ = {isa = PBXBuildFile; productRef = A9E031672CAAAF6800E13BD9 /* XCTSpeziNotifications */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -43,11 +48,14 @@ 2F6D139228F5F384007C25D6 /* TestApp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = TestApp.app; sourceTree = BUILT_PRODUCTS_DIR; }; 2F6D139928F5F386007C25D6 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 2F6D13AC28F5F386007C25D6 /* TestAppUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = TestAppUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; - 2F8A431229130A8C005D2B8F /* TestAppUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestAppUITests.swift; sourceTree = ""; }; + 2F8A431229130A8C005D2B8F /* RemoteNotificationsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteNotificationsTests.swift; sourceTree = ""; }; 2FA7382B290ADFAA007ACEB9 /* TestApp.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TestApp.swift; sourceTree = ""; }; 2FB0758A299DDB9000C0B37F /* TestApp.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = TestApp.xctestplan; sourceTree = ""; }; - 2FB5B6DF2C2F6C50009162E6 /* OperatingSystem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OperatingSystem.swift; sourceTree = ""; }; - 2FF8922E2A770D4200903A5A /* TestAppWatchApp.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = TestAppWatchApp.xctestplan; sourceTree = ""; }; + A902FE542CAAB75800C80383 /* TestAppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestAppDelegate.swift; sourceTree = ""; }; + A902FE562CAAC03800C80383 /* PendingNotificationsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PendingNotificationsTests.swift; sourceTree = ""; }; + A902FE582CAAC63900C80383 /* TestApp.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = TestApp.entitlements; sourceTree = ""; }; + A9E0315D2CAAA8F400E13BD9 /* ControlsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ControlsView.swift; sourceTree = ""; }; + A9E0315F2CAAAA8B00E13BD9 /* NotificationAuthorizationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationAuthorizationTests.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -56,6 +64,7 @@ buildActionMask = 2147483647; files = ( 2F68C3C8292EA52000B3E12C /* SpeziNotifications in Frameworks */, + A95C45EB2CAAB34600EBB08D /* XCTSpeziNotificationsUI in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -63,6 +72,8 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + A9E031662CAAACF900E13BD9 /* XCTestExtensions in Frameworks */, + A9E031682CAAAF6800E13BD9 /* XCTSpeziNotifications in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -73,7 +84,6 @@ isa = PBXGroup; children = ( 2FB0758A299DDB9000C0B37F /* TestApp.xctestplan */, - 2FF8922E2A770D4200903A5A /* TestAppWatchApp.xctestplan */, 2F68C3C6292E9F8F00B3E12C /* SpeziNotifications */, 2F6D139428F5F384007C25D6 /* TestApp */, 2F6D13AF28F5F386007C25D6 /* TestAppUITests */, @@ -94,8 +104,10 @@ 2F6D139428F5F384007C25D6 /* TestApp */ = { isa = PBXGroup; children = ( + A902FE582CAAC63900C80383 /* TestApp.entitlements */, + A9E0315D2CAAA8F400E13BD9 /* ControlsView.swift */, 2FA7382B290ADFAA007ACEB9 /* TestApp.swift */, - 2FB5B6DF2C2F6C50009162E6 /* OperatingSystem.swift */, + A902FE542CAAB75800C80383 /* TestAppDelegate.swift */, 2F6D139928F5F386007C25D6 /* Assets.xcassets */, ); path = TestApp; @@ -104,7 +116,9 @@ 2F6D13AF28F5F386007C25D6 /* TestAppUITests */ = { isa = PBXGroup; children = ( - 2F8A431229130A8C005D2B8F /* TestAppUITests.swift */, + A902FE562CAAC03800C80383 /* PendingNotificationsTests.swift */, + A9E0315F2CAAAA8B00E13BD9 /* NotificationAuthorizationTests.swift */, + 2F8A431229130A8C005D2B8F /* RemoteNotificationsTests.swift */, ); path = TestAppUITests; sourceTree = ""; @@ -135,6 +149,7 @@ name = TestApp; packageProductDependencies = ( 2F68C3C7292EA52000B3E12C /* SpeziNotifications */, + A95C45EA2CAAB34600EBB08D /* XCTSpeziNotificationsUI */, ); productName = Example; productReference = 2F6D139228F5F384007C25D6 /* TestApp.app */; @@ -185,6 +200,9 @@ Base, ); mainGroup = 2F6D138928F5F384007C25D6; + packageReferences = ( + A9E031642CAAACF900E13BD9 /* XCRemoteSwiftPackageReference "XCTestExtensions" */, + ); preferredProjectObjectVersion = 77; productRefGroup = 2F6D139328F5F384007C25D6 /* Products */; projectDirPath = ""; @@ -219,8 +237,9 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + A9E0315E2CAAA8F600E13BD9 /* ControlsView.swift in Sources */, 2FA7382C290ADFAA007ACEB9 /* TestApp.swift in Sources */, - 2FB5B6E02C2F6C50009162E6 /* OperatingSystem.swift in Sources */, + A902FE552CAAB75800C80383 /* TestAppDelegate.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -228,8 +247,9 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 2FB5B6E22C2F707A009162E6 /* OperatingSystem.swift in Sources */, - 2F8A431329130A8C005D2B8F /* TestAppUITests.swift in Sources */, + A9E031602CAAAA9F00E13BD9 /* NotificationAuthorizationTests.swift in Sources */, + A902FE572CAAC04300C80383 /* PendingNotificationsTests.swift in Sources */, + 2F8A431329130A8C005D2B8F /* RemoteNotificationsTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -375,6 +395,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = TestApp/TestApp.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = ""; @@ -387,10 +408,12 @@ INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); + MACOSX_DEPLOYMENT_TARGET = 15.0; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = edu.stanford.spezinotifications.testapp; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -402,6 +425,8 @@ SWIFT_STRICT_CONCURRENCY = complete; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2,3,7"; + TVOS_DEPLOYMENT_TARGET = 18.0; + XROS_DEPLOYMENT_TARGET = 2.0; }; name = Debug; }; @@ -410,6 +435,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = TestApp/TestApp.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = ""; @@ -422,10 +448,12 @@ INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); + MACOSX_DEPLOYMENT_TARGET = 15.0; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = edu.stanford.spezinotifications.testapp; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -437,6 +465,8 @@ SWIFT_STRICT_CONCURRENCY = complete; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2,3,7"; + TVOS_DEPLOYMENT_TARGET = 18.0; + XROS_DEPLOYMENT_TARGET = 2.0; }; name = Release; }; @@ -447,6 +477,8 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = 637867499T; GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; + MACOSX_DEPLOYMENT_TARGET = 15.0; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = edu.stanford.spezinotifications.testapp.uitests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -459,6 +491,8 @@ SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2,3,7"; TEST_TARGET_NAME = TestApp; + TVOS_DEPLOYMENT_TARGET = 18.0; + XROS_DEPLOYMENT_TARGET = 2.0; }; name = Debug; }; @@ -469,6 +503,8 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = 637867499T; GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; + MACOSX_DEPLOYMENT_TARGET = 15.0; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = edu.stanford.spezinotifications.testapp.uitests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -481,6 +517,8 @@ SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2,3,7"; TEST_TARGET_NAME = TestApp; + TVOS_DEPLOYMENT_TARGET = 18.0; + XROS_DEPLOYMENT_TARGET = 2.0; }; name = Release; }; @@ -555,6 +593,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = TestApp/TestApp.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = ""; @@ -567,10 +606,12 @@ INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); + MACOSX_DEPLOYMENT_TARGET = 15.0; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = edu.stanford.spezinotifications.testapp; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -582,6 +623,8 @@ SWIFT_STRICT_CONCURRENCY = complete; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2,3,7"; + TVOS_DEPLOYMENT_TARGET = 18.0; + XROS_DEPLOYMENT_TARGET = 2.0; }; name = Test; }; @@ -592,6 +635,8 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = 637867499T; GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; + MACOSX_DEPLOYMENT_TARGET = 15.0; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = edu.stanford.spezinotifications.testapp.uitests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -604,6 +649,8 @@ SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2,3,7"; TEST_TARGET_NAME = TestApp; + TVOS_DEPLOYMENT_TARGET = 18.0; + XROS_DEPLOYMENT_TARGET = 2.0; }; name = Test; }; @@ -642,11 +689,35 @@ }; /* End XCConfigurationList section */ +/* Begin XCRemoteSwiftPackageReference section */ + A9E031642CAAACF900E13BD9 /* XCRemoteSwiftPackageReference "XCTestExtensions" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/StanfordBDHG/XCTestExtensions.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 1.0.0; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + /* Begin XCSwiftPackageProductDependency section */ 2F68C3C7292EA52000B3E12C /* SpeziNotifications */ = { isa = XCSwiftPackageProductDependency; productName = SpeziNotifications; }; + A95C45EA2CAAB34600EBB08D /* XCTSpeziNotificationsUI */ = { + isa = XCSwiftPackageProductDependency; + productName = XCTSpeziNotificationsUI; + }; + A9E031652CAAACF900E13BD9 /* XCTestExtensions */ = { + isa = XCSwiftPackageProductDependency; + package = A9E031642CAAACF900E13BD9 /* XCRemoteSwiftPackageReference "XCTestExtensions" */; + productName = XCTestExtensions; + }; + A9E031672CAAAF6800E13BD9 /* XCTSpeziNotifications */ = { + isa = XCSwiftPackageProductDependency; + productName = XCTSpeziNotifications; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = 2F6D138A28F5F384007C25D6 /* Project object */;