Skip to content
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 @@ -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
Expand Down
172 changes: 116 additions & 56 deletions Sources/NotificationManager/NotificationManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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() }
Expand Down Expand Up @@ -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.
Expand All @@ -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)
}
}

Expand Down Expand Up @@ -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)
}
}

Expand Down Expand Up @@ -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
}
Expand All @@ -279,22 +290,24 @@ 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

/// 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.
Expand Down Expand Up @@ -332,42 +345,55 @@ 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.
public static func removeAllDeliveredNotificationRequests() {
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.
Expand Down Expand Up @@ -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<Void, Never>?

@discardableResult
func enqueue<Value>(_ operation: @escaping () async -> Value) -> Task<Value, Never> {
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<Value>(_ operation: @escaping () async throws -> Value) -> Task<Value, Error> {
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
}
}
Loading
Loading