Skip to content

Commit

Permalink
Support for scheduling notifications (#49)
Browse files Browse the repository at this point in the history
# Support for scheduling notifications

## ♻️ Current situation & Problem
The newly introduced SpeziScheduler #44 didn't include support for
notifications (see #45). This PR adds back this feature, providing
several improvements over the previous implementation.
A core challenge is that Apple limits the amount of locally scheduled
notifications to 64 request at a time. Therefore, we optimize scheduling
by applying the overall rules:
* Schedules that can be expressed using repeating calendar-based
notification triggers are scheduled that way, only ever occupying one
request for all its event occurrences.
* Otherwise, each event is scheduled individually using an
interval-based trigger. In this case we order notification request by
event occurrence. Further, we never schedule more than `30` notification
at a time and never earlier than 1 month in advance to ensure other
modules are still able to schedule local notifications. These settings
can be adjusted by manually configuring the `SchedulerNotifications` in
your `SpeziAppDelegate`
* Scheduled Notifications are updated using background tasks (if
necessary). We schedule a background task every week (or earlier if
necessary) to update the scheduled notification requests.

## ⚙️ Release Notes 
* More advanced notification scheduling that tries to reduce the amount
of notification request by using repeating notifications triggers (when
using the `daily` and `weekly` shorthand initializes of a `Schedule`)
and prioritizes event notifications by their occurrence date.
* Automatically schedule events as time-sensitive notification if their
duration is not `allDay`.
* All Task Notifications are automatically put into the same Scheduler
group.
* Support notification content customization by conforming your Standard
to the `SchedulerNotificationsConstraint`.
* Automatically present notifications while the app is running in
foreground (customize this using the `notificationPresentation` option).
* Automatically request provisional notification authorization to
schedule notification even without explicit authorization (customize
this with the `automaticallyRequestProvisionalAuthorization` option).
* A lot of other fixes and improvements.


## 📚 Documentation
Added a dedicated configuration section around notifications in the
documentation catalog. The documentation of the `SchedulerNotifications`
module highlights the necessary steps to set up notifications for your
project.

## ✅ Testing
Added the `XCTSpeziScheduler` target that provide UI components to
visualize scheduled notification requests. We use this in the UI tests
to verify that notifications are scheduled as expected. The Test App
schedules a repeating daily notification that has its first occurrence
40s after app launch. Additionally we schedule a daily repeating task
that starts 1 week after initial app launch to test event-level
scheduling.

## 📝 Code of Conduct & Contributing Guidelines 

By submitting creating this pull request, you agree to follow our [Code
of
Conduct](https://github.com/StanfordSpezi/.github/blob/main/CODE_OF_CONDUCT.md)
and [Contributing
Guidelines](https://github.com/StanfordSpezi/.github/blob/main/CONTRIBUTING.md):
- [x] I agree to follow the [Code of
Conduct](https://github.com/StanfordSpezi/.github/blob/main/CODE_OF_CONDUCT.md)
and [Contributing
Guidelines](https://github.com/StanfordSpezi/.github/blob/main/CONTRIBUTING.md).
  • Loading branch information
Supereg authored Oct 29, 2024
1 parent afb9193 commit 7ba1ed8
Show file tree
Hide file tree
Showing 52 changed files with 2,479 additions and 165 deletions.
4 changes: 2 additions & 2 deletions .github/workflows/build-and-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -61,9 +61,9 @@ jobs:
scheme: TestApp
uploadcoveragereport:
name: Upload Coverage Report
needs: [buildandtest_ios, buildandtest_watchos, buildandtest_visionos, buildandtestuitests_ios]
needs: [buildandtest_ios, buildandtest_watchos, buildandtest_visionos, buildandtest_macos, buildandtestuitests_ios]
uses: StanfordSpezi/.github/.github/workflows/create-and-upload-coverage-report.yml@v2
with:
coveragereports: SpeziScheduler-iOS.xcresult SpeziScheduler-watchOS.xcresult SpeziScheduler-visionOS.xcresult TestApp.xcresult
coveragereports: SpeziScheduler-iOS.xcresult SpeziScheduler-watchOS.xcresult SpeziScheduler-visionOS.xcresult SpeziScheduler-macOS.xcresult TestApp.xcresult
secrets:
token: ${{ secrets.CODECOV_TOKEN }}
1 change: 1 addition & 0 deletions .spi.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,4 @@ builder:
- platform: ios
documentation_targets:
- SpeziScheduler
- SpeziSchedulerUI
4 changes: 0 additions & 4 deletions .swiftlint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -367,10 +367,6 @@ only_rules:
# The variable should be placed on the left, the constant on the right of a comparison operator.
- yoda_condition

deployment_target: # Availability checks or attributes shouldn’t be using older versions that are satisfied by the deployment target.
iOSApplicationExtension_deployment_target: 16.0
iOS_deployment_target: 16.0

excluded: # paths to ignore during linting. Takes precedence over `included`.
- .build
- .swiftpm
Expand Down
8 changes: 6 additions & 2 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ let package = Package(
.package(url: "https://github.com/StanfordSpezi/Spezi", from: "1.8.0"),
.package(url: "https://github.com/StanfordSpezi/SpeziViews", from: "1.7.0"),
.package(url: "https://github.com/StanfordSpezi/SpeziStorage", from: "1.1.2"),
.package(url: "https://github.com/StanfordSpezi/SpeziNotifications.git", from: "1.0.2"),
.package(url: "https://github.com/apple/swift-algorithms.git", from: "1.2.0"),
.package(url: "https://github.com/swiftlang/swift-syntax", from: "600.0.0-prerelease-2024-08-14"),
.package(url: "https://github.com/pointfreeco/swift-snapshot-testing.git", from: "1.17.2")
] + swiftLintPackage(),
Expand All @@ -51,7 +53,9 @@ let package = Package(
.product(name: "SpeziFoundation", package: "SpeziFoundation"),
.product(name: "Spezi", package: "Spezi"),
.product(name: "SpeziViews", package: "SpeziViews"),
.product(name: "SpeziLocalStorage", package: "SpeziStorage")
.product(name: "SpeziNotifications", package: "SpeziNotifications"),
.product(name: "SpeziLocalStorage", package: "SpeziStorage"),
.product(name: "Algorithms", package: "swift-algorithms")
],
plugins: [] + swiftLintPlugin()
),
Expand Down Expand Up @@ -109,7 +113,7 @@ func swiftLintPlugin() -> [Target.PluginUsage] {

func swiftLintPackage() -> [PackageDescription.Package.Dependency] {
if ProcessInfo.processInfo.environment["SPEZI_DEVELOPMENT_SWIFTLINT"] != nil {
[.package(url: "https://github.com/realm/SwiftLint.git", from: "0.56.2")]
[.package(url: "https://github.com/realm/SwiftLint.git", from: "0.55.1")]
} else {
[]
}
Expand Down
12 changes: 0 additions & 12 deletions Sources/SpeziScheduler/Constants.swift

This file was deleted.

21 changes: 0 additions & 21 deletions Sources/SpeziScheduler/EventQuery.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
//

import Combine
import OSLog
import SwiftData
import SwiftUI

Expand Down Expand Up @@ -182,23 +181,3 @@ extension EventQuery: DynamicProperty {
}
}
}


private let logger = Logger(subsystem: "edu.stanford.spezi.scheduler", category: "EventQuery")


private func measure<T, C: Clock>(
clock: C = ContinuousClock(),
name: @autoclosure @escaping () -> StaticString,
_ action: () throws -> T
) rethrows -> T where C.Instant.Duration == Duration {
#if DEBUG || TEST
let start = clock.now
let result = try action()
let end = clock.now
logger.debug("Performing \(name()) took \(start.duration(to: end))")
return result
#else
try action()
#endif
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
//
// This source file is part of the Stanford Spezi open-source project
//
// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md)
//
// SPDX-License-Identifier: MIT
//

#if canImport(BackgroundTasks)
import BackgroundTasks


@available(macOS, unavailable)
extension BGTaskScheduler.Error.Code: @retroactive CustomStringConvertible {
public var description: String {
switch self {
case .notPermitted:
"notPermitted"
case .tooManyPendingTaskRequests:
"tooManyPendingTaskRequests"
case .unavailable:
"unavailable"
@unknown default:
"BGTaskSchedulerErrorCode(rawValue: \(rawValue))"
}
}
}
#endif
24 changes: 24 additions & 0 deletions Sources/SpeziScheduler/Notifications/BackgroundMode.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
//
// This source file is part of the Stanford Spezi open-source project
//
// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md)
//
// SPDX-License-Identifier: MIT
//


@usableFromInline
struct BackgroundMode {
@usableFromInline static let processing = BackgroundMode(rawValue: "processing")
@usableFromInline static let fetch = BackgroundMode(rawValue: "fetch")

@usableFromInline let rawValue: String

@usableFromInline
init(rawValue: String) {
self.rawValue = rawValue
}
}


extension BackgroundMode: RawRepresentable, Codable, Hashable, Sendable {}
43 changes: 43 additions & 0 deletions Sources/SpeziScheduler/Notifications/LegacyTaskModel.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
//
// This source file is part of the Stanford Spezi open-source project
//
// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md)
//
// SPDX-License-Identifier: MIT
//

import Foundation
import UserNotifications


/// Minimal model of the legacy event model to retrieve data to provide some interoperability with the legacy version.
struct LegacyEventModel {
let notification: UUID?
}


/// Minimal model of the legacy task model to retrieve data to provide some interoperability with the legacy version.
struct LegacyTaskModel {
let id: UUID
let notifications: Bool
let events: [LegacyEventModel]
}


extension LegacyEventModel: Decodable, Hashable, Sendable {}


extension LegacyTaskModel: Decodable, Hashable, Sendable {}


extension LegacyEventModel {
func cancelNotification() {
guard let notification else {
return
}

let center = UNUserNotificationCenter.current()
center.removeDeliveredNotifications(withIdentifiers: [notification.uuidString])
center.removePendingNotificationRequests(withIdentifiers: [notification.uuidString])
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
//
// This source file is part of the Stanford Spezi open-source project
//
// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md)
//
// SPDX-License-Identifier: MIT
//

import SwiftUI


struct NotificationScenePhaseScheduling: ViewModifier {
@Environment(Scheduler.self)
private var scheduler: Scheduler? // modifier is injected by SchedulerNotifications and it doesn't have a direct scheduler dependency
@Environment(SchedulerNotifications.self)
private var schedulerNotifications

@Environment(\.scenePhase)
private var scenePhase

nonisolated init() {}

func body(content: Content) -> some View {
content
.onChange(of: scenePhase, initial: true) {
guard let scheduler else {
// by the time the modifier appears, the scheduler is injected
return
}

switch scenePhase {
case .active:
_Concurrency.Task { @MainActor in
await schedulerNotifications.checkForInitialScheduling(scheduler: scheduler)
}
case .background, .inactive:
break
@unknown default:
break
}
}
}
}
23 changes: 23 additions & 0 deletions Sources/SpeziScheduler/Notifications/NotificationThread.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
//
// This source file is part of the Stanford Spezi open-source project
//
// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md)
//
// SPDX-License-Identifier: MIT
//


/// Determine the behavior how task notifications are automatically grouped.
public enum NotificationThread {
/// All task notifications are put into the global SpeziScheduler notification thread.
case global
/// The event notification are grouped by task.
case task
/// Specify a custom thread identifier.
case custom(String)
/// No thread identifier is specified and grouping is done automatically by iOS.
case none
}


extension NotificationThread: Sendable, Hashable, Codable {}
37 changes: 37 additions & 0 deletions Sources/SpeziScheduler/Notifications/NotificationTime.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
//
// This source file is part of the Stanford Spezi open-source project
//
// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md)
//
// SPDX-License-Identifier: MIT
//


/// Hour, minute and second date components to determine the scheduled time of a notification.
public struct NotificationTime {
/// The hour component.
public let hour: Int
/// The minute component.
public let minute: Int
/// The second component
public let second: Int


/// Create a new notification time.
/// - Parameters:
/// - hour: The hour component.
/// - minute: The minute component.
/// - second: The second component
public init(hour: Int, minute: Int = 0, second: Int = 0) {
self.hour = hour
self.minute = minute
self.second = second

precondition((0..<24).contains(hour), "hour must be between 0 and 23")
precondition((0..<60).contains(minute), "minute must be between 0 and 59")
precondition((0..<60).contains(second), "second must be between 0 and 59")
}
}


extension NotificationTime: Sendable, Codable, Hashable {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
//
// This source file is part of the Stanford Spezi open-source project
//
// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md)
//
// SPDX-License-Identifier: MIT
//


@usableFromInline
struct PermittedBackgroundTaskIdentifier {
@usableFromInline static let speziSchedulerNotificationsScheduling = PermittedBackgroundTaskIdentifier(
rawValue: "edu.stanford.spezi.scheduler.notifications-scheduling"
)

@usableFromInline let rawValue: String

@usableFromInline
init(rawValue: String) {
self.rawValue = rawValue
}
}


extension PermittedBackgroundTaskIdentifier: RawRepresentable, Hashable, Sendable, Codable {}
Loading

0 comments on commit 7ba1ed8

Please sign in to comment.