diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index f67c6e3..9d5018c 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -1,19 +1,39 @@
name: Build
-on:
- push:
- branches:
- - main
- - development
+on:
+ push:
+ branches:
+ - main
+ - development
+ pull_request:
jobs:
build:
- name: Build
- runs-on: macos-latest
+ name: Build (${{ matrix.platform }})
+ runs-on: macos-15
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - platform: macOS
+ destination: generic/platform=macOS
+ sdk: macosx
+ - platform: iOS
+ destination: generic/platform=iOS
+ sdk: iphoneos
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: 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..e252823 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -1,19 +1,19 @@
name: Test
-on:
- push:
- branches:
- - main
- - development
+on:
+ push:
+ branches:
+ - main
+ - development
+ pull_request:
jobs:
- build:
+ 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
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..4530855 100644
--- a/README.md
+++ b/README.md
@@ -1,120 +1,211 @@
# NotificationManager
+
[](https://opensource.org/license/mit)
[](https://github.com/timokoethe/NotificationManager/actions/workflows/build.yml)
[](https://github.com/timokoethe/NotificationManager/actions/workflows/test.yml)
-[](https://swiftpackageindex.com/timokoethe/NotificationManager)
-[](https://swiftpackageindex.com/timokoethe/NotificationManager)
+[](https://swiftpackageindex.com/timokoethe/NotificationManager)
+[](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```
+Add `NotificationManager` to your target and import it:
-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
+```swift
import NotificationManager
+```
-@main
-struct YourApp: App {
- var body: some Scene {
- WindowGroup {
- ContentView()
- .onAppear {
- requestNotificationAuthorization()
- }
- }
+## Authorization
+
+Request the standard alert, sound, and badge permissions at a point where the user understands why notifications are needed:
+
+```swift
+do {
+ let granted = try await NotificationManager.requestAuthorizationThrowing()
+
+ 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.getPendingNotificationRequestIDs()
+```
+
+Fetch delivered notifications and their identifiers:
+
+```swift
+let deliveredNotifications = await NotificationManager.getDeliveredNotifications()
+let deliveredIDs = await NotificationManager.getDeliveredNotificationIDs()
+```
+
+## 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.removeDeliveredNotifications(
+ ids: ["task-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..07d68bd 100644
--- a/Sources/NotificationManager/NotificationManager.swift
+++ b/Sources/NotificationManager/NotificationManager.swift
@@ -1,238 +1,347 @@
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 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
+}
+
+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 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 {
- 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 requestAuthorizationThrowing()
+ }
+
+ /// 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 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] {
- 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 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
- /// 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, *)
+
+ /// 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.
+ @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)
+ }
+ }
+ }
+
+ /// 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)
}
-
- @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() {
- 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..0acacb7
--- /dev/null
+++ b/Tests/NotificationManagerTests/NotificationManagerTests.swift
@@ -0,0 +1,313 @@
+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.requestAuthorizationThrowing()
+
+ 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.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 {
+ var authorizationGranted = false
+ var authorizationStatusValue: UNAuthorizationStatus = .notDetermined
+ var requestedAuthorizationOptions: UNAuthorizationOptions?
+ 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] = []
+
+ 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 deliveredNotifications() async -> [UNNotification] {
+ deliveredNotificationValues
+ }
+
+ func removeAllPendingNotificationRequests() {
+ removedAllPendingRequests = true
+ }
+
+ func removeAllDeliveredNotifications() {
+ removedAllDeliveredNotifications = true
+ }
+
+ func removePendingNotificationRequests(withIdentifiers identifiers: [String]) {
+ removedIdentifierGroups.append(identifiers)
+ }
+
+ func removeDeliveredNotifications(withIdentifiers identifiers: [String]) {
+ removedDeliveredIdentifierGroups.append(identifiers)
+ }
+
+ func setBadgeCount(_ count: Int) async throws {
+ badgeCounts.append(count)
+ }
+}
+
+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,
+ file: StaticString = #filePath,
+ line: UInt = #line
+) async {
+ do {
+ _ = try await expression()
+ XCTFail("Expected expression to throw", file: file, line: line)
+ } catch {
+ errorHandler(error)
+ }
+}