From 03860103294b669207f8c0e44a0f4e2d73e73882 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timo=20K=C3=B6the?= Date: Mon, 8 Jun 2026 16:30:09 +0200 Subject: [PATCH 1/3] refactor: NotificationManager to async API and tests Introduce an async, throwable API and dependency-injectable UNUserNotificationCenter: add UserNotificationCenter protocol, make NotificationManager use an overridable center, add NotificationManagerError validations (invalidTimeInterval, repeatingTimeIntervalTooShort, triggerDateMustBeInFuture), and provide async scheduling, replace, badge and fetch methods with synchronous fire-and-forget wrappers. Add comprehensive unit tests (TestNotificationCenter + tests) and remove the old empty authorization test. Update CI workflows to run a platform matrix with xcodebuild and PR triggers, rename test job, and add SDK skipping logic. Expand README with usage examples, badges and docs. Minor Package.swift formatting tweaks. --- .github/workflows/build.yml | 41 +- .github/workflows/test.yml | 13 +- Package.swift | 13 +- README.md | 242 ++++++---- .../NotificationManager.swift | 443 +++++++++++------- .../AuthorizationTests.swift | 10 - .../NotificationManagerTests.swift | 249 ++++++++++ 7 files changed, 717 insertions(+), 294 deletions(-) delete mode 100644 Tests/NotificationManagerTests/AuthorizationTests.swift create mode 100644 Tests/NotificationManagerTests/NotificationManagerTests.swift diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f67c6e3..608874d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,19 +1,40 @@ name: Build -on: - push: - branches: - - main - - development +on: + push: + branches: + - main + - development + pull_request: jobs: build: - name: Build + name: Build (${{ matrix.platform }}) runs-on: macos-latest + strategy: + fail-fast: false + matrix: + include: + - platform: macOS + destination: generic/platform=macOS + sdk: macosx + - platform: iOS + destination: generic/platform=iOS + sdk: iphoneos + - platform: visionOS + destination: generic/platform=visionOS + sdk: xros steps: - uses: actions/checkout@v4 - - uses: swift-actions/setup-swift@v2 - with: - swift-version: '5.9' - name: Build - run: swift build + run: | + if ! xcrun --sdk '${{ matrix.sdk }}' --show-sdk-path >/dev/null 2>&1; then + echo "::notice::Skipping ${{ matrix.platform }} because its SDK is not installed on this runner." + exit 0 + fi + + xcodebuild \ + -scheme NotificationManager \ + -destination '${{ matrix.destination }}' \ + CODE_SIGNING_ALLOWED=NO \ + build diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index be93c06..681aa50 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,13 +1,14 @@ name: Test -on: - push: - branches: - - main - - development +on: + push: + branches: + - main + - development + pull_request: jobs: - build: + test: name: Test runs-on: macos-latest steps: diff --git a/Package.swift b/Package.swift index dad255b..d73b8f3 100644 --- a/Package.swift +++ b/Package.swift @@ -7,19 +7,22 @@ let package = Package( name: "NotificationManager", platforms: [ .macOS(.v10_15), - .iOS(.v13), - .visionOS(.v1) + .iOS(.v13), + .visionOS(.v1) ], products: [ .library( name: "NotificationManager", - targets: ["NotificationManager"]), + targets: ["NotificationManager"] + ), ], targets: [ .target( - name: "NotificationManager"), + name: "NotificationManager" + ), .testTarget( name: "NotificationManagerTests", - dependencies: ["NotificationManager"]), + dependencies: ["NotificationManager"] + ), ] ) diff --git a/README.md b/README.md index 92faa61..5d72535 100644 --- a/README.md +++ b/README.md @@ -1,120 +1,200 @@ # NotificationManager + [![License: MIT](https://img.shields.io/badge/license-MIT-blue)](https://opensource.org/license/mit) [![Build](https://github.com/timokoethe/NotificationManager/actions/workflows/build.yml/badge.svg?branch=main)](https://github.com/timokoethe/NotificationManager/actions/workflows/build.yml) [![Test](https://github.com/timokoethe/NotificationManager/actions/workflows/test.yml/badge.svg?branch=main)](https://github.com/timokoethe/NotificationManager/actions/workflows/test.yml) -[![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Ftimokoethe%2FNotificationManager%2Fbadge%3Ftype%3Dswift-versions)](https://swiftpackageindex.com/timokoethe/NotificationManager) -[![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Ftimokoethe%2FNotificationManager%2Fbadge%3Ftype%3Dplatforms)](https://swiftpackageindex.com/timokoethe/NotificationManager) +[![Swift versions](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Ftimokoethe%2FNotificationManager%2Fbadge%3Ftype%3Dswift-versions)](https://swiftpackageindex.com/timokoethe/NotificationManager) +[![Platforms](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Ftimokoethe%2FNotificationManager%2Fbadge%3Ftype%3Dplatforms)](https://swiftpackageindex.com/timokoethe/NotificationManager) -NotificationManager is a Swift Package to make your code easier for managing local notifications. -This package is supposed to make it possible to manage notifications in a highly intuitive way. -It shall also appear as minimalistic as possible. +NotificationManager is a lightweight Swift package for requesting notification authorization and managing local notifications. ## Requirements -- Xcode 15.0+ + - Swift 5.9+ -- macOS 10.15+ +- Xcode 15.0+ - iOS 13.0+ +- macOS 10.15+ - visionOS 1.0+ ## Installation -1. Copy the resource url: -``` + +Add the package in Xcode using **File > Add Package Dependencies** and enter: + +```text https://github.com/timokoethe/NotificationManager.git ``` -2. Open your Xcode project. -3. Navigate to _File_ / _Add Package Dependency_. -4. Paste the resource url at the top right corner in _Search or Enter Package URL_. -5. Choose the right target under _Add to project_. -6. To complete hit _Add Package_. - -## Setup -1. Importing the Framework
-In any Swift file where you want to use NotificationManager, add the following import statement: -```import NotificationManager``` - -2. Request notification authorization
-Before your app can send notifications, you need to request permission from the user. This is typically done when the app first launches. Add the following code to your App struct or the place wherever you want to ask the user to permit: -``` -import SwiftUI -import UserNotifications + +Add `NotificationManager` to your target and import it: + +```swift import NotificationManager +``` + +## Authorization + +Request the standard alert, sound, and badge permissions at a point where the user understands why notifications are needed: -@main -struct YourApp: App { - var body: some Scene { - WindowGroup { - ContentView() - .onAppear { - requestNotificationAuthorization() - } - } +```swift +do { + let granted = try await NotificationManager.requestAuthorizationThrowable() + + if granted { + // Notifications are authorized. } +} catch { + // Handle the authorization error. } ``` -## Usage -- Scheduling a Notification
-Once you have authorization, you can schedule notifications. Here's an example of how to schedule a notification that should arrive after 10 seconds using NotificationManager by pushing a button: +To request custom options: + +```swift +import UserNotifications +let granted = try await NotificationManager.requestAuthorization( + for: [.alert, .sound, .badge, .provisional] +) ``` -import SwiftUI -import NotificationManager -struct ContentView: View { - var body: some View { - VStack { - Button("Schedule") { - NotificationManager.scheduleNotification(id: UUID().uuidString, title: "Title", body: "Body", triggerDate: Date()+10) - } - } - } -} +Read the current authorization status: + +```swift +let status = await NotificationManager.getAuthorizationStatus() ``` -- Getting pending notifications
-Once you have scheduled one or more notifications you can get all pending: +## Scheduling + +The asynchronous APIs validate their input and propagate errors from `UNUserNotificationCenter`. + +Schedule a notification after a time interval: + +```swift +try await NotificationManager.scheduleNotification( + id: UUID().uuidString, + title: "Reminder", + body: "Your task is due.", + timeInterval: 10 +) ``` -import SwiftUI -import NotificationManager -struct ContentView: View { - @State private var notifications = [UNNotificationRequest]() - var body: some View { - VStack { - Button("Get") { - Task { - notifications = await NotificationManager.getPendingNotificationRequests() - } - } - } - } -} +Schedule a notification for a date: + +```swift +let deliveryDate = Date().addingTimeInterval(60) + +try await NotificationManager.scheduleNotification( + id: "task-reminder", + title: "Reminder", + body: "Your task is due.", + triggerDate: deliveryDate +) ``` -- Removing all pending notifications
-After scheduling several notifications you can remove them easily: +Schedule a repeating notification: + +```swift +try await NotificationManager.scheduleRepeatNotification( + id: "hourly-reminder", + title: "Reminder", + body: "Take a short break.", + timeInterval: 3_600 +) ``` -import SwiftUI -import NotificationManager -struct ContentView: View { - var body: some View { - VStack { - Button("Remove") { - NotificationManager.removeAllPendingNotificationRequests() - } - } - } +Repeating notifications require an interval of at least 60 seconds. + +For compatibility, synchronous fire-and-forget overloads are also available. They print scheduling errors instead of returning them: + +```swift +NotificationManager.scheduleNotification( + id: "task-reminder", + title: "Reminder", + body: "Your task is due.", + timeInterval: 10 +) +``` + +## Error Handling + +Input validation uses `NotificationManagerError`: + +```swift +do { + try await NotificationManager.scheduleRepeatNotification( + id: "reminder", + title: "Reminder", + body: "This interval is too short.", + timeInterval: 30 + ) +} catch NotificationManagerError.repeatingTimeIntervalTooShort { + // Repeating intervals must be at least 60 seconds. +} catch { + // Handle errors from the notification center. } ``` +Available validation errors: + +- `invalidTimeInterval` +- `repeatingTimeIntervalTooShort` +- `triggerDateMustBeInFuture` + +## Pending Notifications + +Fetch all pending requests: + +```swift +import UserNotifications + +let requests: [UNNotificationRequest] = + await NotificationManager.getPendingNotificationRequests() +``` + +Fetch only their identifiers: + +```swift +let identifiers = await NotificationManager.getPendingNotificationRequestsIds() +``` + +## Replacing Notifications + +Replace a pending notification while keeping its identifier: + +```swift +try await NotificationManager.replaceNotificationRequestFromId( + id: "task-reminder", + newTitle: "Updated reminder", + newBody: "The task deadline has changed.", + newDate: Date().addingTimeInterval(300) +) +``` + +If no pending request has the supplied identifier, the method returns without making changes. + +## Removing Notifications + +```swift +NotificationManager.removePendingNotificationRequests( + ids: ["task-reminder", "hourly-reminder"] +) + +NotificationManager.removeAllPendingNotificationRequests() +NotificationManager.removeAllDeliveredNotificationRequests() +``` + +## Badge Count + +Badge updates are available on iOS 16+, macOS 13+, and visionOS 1+: + +```swift +try await NotificationManager.setBadge(badge: 3) +try await NotificationManager.resetBadge() +``` + ## Contributing -We welcome contributions from the community to help improve NotificationManager. If you encounter any bugs, have feature requests, or would like to contribute code, please feel free to open an issue or submit a pull request on our GitHub repository. -## Support -If you have any questions, feedback, or need assistance with NotificationManager, please don't hesitate to contact us. We're here to help! +Bug reports, feature requests, and pull requests are welcome through the GitHub repository. ## License -NotificationManager is released under the [MIT License](https://opensource.org/license/mit). -Feel free to adjust and expand upon this template to better suit your project's needs! +NotificationManager is available under the [MIT License](LICENSE). diff --git a/Sources/NotificationManager/NotificationManager.swift b/Sources/NotificationManager/NotificationManager.swift index 42b2ab3..7be2922 100644 --- a/Sources/NotificationManager/NotificationManager.swift +++ b/Sources/NotificationManager/NotificationManager.swift @@ -1,238 +1,317 @@ import Foundation import UserNotifications -/// This represents the main class for managing local notifications. To get the functions -/// work, permission needs to be requested from the user. +protocol UserNotificationCenter { + func requestAuthorization(options: UNAuthorizationOptions) async throws -> Bool + func authorizationStatus() async -> UNAuthorizationStatus + func addNotificationRequest(_ request: UNNotificationRequest) async throws + func pendingNotificationRequests() async -> [UNNotificationRequest] + func removeAllPendingNotificationRequests() + func removeAllDeliveredNotifications() + func removePendingNotificationRequests(withIdentifiers identifiers: [String]) + @available(iOS 16.0, macOS 13.0, visionOS 1.0, *) + func setBadgeCount(_ count: Int) async throws +} + +extension UNUserNotificationCenter: UserNotificationCenter { + func authorizationStatus() async -> UNAuthorizationStatus { + await notificationSettings().authorizationStatus + } + + func addNotificationRequest(_ request: UNNotificationRequest) async throws { + try await add(request) + } +} + +/// Errors produced while validating a notification request. +public enum NotificationManagerError: Error, Equatable { + /// A non-repeating notification must have a positive time interval. + case invalidTimeInterval + /// A repeating notification must have a time interval of at least 60 seconds. + case repeatingTimeIntervalTooShort + /// A date-based notification must be scheduled for a future date. + case triggerDateMustBeInFuture +} + +extension NotificationManagerError: LocalizedError { + public var errorDescription: String? { + switch self { + case .invalidTimeInterval: + return "The notification time interval must be greater than zero." + case .repeatingTimeIntervalTooShort: + return "Repeating notifications require a time interval of at least 60 seconds." + case .triggerDateMustBeInFuture: + return "The notification trigger date must be in the future." + } + } +} + +/// Manages local notification authorization, scheduling, querying, and removal. public struct NotificationManager { - // MARK: Variables in Context - // Instance of the UNUserNotificationCenter to get access to all methods. - private static let center = UNUserNotificationCenter.current() - + private static let defaultAuthorizationOptions: UNAuthorizationOptions = [.alert, .sound, .badge] + private static var centerOverride: (any UserNotificationCenter)? + static var center: any UserNotificationCenter { + get { centerOverride ?? UNUserNotificationCenter.current() } + set { centerOverride = newValue } + } + + static func resetCenter() { + centerOverride = nil + } + // MARK: Authorization - /// Requests authorization for alerts, sound and badges for local notifications. - /// If the authorization process returns an error, the error message is printed to the console. + + /// Requests authorization for alerts, sounds, and badges. public static func requestAuthorization() { - center.requestAuthorization(options: [.alert, .sound, .badge, .carPlay, .criticalAlert, .provisional]) { _, error in - if let error = error { + Task { + do { + _ = try await center.requestAuthorization(options: defaultAuthorizationOptions) + } catch { print("Error: " + error.localizedDescription) } } } - - /// Requests authorization for alerts, sound and badges for local notifications in an asynchronous way. - /// If the authorization process returns an error, the error message is printed to the console. - /// - Returns: true if authorization process went good, otherwise false + + /// Requests authorization for alerts, sounds, and badges. + /// - Returns: Whether the user granted authorization. public static func requestAuthorization() async -> Bool { - var status = false do { - try await status = center.requestAuthorization(options: [.alert, .sound, .badge, .carPlay, .criticalAlert, .provisional]) + return try await center.requestAuthorization(options: defaultAuthorizationOptions) } catch { print("Error: " + error.localizedDescription) + return false } - return status } - - /// Requests authorization for alerts, sound and badges for local notifications in an asynchronous way. - /// If the authorization process returns an error, the error is thrown. - /// - Returns: true if authorization process went good, otherwise false + + /// Requests authorization for alerts, sounds, and badges. + /// - Returns: Whether the user granted authorization. public static func requestAuthorizationThrowable() async throws -> Bool { - var status = false - try await status = center.requestAuthorization(options: [.alert, .sound, .badge, .carPlay, .criticalAlert, .provisional]) - return status - } - - /// Requests authorization for certain authorization options for local notifications in an asynchronous way. - /// If the authorization process returns an error, the error is thrown. - /// - Parameter options: authorization options of UNAuthorizationOptions - public static func requestAuthorization(for options: UNAuthorizationOptions) async throws { + try await center.requestAuthorization(options: defaultAuthorizationOptions) + } + + /// Requests authorization for the supplied options. + /// - Parameter options: The notification authorization options to request. + /// - Returns: Whether the user granted authorization. + @discardableResult + public static func requestAuthorization(for options: UNAuthorizationOptions) async throws -> Bool { try await center.requestAuthorization(options: options) } - /// Retrieves the authorization settings for your app. - /// - Returns: Constants indicating whether the app is allowed to schedule notifications. + /// Retrieves the current notification authorization status. public static func getAuthorizationStatus() async -> UNAuthorizationStatus { - var notificationSettings: UNNotificationSettings - notificationSettings = await center.notificationSettings() - return notificationSettings.authorizationStatus + await center.authorizationStatus() } - + // MARK: Schedule - /// Schedules a notification to arrive at a certain point of time from now. - /// - Parameters: - /// - id: unique id of the notification - /// - title: title of the notification that should be shown - /// - body: body of the notification that should be shown - /// - triggerDate: exact date when the notification should arrive + + /// Schedules a notification for a future date and reports scheduling errors. + public static func scheduleNotification( + id: String, + title: String, + 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 + ) + } + + /// Schedules a notification for a future date. public static func scheduleNotification(id: String, title: String, body: String, triggerDate: Date) { - if triggerDate > Date() { - //Content - let content = UNMutableNotificationContent() - content.title = title - content.body = body - content.sound = .default - - let timeInterval = triggerDate.timeIntervalSince(Date()) - - //Trigger - let trigger = UNTimeIntervalNotificationTrigger(timeInterval: timeInterval, repeats: false) - - //Request - let request = UNNotificationRequest(identifier: id, content: content, trigger: trigger) - - //Schedule - center.add(request) { (error) in - if let error = error { - print("Error: " + error.localizedDescription) - } - } + Task { + do { + try await scheduleNotification(id: id, title: title, body: body, triggerDate: triggerDate) + } catch { + print("Error: " + error.localizedDescription) + } } } - - /// Schedules a notification to arrive after a certain time interval in seconds from now. - /// - Parameters: - /// - id: unique id of the notification - /// - title: title of the notification that should be shown - /// - body: body of the notification that should be shown - /// - timeInterval: time interval in seconds from now when the notification should arrive + + /// Schedules a notification after a positive number of seconds and reports scheduling errors. + public static func scheduleNotification( + id: String, + title: String, + body: String, + timeInterval: Int + ) async throws { + try await scheduleNotification( + id: id, + title: title, + body: body, + timeInterval: TimeInterval(timeInterval), + repeats: false + ) + } + + /// Schedules a notification after a positive number of seconds. public static func scheduleNotification(id: String, title: String, body: String, timeInterval: Int) { - if timeInterval > 0 { - //Content - let content = UNMutableNotificationContent() - content.title = title - content.body = body - content.sound = .default - - //Trigger - let trigger = UNTimeIntervalNotificationTrigger(timeInterval: TimeInterval(timeInterval), repeats: false) - - //Request - let request = UNNotificationRequest(identifier: id, content: content, trigger: trigger) - - //Schedule - center.add(request) { (error) in - if let error = error { - print("Error: " + error.localizedDescription) - } - } + Task { + do { + try await scheduleNotification(id: id, title: title, body: body, timeInterval: timeInterval) + } catch { + print("Error: " + error.localizedDescription) + } } } - - /// Schedules a notification to arrive after a certain time interval in seconds from now. The notificaiton will repeat - /// after the time interval. - /// - Parameters: - /// - id: unique id of the notification - /// - title: title of the notification that should be shown - /// - body: body of the notification that should be shown - /// - timeInterval: time interval in seconds from now when the notification should arrive + + /// Schedules a repeating notification and reports scheduling errors. + public static func scheduleRepeatNotification( + id: String, + title: String, + body: String, + timeInterval: Int + ) async throws { + try await scheduleNotification( + id: id, + title: title, + body: body, + timeInterval: TimeInterval(timeInterval), + repeats: true + ) + } + + /// Schedules a repeating notification. Repeating intervals must be at least 60 seconds. public static func scheduleRepeatNotification(id: String, title: String, body: String, timeInterval: Int) { - if timeInterval > 0 { - //Content - let content = UNMutableNotificationContent() - content.title = title - content.body = body - content.sound = .default - - //Trigger - let trigger = UNTimeIntervalNotificationTrigger(timeInterval: TimeInterval(timeInterval), repeats: true) - - //Request - let request = UNNotificationRequest(identifier: id, content: content, trigger: trigger) - - //Schedule - center.add(request) { (error) in - if let error = error { - print("Error: " + error.localizedDescription) - } - } + Task { + do { + try await scheduleRepeatNotification( + id: id, + title: title, + body: body, + timeInterval: timeInterval + ) + } catch { + print("Error: " + error.localizedDescription) + } + } + } + + private static func scheduleNotification( + id: String, + title: String, + body: String, + timeInterval: TimeInterval, + repeats: Bool + ) async throws { + guard timeInterval > 0 else { + throw NotificationManagerError.invalidTimeInterval + } + guard !repeats || timeInterval >= 60 else { + throw NotificationManagerError.repeatingTimeIntervalTooShort } + + let content = UNMutableNotificationContent() + content.title = title + content.body = body + content.sound = .default + + let trigger = UNTimeIntervalNotificationTrigger(timeInterval: timeInterval, repeats: repeats) + let request = UNNotificationRequest(identifier: id, content: content, trigger: trigger) + try await center.addNotificationRequest(request) } - + // MARK: Fetch - /// Fetches all of your app’s local notifications that are pending delivery. - /// - Returns: array containing all pending notification requests + + /// Fetches all pending local notification requests. public static func getPendingNotificationRequests() async -> [UNNotificationRequest] { - var notificationRequests: [UNNotificationRequest] - notificationRequests = await center.pendingNotificationRequests() - return notificationRequests + await center.pendingNotificationRequests() } - - /// Fetches all of your app’s local notifications identifiers that are pending delivery. - /// - Returns: array containing all identifiers of pending notification requests + + /// Fetches the identifiers of all pending local notification requests. public static func getPendingNotificationRequestsIds() async -> [String] { - var notificationRequests: [UNNotificationRequest] - var notificationIds = [String]() - notificationRequests = await center.pendingNotificationRequests() - - guard notificationRequests.isEmpty else { - for notificationRequest in notificationRequests { - notificationIds.append(notificationRequest.identifier) - } - return notificationIds - } - - return notificationIds + await center.pendingNotificationRequests().map(\.identifier) } - + // MARK: Update - /// Updates an already scheduled notification according to new parameters. If the notification's id - /// does not exist, nothing happens. - /// - Parameters: - /// - id: unique id of the notification - /// - newTitle: new title of the notification - /// - newBody: new body of the notification - /// - newDate: new exact date when the notification should arrive - public static func replaceNotificationRequestFromId(id: String, newTitle: String, newBody: String, newDate: Date) async throws { + + /// Replaces an existing pending notification. If the identifier does not exist, nothing happens. + public static func replaceNotificationRequestFromId( + id: String, + newTitle: String, + newBody: String, + newDate: Date + ) async throws { let requests = await center.pendingNotificationRequests() - guard !requests.isEmpty else { return } - guard requests.contains(where: {$0.identifier == id}) else { return } - - // Remove old notification - center.removePendingNotificationRequests(withIdentifiers: [id]) - - // New content - let newContent = UNMutableNotificationContent() - newContent.title = newTitle - newContent.body = newBody - - // New Trigger - let newTimeInterval = newDate.timeIntervalSince(Date()) - let newTrigger = UNTimeIntervalNotificationTrigger(timeInterval: newTimeInterval, repeats: false) - - // New Request - let newRequest = UNNotificationRequest(identifier: id, content: newContent, trigger: newTrigger) - try await center.add(newRequest) - } - + guard requests.contains(where: { $0.identifier == id }) else { + return + } + + 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 + ) + } + // MARK: Remove - /// Removes all pending notifications. Attention: Removed pending notifications cannot be restored. + + /// Removes all pending notifications. public static func removeAllPendingNotificationRequests() { center.removeAllPendingNotificationRequests() } - - /// Removes all delivered notifications. Attention: Removed notifications cannot be restored. + + /// Removes all delivered notifications. public static func removeAllDeliveredNotificationRequests() { center.removeAllDeliveredNotifications() } - - /// Removes certain pending notifications. Attention: Removed pending notifications cannot be restored. - /// - Parameter ids: unique identifiers of notifications + + /// Removes pending notifications with the supplied identifiers. public static func removePendingNotificationRequests(ids: [String]) { center.removePendingNotificationRequests(withIdentifiers: ids) } - - // MARK: Others - @available(iOS 16.0, *) - @available(macOS 13.0, *) + + // MARK: Badge + + /// Updates the application's badge count. + @available(iOS 16.0, macOS 13.0, visionOS 1.0, *) + public static func setBadge(badge: Int) async throws { + try await center.setBadgeCount(badge) + } + /// Updates the application's badge count. - /// - Parameter badge: badge count + @available(iOS 16.0, macOS 13.0, visionOS 1.0, *) public static func setBadge(badge: Int) { - UNUserNotificationCenter.current().setBadgeCount(badge) + Task { + do { + try await setBadge(badge: badge) + } catch { + print("Error: " + error.localizedDescription) + } + } } - - @available(iOS 16.0, *) - @available(macOS 13.0, *) + /// Resets the application's badge count. + @available(iOS 16.0, macOS 13.0, visionOS 1.0, *) + public static func resetBadge() async throws { + try await center.setBadgeCount(0) + } + + /// Resets the application's badge count. + @available(iOS 16.0, macOS 13.0, visionOS 1.0, *) public static func resetBadge() { - UNUserNotificationCenter.current().setBadgeCount(0) + Task { + do { + try await resetBadge() + } catch { + print("Error: " + error.localizedDescription) + } + } } } diff --git a/Tests/NotificationManagerTests/AuthorizationTests.swift b/Tests/NotificationManagerTests/AuthorizationTests.swift deleted file mode 100644 index 5d1f88a..0000000 --- a/Tests/NotificationManagerTests/AuthorizationTests.swift +++ /dev/null @@ -1,10 +0,0 @@ -import XCTest -import UserNotifications -import Foundation -@testable import NotificationManager - -/// This represents the testing cases for the authorization process. -final class AuthorizationTests: XCTestCase { - - -} diff --git a/Tests/NotificationManagerTests/NotificationManagerTests.swift b/Tests/NotificationManagerTests/NotificationManagerTests.swift new file mode 100644 index 0000000..249f601 --- /dev/null +++ b/Tests/NotificationManagerTests/NotificationManagerTests.swift @@ -0,0 +1,249 @@ +import Foundation +import UserNotifications +import XCTest +@testable import NotificationManager + +final class NotificationManagerTests: XCTestCase { + private var center: TestNotificationCenter! + + override func setUp() { + super.setUp() + center = TestNotificationCenter() + NotificationManager.center = center + } + + override func tearDown() { + NotificationManager.resetCenter() + center = nil + super.tearDown() + } + + func testDefaultAuthorizationRequestsOnlyAlertSoundAndBadge() async throws { + center.authorizationGranted = true + + let granted = try await NotificationManager.requestAuthorizationThrowable() + + XCTAssertTrue(granted) + XCTAssertEqual(center.requestedAuthorizationOptions, [.alert, .sound, .badge]) + } + + func testCustomAuthorizationReturnsResult() async throws { + center.authorizationGranted = true + + let granted = try await NotificationManager.requestAuthorization(for: [.provisional]) + + XCTAssertTrue(granted) + XCTAssertEqual(center.requestedAuthorizationOptions, [.provisional]) + } + + func testScheduleNotificationCreatesExpectedRequest() async throws { + try await NotificationManager.scheduleNotification( + id: "request-id", + title: "Title", + body: "Body", + timeInterval: 10 + ) + + let request = try XCTUnwrap(center.addedRequests.first) + let trigger = try XCTUnwrap(request.trigger as? UNTimeIntervalNotificationTrigger) + XCTAssertEqual(request.identifier, "request-id") + XCTAssertEqual(request.content.title, "Title") + XCTAssertEqual(request.content.body, "Body") + XCTAssertEqual(trigger.timeInterval, 10) + XCTAssertFalse(trigger.repeats) + } + + func testScheduleNotificationRejectsNonPositiveInterval() async { + await XCTAssertThrowsErrorAsync( + try await NotificationManager.scheduleNotification( + id: "request-id", + title: "Title", + body: "Body", + timeInterval: 0 + ) + ) { error in + XCTAssertEqual(error as? NotificationManagerError, .invalidTimeInterval) + } + XCTAssertTrue(center.addedRequests.isEmpty) + } + + func testScheduleNotificationPropagatesCenterError() 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) + } + } + + func testRepeatingNotificationRejectsIntervalBelowSixtySeconds() async { + await XCTAssertThrowsErrorAsync( + try await NotificationManager.scheduleRepeatNotification( + id: "request-id", + title: "Title", + body: "Body", + timeInterval: 59 + ) + ) { error in + XCTAssertEqual(error as? NotificationManagerError, .repeatingTimeIntervalTooShort) + } + XCTAssertTrue(center.addedRequests.isEmpty) + } + + func testRepeatingNotificationAcceptsSixtySeconds() async throws { + try await NotificationManager.scheduleRepeatNotification( + id: "request-id", + title: "Title", + body: "Body", + timeInterval: 60 + ) + + let request = try XCTUnwrap(center.addedRequests.first) + let trigger = try XCTUnwrap(request.trigger as? UNTimeIntervalNotificationTrigger) + XCTAssertEqual(trigger.timeInterval, 60) + XCTAssertTrue(trigger.repeats) + } + + func testDateSchedulingRejectsPastDate() async { + await XCTAssertThrowsErrorAsync( + try await NotificationManager.scheduleNotification( + id: "request-id", + title: "Title", + body: "Body", + triggerDate: Date().addingTimeInterval(-1) + ) + ) { error in + XCTAssertEqual(error as? NotificationManagerError, .triggerDateMustBeInFuture) + } + } + + func testReplaceAddsSameIdentifierWithoutRemovingOldRequestFirst() async throws { + center.pendingRequests = [ + UNNotificationRequest( + identifier: "request-id", + content: UNMutableNotificationContent(), + trigger: nil + ) + ] + + try await NotificationManager.replaceNotificationRequestFromId( + id: "request-id", + newTitle: "New title", + newBody: "New body", + newDate: Date().addingTimeInterval(120) + ) + + XCTAssertEqual(center.removedIdentifierGroups, []) + XCTAssertEqual(center.addedRequests.first?.identifier, "request-id") + XCTAssertEqual(center.addedRequests.first?.content.title, "New title") + XCTAssertEqual(center.addedRequests.first?.content.body, "New body") + } + + func testReplaceRejectsPastDateWithoutChangingExistingRequest() async { + center.pendingRequests = [ + UNNotificationRequest( + identifier: "request-id", + content: UNMutableNotificationContent(), + trigger: nil + ) + ] + + await XCTAssertThrowsErrorAsync( + try await NotificationManager.replaceNotificationRequestFromId( + id: "request-id", + newTitle: "New title", + newBody: "New body", + newDate: Date().addingTimeInterval(-1) + ) + ) { error in + XCTAssertEqual(error as? NotificationManagerError, .triggerDateMustBeInFuture) + } + + XCTAssertTrue(center.addedRequests.isEmpty) + XCTAssertTrue(center.removedIdentifierGroups.isEmpty) + } + + func testPendingRequestIdentifiersAreMappedDirectly() async { + center.pendingRequests = [ + UNNotificationRequest(identifier: "first", content: UNMutableNotificationContent(), trigger: nil), + UNNotificationRequest(identifier: "second", content: UNMutableNotificationContent(), trigger: nil) + ] + + let identifiers = await NotificationManager.getPendingNotificationRequestsIds() + + XCTAssertEqual(identifiers, ["first", "second"]) + } +} + +private final class TestNotificationCenter: UserNotificationCenter { + var authorizationGranted = false + var authorizationStatusValue: UNAuthorizationStatus = .notDetermined + var requestedAuthorizationOptions: UNAuthorizationOptions? + var addedRequests: [UNNotificationRequest] = [] + var addError: Error? + var pendingRequests: [UNNotificationRequest] = [] + var removedIdentifierGroups: [[String]] = [] + var removedAllPendingRequests = false + var removedAllDeliveredNotifications = false + var badgeCounts: [Int] = [] + + func requestAuthorization(options: UNAuthorizationOptions) async throws -> Bool { + requestedAuthorizationOptions = options + return authorizationGranted + } + + func authorizationStatus() async -> UNAuthorizationStatus { + authorizationStatusValue + } + + func addNotificationRequest(_ request: UNNotificationRequest) async throws { + if let addError { + throw addError + } + addedRequests.append(request) + } + + func pendingNotificationRequests() async -> [UNNotificationRequest] { + pendingRequests + } + + func removeAllPendingNotificationRequests() { + removedAllPendingRequests = true + } + + func removeAllDeliveredNotifications() { + removedAllDeliveredNotifications = true + } + + func removePendingNotificationRequests(withIdentifiers identifiers: [String]) { + removedIdentifierGroups.append(identifiers) + } + + func setBadgeCount(_ count: Int) async throws { + badgeCounts.append(count) + } +} + +private enum TestError: Error, Equatable { + case addFailed +} + +private func XCTAssertThrowsErrorAsync( + _ expression: @autoclosure () async throws -> T, + _ errorHandler: (Error) -> Void, + file: StaticString = #filePath, + line: UInt = #line +) async { + do { + _ = try await expression() + XCTFail("Expected expression to throw", file: file, line: line) + } catch { + errorHandler(error) + } +} From 75ec3103bad21017fd5982f8d5f2078d9de3908e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timo=20K=C3=B6the?= Date: Sat, 20 Jun 2026 17:48:26 +0200 Subject: [PATCH 2/3] docs: add delivered notification APIs and rename methods Introduce support for delivered notifications: add UserNotificationCenter.deliveredNotifications(), removeDeliveredNotifications(withIdentifiers:), NotificationManager.getDeliveredNotifications(), and NotificationManager.getDeliveredNotificationIDs(); update TestNotificationCenter and add helpers for delivered notification tests. Rename requestAuthorizationThrowable() to requestAuthorizationThrowing() and getPendingNotificationRequestsIds() to getPendingNotificationRequestIDs(), preserving the old names as deprecated wrappers. Update README examples to reflect the new API names and show delivered notification usage. Also remove the visionOS job from the GitHub Actions build workflow and update unit tests accordingly. --- .github/workflows/build.yml | 3 - README.md | 15 +++- .../NotificationManager.swift | 34 +++++++++- .../NotificationManagerTests.swift | 68 ++++++++++++++++++- 4 files changed, 111 insertions(+), 9 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 608874d..52826ae 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -21,9 +21,6 @@ jobs: - platform: iOS destination: generic/platform=iOS sdk: iphoneos - - platform: visionOS - destination: generic/platform=visionOS - sdk: xros steps: - uses: actions/checkout@v4 - name: Build diff --git a/README.md b/README.md index 5d72535..4530855 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ Request the standard alert, sound, and badge permissions at a point where the us ```swift do { - let granted = try await NotificationManager.requestAuthorizationThrowable() + let granted = try await NotificationManager.requestAuthorizationThrowing() if granted { // Notifications are authorized. @@ -153,7 +153,14 @@ let requests: [UNNotificationRequest] = Fetch only their identifiers: ```swift -let identifiers = await NotificationManager.getPendingNotificationRequestsIds() +let identifiers = await NotificationManager.getPendingNotificationRequestIDs() +``` + +Fetch delivered notifications and their identifiers: + +```swift +let deliveredNotifications = await NotificationManager.getDeliveredNotifications() +let deliveredIDs = await NotificationManager.getDeliveredNotificationIDs() ``` ## Replacing Notifications @@ -178,6 +185,10 @@ NotificationManager.removePendingNotificationRequests( ids: ["task-reminder", "hourly-reminder"] ) +NotificationManager.removeDeliveredNotifications( + ids: ["task-reminder"] +) + NotificationManager.removeAllPendingNotificationRequests() NotificationManager.removeAllDeliveredNotificationRequests() ``` diff --git a/Sources/NotificationManager/NotificationManager.swift b/Sources/NotificationManager/NotificationManager.swift index 7be2922..07d68bd 100644 --- a/Sources/NotificationManager/NotificationManager.swift +++ b/Sources/NotificationManager/NotificationManager.swift @@ -6,9 +6,11 @@ protocol UserNotificationCenter { func authorizationStatus() async -> UNAuthorizationStatus func addNotificationRequest(_ request: UNNotificationRequest) async throws func pendingNotificationRequests() async -> [UNNotificationRequest] + func deliveredNotifications() async -> [UNNotification] func removeAllPendingNotificationRequests() func removeAllDeliveredNotifications() func removePendingNotificationRequests(withIdentifiers identifiers: [String]) + func removeDeliveredNotifications(withIdentifiers identifiers: [String]) @available(iOS 16.0, macOS 13.0, visionOS 1.0, *) func setBadgeCount(_ count: Int) async throws } @@ -85,10 +87,17 @@ public struct NotificationManager { /// Requests authorization for alerts, sounds, and badges. /// - Returns: Whether the user granted authorization. - public static func requestAuthorizationThrowable() async throws -> Bool { + public static func requestAuthorizationThrowing() async throws -> Bool { try await center.requestAuthorization(options: defaultAuthorizationOptions) } + /// Requests authorization for alerts, sounds, and badges. + /// - Returns: Whether the user granted authorization. + @available(*, deprecated, renamed: "requestAuthorizationThrowing()") + public static func requestAuthorizationThrowable() async throws -> Bool { + try await requestAuthorizationThrowing() + } + /// Requests authorization for the supplied options. /// - Parameter options: The notification authorization options to request. /// - Returns: Whether the user granted authorization. @@ -227,10 +236,26 @@ public struct NotificationManager { } /// Fetches the identifiers of all pending local notification requests. - public static func getPendingNotificationRequestsIds() async -> [String] { + public static func getPendingNotificationRequestIDs() async -> [String] { await center.pendingNotificationRequests().map(\.identifier) } + /// Fetches the identifiers of all pending local notification requests. + @available(*, deprecated, renamed: "getPendingNotificationRequestIDs()") + public static func getPendingNotificationRequestsIds() async -> [String] { + await getPendingNotificationRequestIDs() + } + + /// Fetches all delivered local notifications. + public static func getDeliveredNotifications() async -> [UNNotification] { + await center.deliveredNotifications() + } + + /// Fetches the identifiers of all delivered local notifications. + public static func getDeliveredNotificationIDs() async -> [String] { + await center.deliveredNotifications().map(\.request.identifier) + } + // MARK: Update /// Replaces an existing pending notification. If the identifier does not exist, nothing happens. @@ -277,6 +302,11 @@ public struct NotificationManager { center.removePendingNotificationRequests(withIdentifiers: ids) } + /// Removes delivered notifications with the supplied identifiers. + public static func removeDeliveredNotifications(ids: [String]) { + center.removeDeliveredNotifications(withIdentifiers: ids) + } + // MARK: Badge /// Updates the application's badge count. diff --git a/Tests/NotificationManagerTests/NotificationManagerTests.swift b/Tests/NotificationManagerTests/NotificationManagerTests.swift index 249f601..0acacb7 100644 --- a/Tests/NotificationManagerTests/NotificationManagerTests.swift +++ b/Tests/NotificationManagerTests/NotificationManagerTests.swift @@ -21,7 +21,7 @@ final class NotificationManagerTests: XCTestCase { func testDefaultAuthorizationRequestsOnlyAlertSoundAndBadge() async throws { center.authorizationGranted = true - let granted = try await NotificationManager.requestAuthorizationThrowable() + let granted = try await NotificationManager.requestAuthorizationThrowing() XCTAssertTrue(granted) XCTAssertEqual(center.requestedAuthorizationOptions, [.alert, .sound, .badge]) @@ -175,10 +175,38 @@ final class NotificationManagerTests: XCTestCase { UNNotificationRequest(identifier: "second", content: UNMutableNotificationContent(), trigger: nil) ] - let identifiers = await NotificationManager.getPendingNotificationRequestsIds() + let identifiers = await NotificationManager.getPendingNotificationRequestIDs() XCTAssertEqual(identifiers, ["first", "second"]) } + + func testDeliveredNotificationsAreReturned() async { + center.deliveredNotificationValues = [ + makeDeliveredNotification(identifier: "first"), + makeDeliveredNotification(identifier: "second") + ] + + let notifications = await NotificationManager.getDeliveredNotifications() + + XCTAssertEqual(notifications.map(\.request.identifier), ["first", "second"]) + } + + func testDeliveredNotificationIdentifiersAreMappedDirectly() async { + center.deliveredNotificationValues = [ + makeDeliveredNotification(identifier: "first"), + makeDeliveredNotification(identifier: "second") + ] + + let identifiers = await NotificationManager.getDeliveredNotificationIDs() + + XCTAssertEqual(identifiers, ["first", "second"]) + } + + func testRemoveDeliveredNotificationsPassesIdentifiersThrough() { + NotificationManager.removeDeliveredNotifications(ids: ["first", "second"]) + + XCTAssertEqual(center.removedDeliveredIdentifierGroups, [["first", "second"]]) + } } private final class TestNotificationCenter: UserNotificationCenter { @@ -188,7 +216,9 @@ private final class TestNotificationCenter: UserNotificationCenter { var addedRequests: [UNNotificationRequest] = [] var addError: Error? var pendingRequests: [UNNotificationRequest] = [] + var deliveredNotificationValues: [UNNotification] = [] var removedIdentifierGroups: [[String]] = [] + var removedDeliveredIdentifierGroups: [[String]] = [] var removedAllPendingRequests = false var removedAllDeliveredNotifications = false var badgeCounts: [Int] = [] @@ -213,6 +243,10 @@ private final class TestNotificationCenter: UserNotificationCenter { pendingRequests } + func deliveredNotifications() async -> [UNNotification] { + deliveredNotificationValues + } + func removeAllPendingNotificationRequests() { removedAllPendingRequests = true } @@ -225,6 +259,10 @@ private final class TestNotificationCenter: UserNotificationCenter { removedIdentifierGroups.append(identifiers) } + func removeDeliveredNotifications(withIdentifiers identifiers: [String]) { + removedDeliveredIdentifierGroups.append(identifiers) + } + func setBadgeCount(_ count: Int) async throws { badgeCounts.append(count) } @@ -234,6 +272,32 @@ private enum TestError: Error, Equatable { case addFailed } +private func makeDeliveredNotification(identifier: String) -> UNNotification { + let request = UNNotificationRequest( + identifier: identifier, + content: UNMutableNotificationContent(), + trigger: nil + ) + + return UNNotification(coder: TestNotificationCoder(request: request))! +} + +private final class TestNotificationCoder: NSCoder { + private let request: UNNotificationRequest + + init(request: UNNotificationRequest) { + self.request = request + } + + override var allowsKeyedCoding: Bool { + true + } + + override func decodeObject(forKey key: String) -> Any? { + key == "request" ? request : nil + } +} + private func XCTAssertThrowsErrorAsync( _ expression: @autoclosure () async throws -> T, _ errorHandler: (Error) -> Void, From c1c9559cc7b30f6777d5d3c94a80643370497bee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timo=20K=C3=B6the?= Date: Sat, 20 Jun 2026 17:59:45 +0200 Subject: [PATCH 3/3] chore: pin macOS runner and select Xcode in CI Change GitHub Actions workflows to run on macos-15 and add a step to select Xcode 16.4 (/Applications/Xcode_16.4.app). The test workflow drops the swift-actions/setup-swift step and relies on the Xcode selection so tests run with the desired toolchain. This ensures consistent macOS/Xcode environment for builds and tests. --- .github/workflows/build.yml | 4 +++- .github/workflows/test.yml | 7 +++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 52826ae..9d5018c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -10,7 +10,7 @@ on: jobs: build: name: Build (${{ matrix.platform }}) - runs-on: macos-latest + runs-on: macos-15 strategy: fail-fast: false matrix: @@ -23,6 +23,8 @@ jobs: sdk: iphoneos steps: - uses: actions/checkout@v4 + - name: Select Xcode + run: sudo xcode-select -s /Applications/Xcode_16.4.app/Contents/Developer - name: Build run: | if ! xcrun --sdk '${{ matrix.sdk }}' --show-sdk-path >/dev/null 2>&1; then diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 681aa50..e252823 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -10,11 +10,10 @@ on: jobs: test: name: Test - runs-on: macos-latest + runs-on: macos-15 steps: - uses: actions/checkout@v4 - - uses: swift-actions/setup-swift@v2 - with: - swift-version: '5.9' + - name: Select Xcode + run: sudo xcode-select -s /Applications/Xcode_16.4.app/Contents/Developer - name: Run Tests run: swift test -v