Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix registerRemoteNotifications action would never return #115

Merged
merged 9 commits into from
Aug 26, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,25 @@
import SwiftUI


private final class RemoteNotificationContinuation: DefaultProvidingKnowledgeSource, Sendable {
@MainActor
private final class RemoteNotificationContinuation: KnowledgeSource, Sendable {
typealias Anchor = SpeziAnchor

static let defaultValue = RemoteNotificationContinuation()
fileprivate(set) var continuation: CheckedContinuation<Data, Error>?
fileprivate(set) var access = AsyncSemaphore()

@MainActor
var continuation: CheckedContinuation<Data, Error>?

init() {}


@MainActor
func resume(with result: Result<Data, Error>) {
if let continuation {
self.continuation = nil
access.signal()
continuation.resume(with: result)
}
}
}


Expand All @@ -33,24 +43,40 @@
///
/// 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
///
/// class ExampleModule: Module {
/// @Application(\.registerRemoteNotifications)
/// var registerRemoteNotifications
///
/// func handleNotificationsAllowed() async throws {
/// let deviceToken = try await registerRemoteNotifications()
/// func handleNotificationsPermissions() async throws {
/// // Make sure to request notifications permissions before registering for remote notifications
Supereg marked this conversation as resolved.
Show resolved Hide resolved
/// 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
/// }
/// }
/// ```
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
}

///
/// > Tip: Make sure to request authorization by calling [`requestAuthorization(options:completionHandler:)`](https://developer.apple.com/documentation/usernotifications/unusernotificationcenter/requestauthorization(options:completionhandler:))
/// to have your remote notifications be able to display alerts, badges or use sound. Otherwise, all remote notifications will be delivered silently.
public struct RegisterRemoteNotificationsAction: Sendable {
private weak var spezi: Spezi?

init(_ spezi: Spezi) {
Expand All @@ -65,28 +91,41 @@
/// 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
/// if your app is not properly configured for remote notifications. It might also throw a `TimeoutError` when running on a simulator device running on a host
/// that is not connected to an Apple ID.
@discardableResult
@MainActor
public func callAsFunction() async throws -> Data {
guard let spezi else {
preconditionFailure("RegisterRemoteNotificationsAction was used in a scope where Spezi was not available anymore!")
}


#if os(watchOS)
let application = _Application.shared()
#else
let application = _Application.shared
#endif
#endif // os(watchOS)

let registration: RemoteNotificationContinuation
if let existing = spezi.storage[RemoteNotificationContinuation.self] {
registration = existing

Check warning on line 112 in Sources/Spezi/Capabilities/Notifications/RegisterRemoteNotificationsAction.swift

View check run for this annotation

Codecov / codecov/patch

Sources/Spezi/Capabilities/Notifications/RegisterRemoteNotificationsAction.swift#L112

Added line #L112 was not covered by tests
} else {
registration = RemoteNotificationContinuation()
spezi.storage[RemoteNotificationContinuation.self] = registration
}

try await registration.access.waitCheckingCancellation()

let registration = spezi.storage[RemoteNotificationContinuation.self]
if registration.continuation != nil {
throw ActionError.concurrentAccess
#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.registerForRemoteNotifications()
}
Expand All @@ -100,20 +139,46 @@
/// 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
///
/// class ExampleModule: Module {
/// @Application(\.registerRemoteNotifications)
/// var registerRemoteNotifications
///
/// func handleNotificationsAllowed() async throws {
/// let deviceToken = try await 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])
///
///
/// 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 [`requestAuthorization(options:completionHandler:)`](https://developer.apple.com/documentation/usernotifications/unusernotificationcenter/requestauthorization(options:completionhandler:))
/// 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``
Expand All @@ -126,24 +191,26 @@
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.
guard let registration = spezi.storage[RemoteNotificationContinuation.self] else {
return
}
registration.continuation = nil
continuation.resume(returning: deviceToken)

// 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))
}

@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.")
guard let registration = spezi.storage[RemoteNotificationContinuation.self] else {
return
}
registration.continuation = nil
continuation.resume(throwing: error)

if registration.continuation == nil {
spezi.logger.warning("Received a call to \(#function) while we were not waiting for a notifications registration request.")

Check warning on line 211 in Sources/Spezi/Capabilities/Notifications/RegisterRemoteNotificationsAction.swift

View check run for this annotation

Codecov / codecov/patch

Sources/Spezi/Capabilities/Notifications/RegisterRemoteNotificationsAction.swift#L211

Added line #L211 was not covered by tests
}

registration.resume(with: .failure(error))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ import SwiftUI
/// }
/// }
/// ```
public struct UnregisterRemoteNotificationsAction {
public struct UnregisterRemoteNotificationsAction: Sendable {
init() {}


Expand Down
8 changes: 6 additions & 2 deletions Sources/Spezi/Spezi/Spezi.swift
Original file line number Diff line number Diff line change
Expand Up @@ -79,12 +79,16 @@
/// - ``launchOptions``
/// - ``spezi``
///
/// ### Actions
/// ### Remote Notifications
/// - ``registerRemoteNotifications``
/// - ``unregisterRemoteNotifications``
///
/// ### Dynamically Loading Modules
/// - ``loadModule(_:ownership:)``
/// - ``unloadModule(_:)``
@Observable
public final class Spezi: Sendable {
static let logger = Logger(subsystem: "edu.stanford.spezi", category: "Spezi")

Check warning on line 91 in Sources/Spezi/Spezi/Spezi.swift

View workflow job for this annotation

GitHub Actions / Build and Test Swift Package iOS / Test using xcodebuild or run fastlane

static property 'logger' is not concurrency-safe because it is not either conforming to 'Sendable' or isolated to a global actor; this is an error in Swift 6

Check warning on line 91 in Sources/Spezi/Spezi/Spezi.swift

View workflow job for this annotation

GitHub Actions / Build and Test Swift Package iOS / Test using xcodebuild or run fastlane

static property 'logger' is not concurrency-safe because it is not either conforming to 'Sendable' or isolated to a global actor; this is an error in Swift 6

Check warning on line 91 in Sources/Spezi/Spezi/Spezi.swift

View workflow job for this annotation

GitHub Actions / Build and Test Swift Package macOS / Test using xcodebuild or run fastlane

static property 'logger' is not concurrency-safe because it is not either conforming to 'Sendable' or isolated to a global actor; this is an error in Swift 6

Check warning on line 91 in Sources/Spezi/Spezi/Spezi.swift

View workflow job for this annotation

GitHub Actions / Build and Test Swift Package visionOS / Test using xcodebuild or run fastlane

static property 'logger' is not concurrency-safe because it is not either conforming to 'Sendable' or isolated to a global actor; this is an error in Swift 6

Check warning on line 91 in Sources/Spezi/Spezi/Spezi.swift

View workflow job for this annotation

GitHub Actions / Build and Test Swift Package tvOS / Test using xcodebuild or run fastlane

static property 'logger' is not concurrency-safe because it is not either conforming to 'Sendable' or isolated to a global actor; this is an error in Swift 6

Check warning on line 91 in Sources/Spezi/Spezi/Spezi.swift

View workflow job for this annotation

GitHub Actions / Build and Test Swift Package watchOS / Test using xcodebuild or run fastlane

static property 'logger' is not concurrency-safe because it is not either conforming to 'Sendable' or isolated to a global actor; this is an error in Swift 6

Check warning on line 91 in Sources/Spezi/Spezi/Spezi.swift

View workflow job for this annotation

GitHub Actions / Build and Test Swift Package watchOS / Test using xcodebuild or run fastlane

static property 'logger' is not concurrency-safe because it is not either conforming to 'Sendable' or isolated to a global actor; this is an error in Swift 6

Check warning on line 91 in Sources/Spezi/Spezi/Spezi.swift

View workflow job for this annotation

GitHub Actions / Build and Test UI Tests iOS / Test using xcodebuild or run fastlane

static property 'logger' is not concurrency-safe because it is not either conforming to 'Sendable' or isolated to a global actor; this is an error in Swift 6

Check warning on line 91 in Sources/Spezi/Spezi/Spezi.swift

View workflow job for this annotation

GitHub Actions / Build and Test UI Tests visionOS / Test using xcodebuild or run fastlane

static property 'logger' is not concurrency-safe because it is not either conforming to 'Sendable' or isolated to a global actor; this is an error in Swift 6

@TaskLocal static var moduleInitContext: ModuleDescription?

Expand All @@ -93,7 +97,7 @@
/// 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) nonisolated(unsafe) var storage: SpeziStorage // nonisolated, writes are all isolated to @MainActor, just reads are non-isolated
nonisolated(unsafe) var storage: SpeziStorage // nonisolated, writes are all isolated to @MainActor, just reads are non-isolated

/// Key is either a UUID for `@Modifier` or `@Model` property wrappers, or a `ModuleReference` for `EnvironmentAccessible` modifiers.
@MainActor private var _viewModifiers: OrderedDictionary<AnyHashable, any ViewModifier> = [:]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import XCTSpezi
struct LifecycleHandlerTestsView: View {
@Environment(LifecycleHandlerModel.self)
var model


var body: some View {
VStack {
Expand Down
19 changes: 19 additions & 0 deletions Tests/UITests/TestApp/RemoteNotifications/NotificationModule.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
//
// 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


@MainActor
class NotificationModule: Module, EnvironmentAccessible {
@Application(\.registerRemoteNotifications)
var registerRemoteNotifications

@Application(\.unregisterRemoteNotifications)
var unregisterRemoteNotifications
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
//
// 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


struct RemoteNotificationsTestView: View {
@Environment(NotificationModule.self)
private var notificationModule

@State private var token: Data?
@State private var error: Error?

@State private var task: Task<Void, Never>?

var body: some View {
List { // swiftlint:disable:this closure_body_length
Section("Token") {
HStack {
Text(verbatim: "Token")
Spacer()
if let token {
Text(token.description)
.foregroundStyle(.green)
} else if let error = error as? LocalizedError,
let description = error.errorDescription ?? error.failureReason {
Text(verbatim: description)
.foregroundStyle(.red)
} else if error != nil {
Text(verbatim: "failed")
.foregroundStyle(.red)
} else {
Text(verbatim: "none")
.foregroundStyle(.secondary)
}
}
.accessibilityElement(children: .combine)
.accessibilityIdentifier("token-field")
}

Section("Actions") {
Button("Register") {
task = Task { @MainActor in
do {
token = try await notificationModule.registerRemoteNotifications()
} catch {
self.error = error
}
}
}
Button("Unregister") {
notificationModule.unregisterRemoteNotifications()
token = nil
error = nil
}
}
}
.onDisappear {
task?.cancel()
}
}
}


#if DEBUG
#Preview {
RemoteNotificationsTestView()
.environment(NotificationModule())
}
#endif
1 change: 1 addition & 0 deletions Tests/UITests/TestApp/Shared/TestAppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ class TestAppDelegate: SpeziAppDelegate {
}
ModuleWithModifier()
ModuleWithModel()
NotificationModule()
}
}
}
5 changes: 4 additions & 1 deletion Tests/UITests/TestApp/SpeziTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ enum SpeziTests: String, TestAppTests {
case viewModifier = "ViewModifier"
case lifecycleHandler = "LifecycleHandler"
case model = "Model"

case notifications = "Remote Notifications"


func view(withNavigationPath path: Binding<NavigationPath>) -> some View {
switch self {
Expand All @@ -24,6 +25,8 @@ enum SpeziTests: String, TestAppTests {
LifecycleHandlerTestsView()
case .model:
ModelTestView()
case .notifications:
RemoteNotificationsTestView()
}
}
}
10 changes: 10 additions & 0 deletions Tests/UITests/TestApp/TestApp.entitlements
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>aps-environment</key>
<string>development</string>
<key>com.apple.developer.aps-environment</key>
<string>development</string>
</dict>
</plist>
5 changes: 5 additions & 0 deletions Tests/UITests/TestApp/TestApp.entitlements.license
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading