diff --git a/Sources/NotificationManager/NotificationManager.docc/NotificationManager.md b/Sources/NotificationManager/NotificationManager.docc/NotificationManager.md index a88aabc..9a2e06d 100644 --- a/Sources/NotificationManager/NotificationManager.docc/NotificationManager.md +++ b/Sources/NotificationManager/NotificationManager.docc/NotificationManager.md @@ -36,6 +36,14 @@ validation and notification-center errors observable. Non-async fire-and-forget overloads are available for compatibility, but handle errors by printing them from an asynchronous task. +Pending scheduling, replacement, queries, and removal share an internal queue. +Synchronous calls register their operations before returning, so a subsequent +removal waits for earlier additions to finish. Removal methods enqueue work and +return without blocking. Await ``NotificationManager/getPendingNotificationRequests()`` +to observe the state after earlier operations complete. Concurrent calls are +ordered at queue registration; direct system notification-center calls are outside +this guarantee. + ## Topics ### Authorization diff --git a/Sources/NotificationManager/NotificationManager.swift b/Sources/NotificationManager/NotificationManager.swift index a561644..e972710 100644 --- a/Sources/NotificationManager/NotificationManager.swift +++ b/Sources/NotificationManager/NotificationManager.swift @@ -51,6 +51,7 @@ extension NotificationManagerError: LocalizedError { /// Manages local notification authorization, scheduling, querying, and removal. public struct NotificationManager { private static let defaultAuthorizationOptions: UNAuthorizationOptions = [.alert, .sound, .badge] + private static let pendingOperations = PendingNotificationOperations() private static var centerOverride: (any UserNotificationCenter)? static var center: any UserNotificationCenter { get { centerOverride ?? UNUserNotificationCenter.current() } @@ -137,18 +138,15 @@ public struct NotificationManager { body: String, triggerDate: Date ) async throws { - let timeInterval = triggerDate.timeIntervalSinceNow - guard timeInterval > 0 else { - throw NotificationManagerError.triggerDateMustBeInFuture - } - - try await scheduleNotification( - id: id, - title: title, - body: body, - timeInterval: timeInterval, - repeats: false - ) + let notificationCenter = center + try await pendingOperations.enqueue { + let interval = triggerDate.timeIntervalSinceNow + guard interval > 0 else { + throw NotificationManagerError.triggerDateMustBeInFuture + } + let request = try makeRequest(id: id, title: title, body: body, timeInterval: interval, repeats: false) + try await notificationCenter.addNotificationRequest(request) + }.value } /// Schedules a notification for a future date without waiting for completion. @@ -160,12 +158,12 @@ public struct NotificationManager { /// - Note: Validation and scheduling errors are printed instead of returned /// to the caller. public static func scheduleNotification(id: String, title: String, body: String, triggerDate: Date) { - Task { - do { - try await scheduleNotification(id: id, title: title, body: body, triggerDate: triggerDate) - } catch { - print("Error: " + error.localizedDescription) + scheduleWithoutWaiting { + let interval = triggerDate.timeIntervalSinceNow + guard interval > 0 else { + throw NotificationManagerError.triggerDateMustBeInFuture } + return try makeRequest(id: id, title: title, body: body, timeInterval: interval, repeats: false) } } @@ -201,12 +199,8 @@ public struct NotificationManager { /// - Note: Validation and scheduling errors are printed instead of returned /// to the caller. public static func scheduleNotification(id: String, title: String, body: String, timeInterval: Int) { - Task { - do { - try await scheduleNotification(id: id, title: title, body: body, timeInterval: timeInterval) - } catch { - print("Error: " + error.localizedDescription) - } + scheduleWithoutWaiting { + try makeRequest(id: id, title: title, body: body, timeInterval: TimeInterval(timeInterval), repeats: false) } } @@ -245,27 +239,44 @@ public struct NotificationManager { /// - Note: Validation and scheduling errors are printed instead of returned /// to the caller. public static func scheduleRepeatNotification(id: String, title: String, body: String, timeInterval: Int) { - Task { + scheduleWithoutWaiting { + try makeRequest(id: id, title: title, body: body, timeInterval: TimeInterval(timeInterval), repeats: true) + } + } + + private static func scheduleNotification( + id: String, + title: String, + body: String, + timeInterval: TimeInterval, + repeats: Bool + ) async throws { + let request = try makeRequest(id: id, title: title, body: body, timeInterval: timeInterval, repeats: repeats) + let notificationCenter = center + try await pendingOperations.enqueue { + try await notificationCenter.addNotificationRequest(request) + }.value + } + + private static func scheduleWithoutWaiting(_ makeRequest: @escaping () throws -> UNNotificationRequest) { + let notificationCenter = center + pendingOperations.enqueue { do { - try await scheduleRepeatNotification( - id: id, - title: title, - body: body, - timeInterval: timeInterval - ) + let request = try makeRequest() + try await notificationCenter.addNotificationRequest(request) } catch { print("Error: " + error.localizedDescription) } } } - private static func scheduleNotification( + private static func makeRequest( id: String, title: String, body: String, timeInterval: TimeInterval, repeats: Bool - ) async throws { + ) throws -> UNNotificationRequest { guard timeInterval > 0 else { throw NotificationManagerError.invalidTimeInterval } @@ -279,8 +290,7 @@ public struct NotificationManager { content.sound = .default let trigger = UNTimeIntervalNotificationTrigger(timeInterval: timeInterval, repeats: repeats) - let request = UNNotificationRequest(identifier: id, content: content, trigger: trigger) - try await center.addNotificationRequest(request) + return UNNotificationRequest(identifier: id, content: content, trigger: trigger) } // MARK: Fetch @@ -288,13 +298,16 @@ public struct NotificationManager { /// Fetches all pending local notification requests. /// - Returns: The requests that are scheduled and awaiting delivery. public static func getPendingNotificationRequests() async -> [UNNotificationRequest] { - await center.pendingNotificationRequests() + let notificationCenter = center + return await pendingOperations.enqueue { + await notificationCenter.pendingNotificationRequests() + }.value } /// Fetches the identifiers of all pending local notification requests. /// - Returns: The identifiers of requests that are awaiting delivery. public static func getPendingNotificationRequestIDs() async -> [String] { - await center.pendingNotificationRequests().map(\.identifier) + await getPendingNotificationRequests().map(\.identifier) } /// Fetches the identifiers of all pending local notification requests. @@ -332,31 +345,39 @@ public struct NotificationManager { newBody: String, newDate: Date ) async throws { - let requests = await center.pendingNotificationRequests() - guard requests.contains(where: { $0.identifier == id }) else { - return - } + let notificationCenter = center + try await pendingOperations.enqueue { + let requests = await notificationCenter.pendingNotificationRequests() + guard requests.contains(where: { $0.identifier == id }) else { + return + } - let timeInterval = newDate.timeIntervalSinceNow - guard timeInterval > 0 else { - throw NotificationManagerError.triggerDateMustBeInFuture - } + let timeInterval = newDate.timeIntervalSinceNow + guard timeInterval > 0 else { + throw NotificationManagerError.triggerDateMustBeInFuture + } - // Adding a request with an existing identifier atomically replaces the old request. - try await scheduleNotification( - id: id, - title: newTitle, - body: newBody, - timeInterval: timeInterval, - repeats: false - ) + let request = try makeRequest( + id: id, + title: newTitle, + body: newBody, + timeInterval: timeInterval, + repeats: false + ) + try await notificationCenter.addNotificationRequest(request) + }.value } // MARK: Remove - /// Removes all pending notifications. + /// Enqueues removal of all pending notifications after earlier pending operations. + /// - Note: Returns without waiting. Await ``getPendingNotificationRequests()`` + /// to observe the state after removal. public static func removeAllPendingNotificationRequests() { - center.removeAllPendingNotificationRequests() + let notificationCenter = center + pendingOperations.enqueue { + notificationCenter.removeAllPendingNotificationRequests() + } } /// Removes all delivered notifications. @@ -364,10 +385,15 @@ public struct NotificationManager { center.removeAllDeliveredNotifications() } - /// Removes pending notifications with the supplied identifiers. + /// Enqueues removal of pending notifications after earlier pending operations. /// - Parameter ids: The identifiers of pending requests to remove. + /// - Note: Returns without waiting. Await ``getPendingNotificationRequests()`` + /// to observe the state after removal. public static func removePendingNotificationRequests(ids: [String]) { - center.removePendingNotificationRequests(withIdentifiers: ids) + let notificationCenter = center + pendingOperations.enqueue { + notificationCenter.removePendingNotificationRequests(withIdentifiers: ids) + } } /// Removes delivered notifications with the supplied identifiers. @@ -422,3 +448,37 @@ public struct NotificationManager { } } } + +/// Registers operations synchronously, then waits for each predecessor to finish. +/// A serial task chain also preserves ordering across suspension points. +private final class PendingNotificationOperations { + private let lock = NSLock() + private var tail: Task? + + @discardableResult + func enqueue(_ operation: @escaping () async -> Value) -> Task { + lock.lock() + defer { lock.unlock() } + let previous = tail + let task = Task { + await previous?.value + return await operation() + } + tail = Task { _ = await task.value } + return task + } + + @discardableResult + func enqueue(_ operation: @escaping () async throws -> Value) -> Task { + lock.lock() + defer { lock.unlock() } + let previous = tail + let task = Task { + await previous?.value + return try await operation() + } + // Failure must not prevent later operations (especially removals). + tail = Task { _ = try? await task.value } + return task + } +} diff --git a/Tests/NotificationManagerTests/NotificationManagerTests.swift b/Tests/NotificationManagerTests/NotificationManagerTests.swift index 0acacb7..b13509f 100644 --- a/Tests/NotificationManagerTests/NotificationManagerTests.swift +++ b/Tests/NotificationManagerTests/NotificationManagerTests.swift @@ -18,6 +18,104 @@ final class NotificationManagerTests: XCTestCase { super.tearDown() } + func testSynchronousSchedulingThenRemovalLeavesNoPendingRequest() async { + for kind in [SynchronousScheduleKind.interval, .date, .repeating] { + scheduleSynchronously(kind, id: "request-id") + NotificationManager.removePendingNotificationRequests(ids: ["request-id"]) + + let requests = await NotificationManager.getPendingNotificationRequests() + XCTAssertTrue(requests.isEmpty) + } + XCTAssertEqual(center.addedRequests.count, 3) + } + + func testRemovalWaitsForSuspendedAddToFinish() async { + let started = expectation(description: "Add started") + let gate = AddGate(started: started) + center.beforeAdd = { await gate.wait() } + scheduleSynchronously(.interval, id: "request-id") + await fulfillment(of: [started], timeout: 2) + + NotificationManager.removePendingNotificationRequests(ids: ["request-id"]) + await gate.release() + + let requests = await NotificationManager.getPendingNotificationRequests() + XCTAssertTrue(requests.isEmpty) + XCTAssertEqual(center.addedRequests.count, 1) + } + + func testRemoveAllFollowsEarlierSchedules() async { + scheduleSynchronously(.interval, id: "first") + scheduleSynchronously(.repeating, id: "second") + NotificationManager.removeAllPendingNotificationRequests() + + let requests = await NotificationManager.getPendingNotificationRequests() + XCTAssertTrue(requests.isEmpty) + XCTAssertEqual(center.addedRequests.count, 2) + } + + func testScopedRemovalPreservesOtherAndLaterRequests() async { + scheduleSynchronously(.interval, id: "other") + scheduleSynchronously(.interval, id: "request-id") + NotificationManager.removePendingNotificationRequests(ids: ["request-id"]) + scheduleSynchronously(.date, id: "request-id") + + let requests = await NotificationManager.getPendingNotificationRequests() + XCTAssertEqual(Set(requests.map(\.identifier)), ["other", "request-id"]) + XCTAssertEqual(center.addedRequests.count, 3) + } + + func testFailedAddDoesNotPreventLaterRemoval() async { + center.addError = TestError.addFailed + scheduleSynchronously(.interval, id: "request-id") + NotificationManager.removeAllPendingNotificationRequests() + + let requests = await NotificationManager.getPendingNotificationRequests() + XCTAssertTrue(requests.isEmpty) + XCTAssertTrue(center.removedAllPendingRequests) + } + + func testThrowingAddDoesNotBlockQueueAfterFailure() async { + center.addError = TestError.addFailed + await XCTAssertThrowsErrorAsync( + try await NotificationManager.scheduleNotification( + id: "request-id", title: "Title", body: "Body", timeInterval: 10 + ) + ) { error in + XCTAssertEqual(error as? TestError, .addFailed) + } + NotificationManager.removeAllPendingNotificationRequests() + + _ = await NotificationManager.getPendingNotificationRequests() + XCTAssertTrue(center.removedAllPendingRequests) + } + + func testReplacementSeesEarlierSynchronousSchedule() async throws { + scheduleSynchronously(.interval, id: "request-id") + try await NotificationManager.replaceNotificationRequestFromId( + id: "request-id", newTitle: "Replacement", newBody: "Body", + newDate: Date().addingTimeInterval(120) + ) + + let requests = await NotificationManager.getPendingNotificationRequests() + XCTAssertEqual(requests.count, 1) + XCTAssertEqual(requests.first?.content.title, "Replacement") + } + + // Keep overload selection in a synchronous context. + private func scheduleSynchronously(_ kind: SynchronousScheduleKind, id: String) { + switch kind { + case .interval: + NotificationManager.scheduleNotification(id: id, title: "Title", body: "Body", timeInterval: 10) + case .date: + NotificationManager.scheduleNotification( + id: id, title: "Title", body: "Body", triggerDate: Date().addingTimeInterval(120) + ) + case .repeating: + NotificationManager.scheduleRepeatNotification(id: id, title: "Title", body: "Body", timeInterval: 60) + } + } + func testDefaultAuthorizationRequestsOnlyAlertSoundAndBadge() async throws { center.authorizationGranted = true @@ -215,6 +313,7 @@ private final class TestNotificationCenter: UserNotificationCenter { var requestedAuthorizationOptions: UNAuthorizationOptions? var addedRequests: [UNNotificationRequest] = [] var addError: Error? + var beforeAdd: (() async -> Void)? var pendingRequests: [UNNotificationRequest] = [] var deliveredNotificationValues: [UNNotification] = [] var removedIdentifierGroups: [[String]] = [] @@ -233,10 +332,13 @@ private final class TestNotificationCenter: UserNotificationCenter { } func addNotificationRequest(_ request: UNNotificationRequest) async throws { + await beforeAdd?() if let addError { throw addError } addedRequests.append(request) + pendingRequests.removeAll { $0.identifier == request.identifier } + pendingRequests.append(request) } func pendingNotificationRequests() async -> [UNNotificationRequest] { @@ -249,6 +351,7 @@ private final class TestNotificationCenter: UserNotificationCenter { func removeAllPendingNotificationRequests() { removedAllPendingRequests = true + pendingRequests.removeAll() } func removeAllDeliveredNotifications() { @@ -257,6 +360,7 @@ private final class TestNotificationCenter: UserNotificationCenter { func removePendingNotificationRequests(withIdentifiers identifiers: [String]) { removedIdentifierGroups.append(identifiers) + pendingRequests.removeAll { identifiers.contains($0.identifier) } } func removeDeliveredNotifications(withIdentifiers identifiers: [String]) { @@ -311,3 +415,28 @@ private func XCTAssertThrowsErrorAsync( errorHandler(error) } } + +private enum SynchronousScheduleKind { + case interval, date, repeating +} + +private actor AddGate { + let started: XCTestExpectation + private var continuation: CheckedContinuation? + + init(started: XCTestExpectation) { + self.started = started + } + + func wait() async { + await withCheckedContinuation { continuation in + self.continuation = continuation + started.fulfill() + } + } + + func release() { + continuation?.resume() + continuation = nil + } +}