Skip to content

Commit

Permalink
Initial notification scheduling attempt
Browse files Browse the repository at this point in the history
  • Loading branch information
Supereg committed Sep 13, 2024
1 parent b08e806 commit 036e52a
Show file tree
Hide file tree
Showing 8 changed files with 164 additions and 6 deletions.
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
2 changes: 1 addition & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ let package = Package(
],
dependencies: [
.package(url: "https://github.com/StanfordSpezi/SpeziFoundation", from: "2.0.0-beta.2"),
.package(url: "https://github.com/StanfordSpezi/Spezi", from: "1.7.0"),
.package(url: "https://github.com/StanfordSpezi/Spezi", branch: "feature/application-for-swiftui"),
.package(url: "https://github.com/StanfordSpezi/SpeziViews", branch: "feature/additional-infrastructure"),
.package(url: "https://github.com/StanfordSpezi/SpeziStorage", from: "1.1.2"),
.package(url: "https://github.com/swiftlang/swift-syntax", from: "600.0.0-prerelease-2024-08-14"),
Expand Down
17 changes: 17 additions & 0 deletions Sources/SpeziScheduler/EventQuery.swift
Original file line number Diff line number Diff line change
Expand Up @@ -202,3 +202,20 @@ func measure<T, C: Clock>(
try action()
#endif
}

func measure<T, C: Clock>(
isolation: isolated (any Actor)? = #isolation,
clock: C = ContinuousClock(),
name: @autoclosure @escaping () -> StaticString,
_ action: () async throws -> sending T
) async rethrows -> sending T where C.Instant.Duration == Duration {
#if DEBUG || TEST
let start = clock.now
let result = try await action()
let end = clock.now
logger.debug("Performing \(name()) took \(start.duration(to: end))")
return result
#else
try action()
#endif
}
3 changes: 3 additions & 0 deletions Sources/SpeziScheduler/Schedule/Schedule.swift
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,9 @@ public struct Schedule {

private var recurrenceRule: Data?

// TODO: add a notification interval hint? => wee need DateComponents (=> we can provide that for daily and weekly?)

Check failure on line 56 in Sources/SpeziScheduler/Schedule/Schedule.swift

View workflow job for this annotation

GitHub Actions / SwiftLint / SwiftLint / SwiftLint

Todo Violation: TODOs should be resolved (add a notification interval hi...) (todo)
// TODO: we can only do that if we are post the startDate (e.g., daily but only starting next week) =>

Check failure on line 57 in Sources/SpeziScheduler/Schedule/Schedule.swift

View workflow job for this annotation

GitHub Actions / SwiftLint / SwiftLint / SwiftLint

Todo Violation: TODOs should be resolved (we can only do that if we are ...) (todo)

/// The duration of a single occurrence.
///
/// If the duration is `nil`, the schedule provides a start date only. The end date will be automatically chosen to be end of day.
Expand Down
124 changes: 124 additions & 0 deletions Sources/SpeziScheduler/Scheduler+Notifications.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
//
// 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 Spezi
import SwiftData
@preconcurrency import UserNotifications

Check warning on line 12 in Sources/SpeziScheduler/Scheduler+Notifications.swift

View workflow job for this annotation

GitHub Actions / Build and Test Swift Package / Test using xcodebuild or run fastlane

'@preconcurrency' attribute on module 'UserNotifications' has no effect

Check warning on line 12 in Sources/SpeziScheduler/Scheduler+Notifications.swift

View workflow job for this annotation

GitHub Actions / Build and Test Swift Package / Test using xcodebuild or run fastlane

'@preconcurrency' attribute on module 'UserNotifications' has no effect

Check warning on line 12 in Sources/SpeziScheduler/Scheduler+Notifications.swift

View workflow job for this annotation

GitHub Actions / Build and Test Swift Package / Test using xcodebuild or run fastlane

'@preconcurrency' attribute on module 'UserNotifications' has no effect

Check warning on line 12 in Sources/SpeziScheduler/Scheduler+Notifications.swift

View workflow job for this annotation

GitHub Actions / Build and Test Swift Package visionOS / Test using xcodebuild or run fastlane

'@preconcurrency' attribute on module 'UserNotifications' has no effect

Check warning on line 12 in Sources/SpeziScheduler/Scheduler+Notifications.swift

View workflow job for this annotation

GitHub Actions / Build and Test Swift Package visionOS / Test using xcodebuild or run fastlane

'@preconcurrency' attribute on module 'UserNotifications' has no effect

Check warning on line 12 in Sources/SpeziScheduler/Scheduler+Notifications.swift

View workflow job for this annotation

GitHub Actions / Build and Test Swift Package visionOS / Test using xcodebuild or run fastlane

'@preconcurrency' attribute on module 'UserNotifications' has no effect

Check warning on line 12 in Sources/SpeziScheduler/Scheduler+Notifications.swift

View workflow job for this annotation

GitHub Actions / Build and Test UI Tests / Test using xcodebuild or run fastlane

'@preconcurrency' attribute on module 'UserNotifications' has no effect

Check warning on line 12 in Sources/SpeziScheduler/Scheduler+Notifications.swift

View workflow job for this annotation

GitHub Actions / Build and Test UI Tests / Test using xcodebuild or run fastlane

'@preconcurrency' attribute on module 'UserNotifications' has no effect

Check warning on line 12 in Sources/SpeziScheduler/Scheduler+Notifications.swift

View workflow job for this annotation

GitHub Actions / Build and Test UI Tests / Test using xcodebuild or run fastlane

'@preconcurrency' attribute on module 'UserNotifications' has no effect


extension Scheduler {
private var schedulerLimit: Int {
30
// TODO: additional time limit (30 or 1 month in advance?) and then background tasks?

Check failure on line 18 in Sources/SpeziScheduler/Scheduler+Notifications.swift

View workflow job for this annotation

GitHub Actions / SwiftLint / SwiftLint / SwiftLint

Todo Violation: TODOs should be resolved (additional time limit (30 or 1...) (todo)
}

private var schedulerTimeLimit: TimeInterval {
// default limit is 4 weeks
1 * 60 * 60 * 24 * 7 * 4
}
// TODO: cancel all legacy notifications!

Check failure on line 25 in Sources/SpeziScheduler/Scheduler+Notifications.swift

View workflow job for this annotation

GitHub Actions / SwiftLint / SwiftLint / SwiftLint

Todo Violation: TODOs should be resolved (cancel all legacy notification...) (todo)

func updateNotifications() async throws {
// TODO: this might qualify as a processing task?

Check failure on line 28 in Sources/SpeziScheduler/Scheduler+Notifications.swift

View workflow job for this annotation

GitHub Actions / SwiftLint / SwiftLint / SwiftLint

Todo Violation: TODOs should be resolved (this might qualify as a proces...) (todo)
// TODO: add background fetch task (if enabled) to reschedule every week?

Check failure on line 29 in Sources/SpeziScheduler/Scheduler+Notifications.swift

View workflow job for this annotation

GitHub Actions / SwiftLint / SwiftLint / SwiftLint

Todo Violation: TODOs should be resolved (add background fetch task (if ...) (todo)
// TODO: then reschedule in the background on the 75% of the latest scheduled event! (but max a week?)

Check failure on line 30 in Sources/SpeziScheduler/Scheduler+Notifications.swift

View workflow job for this annotation

GitHub Actions / SwiftLint / SwiftLint / SwiftLint

Todo Violation: TODOs should be resolved (then reschedule in the backgro...) (todo)

/*
We can only schedule a limited amount of notifications at the same time.
Therefore, we do the following:
1) Just consider the events within the `schedulerTimeLimit`.
2) Sort all events by their occurrence.
3) Take the first N events and schedule their notifications, with N=`schedulerLimit`.
*/
// TODO: this might schedule way less notifications if there are only few notifications in the 4 weeks

Check failure on line 39 in Sources/SpeziScheduler/Scheduler+Notifications.swift

View workflow job for this annotation

GitHub Actions / SwiftLint / SwiftLint / SwiftLint

Todo Violation: TODOs should be resolved (this might schedule way less n...) (todo)
// TODO: on this level we cannot reason about task level scheduling (e.g., where we have a notifications shorthand via timer interval!).
// => have a separate query that only looks at tasks who's schedule can be represented using DateComponents and who's start date allows
// => for that => would need to set queryable attributes!

let now: Date = .now
let range = now..<now.addingTimeInterval(schedulerTimeLimit)

// TODO: allow to skip querying the outcomes for more efficiency!
// we query all future events until next month. We receive the result sorted
let events = try queryEvents(for: range, predicate: #Predicate { task in
task.scheduleNotifications == true
})
.prefix(schedulerLimit) // limit to the maximum amount of notifications we can schedule
// TODO: are we able to batch that?

guard !events.isEmpty else {
return // no tasks with enabled notifications!
}

// TODO: support .deliveredNotifications() + pendingNotificationRequests with sending return types

let pendingNotifications = Set(await UNUserNotificationCenter.current()
.pendingNotificationRequests()
.map { $0.identifier }
.filter { $0.starts(with: "edu.stanford.spezi.scheduler." ) })
// TODO: existing notifications

let remainingLimit = await self.notifications.remainingNotificationLimit()


// TODO: move up, if this is zero, we could just stop and assume notifications are fine? However, just a count assumption then :/
let remainingSpace = remainingLimit - pendingNotifications.count // TODO: we might generally not have enough space for stuff!
// TODO: let ourOwnReamingLimit = schedulerLimit - pendingNotifications.count

let schedulingEvents = events.prefix(remainingSpace) // TODO: do only one prefix?

guard let lastScheduledEvent = schedulingEvents.last else {

Check warning on line 76 in Sources/SpeziScheduler/Scheduler+Notifications.swift

View workflow job for this annotation

GitHub Actions / Build and Test Swift Package / Test using xcodebuild or run fastlane

value 'lastScheduledEvent' was defined but never used; consider replacing with boolean test

Check warning on line 76 in Sources/SpeziScheduler/Scheduler+Notifications.swift

View workflow job for this annotation

GitHub Actions / Build and Test Swift Package / Test using xcodebuild or run fastlane

value 'lastScheduledEvent' was defined but never used; consider replacing with boolean test

Check warning on line 76 in Sources/SpeziScheduler/Scheduler+Notifications.swift

View workflow job for this annotation

GitHub Actions / Build and Test Swift Package / Test using xcodebuild or run fastlane

value 'lastScheduledEvent' was defined but never used; consider replacing with boolean test

Check warning on line 76 in Sources/SpeziScheduler/Scheduler+Notifications.swift

View workflow job for this annotation

GitHub Actions / Build and Test Swift Package visionOS / Test using xcodebuild or run fastlane

value 'lastScheduledEvent' was defined but never used; consider replacing with boolean test

Check warning on line 76 in Sources/SpeziScheduler/Scheduler+Notifications.swift

View workflow job for this annotation

GitHub Actions / Build and Test Swift Package visionOS / Test using xcodebuild or run fastlane

value 'lastScheduledEvent' was defined but never used; consider replacing with boolean test

Check warning on line 76 in Sources/SpeziScheduler/Scheduler+Notifications.swift

View workflow job for this annotation

GitHub Actions / Build and Test Swift Package visionOS / Test using xcodebuild or run fastlane

value 'lastScheduledEvent' was defined but never used; consider replacing with boolean test

Check warning on line 76 in Sources/SpeziScheduler/Scheduler+Notifications.swift

View workflow job for this annotation

GitHub Actions / Build and Test UI Tests / Test using xcodebuild or run fastlane

value 'lastScheduledEvent' was defined but never used; consider replacing with boolean test

Check warning on line 76 in Sources/SpeziScheduler/Scheduler+Notifications.swift

View workflow job for this annotation

GitHub Actions / Build and Test UI Tests / Test using xcodebuild or run fastlane

value 'lastScheduledEvent' was defined but never used; consider replacing with boolean test

Check warning on line 76 in Sources/SpeziScheduler/Scheduler+Notifications.swift

View workflow job for this annotation

GitHub Actions / Build and Test UI Tests / Test using xcodebuild or run fastlane

value 'lastScheduledEvent' was defined but never used; consider replacing with boolean test
return // nothing got scheduled!
}

for event in schedulingEvents {
// TODO: we might have notifications that got removed! (due to task changes (new task versions))
// => make sure that we also do the reverse check of, which events are not present anymore?
// => need information that pending notifications are part of our schedule? just cancel anything more in the future?
guard !pendingNotifications.contains(event.notificationId) else {
// TODO: improve how we match existing notifications
continue // TODO: if we allow to customize, we might need to check if the notification changed?
}

do {
try await measure(name: "Notification Request") {
try await event.scheduleNotification(notifications: notifications)
}
} catch {
// TODO: anything we can do?
logger.error("Failed to register remote notification for task \(event.task.id) for date \(event.occurrence.start)")
}
}
}
}


// TODO: eventually move somewhere else!
extension Event {
var notificationId: String {
"edu.stanford.spezi.scheduler.\(occurrence.start)"
}

fileprivate func scheduleNotification(

Check failure on line 108 in Sources/SpeziScheduler/Scheduler+Notifications.swift

View workflow job for this annotation

GitHub Actions / SwiftLint / SwiftLint / SwiftLint

Function Default Parameter at End Violation: Prefer to locate parameters with defaults toward the end of the parameter list (function_default_parameter_at_end)
isolation: isolated (any Actor)? = #isolation,
notifications: LocalNotifications
) async throws {
let content = UNMutableNotificationContent()
content.title = String(localized: task.title) // TODO: check, localization might change!
// TODO: there is otherwise localizedUserNotificationString(forKey:arguments:)
content.body = String(localized: task.instructions) // TODO: instructions might be longer! specify custom?

let interval = task.schedule.start.timeIntervalSince(.now)
let trigger = UNTimeIntervalNotificationTrigger(timeInterval: interval, repeats: false)

let request = UNNotificationRequest(identifier: notificationId, content: content, trigger: trigger)

try await notifications.add(request: request)
}
}
8 changes: 7 additions & 1 deletion Sources/SpeziScheduler/Scheduler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -78,11 +78,14 @@ public final class Scheduler {
private static let purgeLegacyStorage = false

@Application(\.logger)
private var logger
var logger

@Dependency(LocalStorage.self)
private var localStorage

@Dependency(LocalNotifications.self)
var notifications

private var _container: Result<ModelContainer, Error>?

private var container: ModelContainer {
Expand Down Expand Up @@ -206,6 +209,7 @@ public final class Scheduler {
category: Task.Category? = nil,
schedule: Schedule,
completionPolicy: AllowedCompletionPolicy = .sameDay,
scheduleNotifications: Bool = false,
tags: [String]? = nil, // swiftlint:disable:this discouraged_optional_collection
effectiveFrom: Date = .now,
with contextClosure: ((inout Task.Context) -> Void)? = nil
Expand Down Expand Up @@ -239,6 +243,7 @@ public final class Scheduler {
category: category,
schedule: schedule,
completionPolicy: completionPolicy,
scheduleNotifications: scheduleNotifications,
tags: tags,
effectiveFrom: effectiveFrom,
with: contextClosure
Expand All @@ -257,6 +262,7 @@ public final class Scheduler {
category: category,
schedule: schedule,
completionPolicy: completionPolicy,
scheduleNotifications: scheduleNotifications,
tags: tags ?? [],
effectiveFrom: effectiveFrom,
with: contextClosure ?? { _ in }
Expand Down
11 changes: 11 additions & 0 deletions Sources/SpeziScheduler/Task/Task.swift
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,8 @@ public final class Task {
/// The policy to decide when an event can be completed by the user.
public private(set) var completionPolicy: AllowedCompletionPolicy

public private(set) var scheduleNotifications: Bool

Check failure on line 120 in Sources/SpeziScheduler/Task/Task.swift

View workflow job for this annotation

GitHub Actions / SwiftLint / SwiftLint / SwiftLint

Missing Docs Violation: public declarations should be documented (missing_docs)

/// Tags associated with the task.
///
/// This is a custom list of tags that can be useful to categorize or group tasks and make it easier to query
Expand Down Expand Up @@ -161,6 +163,7 @@ public final class Task {
category: Category?,
schedule: Schedule,
completionPolicy: AllowedCompletionPolicy,
scheduleNotifications: Bool,
tags: [String],
effectiveFrom: Date,
context: Context
Expand All @@ -171,6 +174,7 @@ public final class Task {
self.category = category
self.schedule = schedule
self.completionPolicy = completionPolicy
self.scheduleNotifications = scheduleNotifications
self.outcomes = []
self.tags = tags
self.effectiveFrom = effectiveFrom
Expand All @@ -187,6 +191,7 @@ public final class Task {
category: Category?,
schedule: Schedule,
completionPolicy: AllowedCompletionPolicy,
scheduleNotifications: Bool,
tags: [String],
effectiveFrom: Date,
with contextClosure: (inout Context) -> Void = { _ in }
Expand All @@ -201,6 +206,7 @@ public final class Task {
category: category,
schedule: schedule,
completionPolicy: completionPolicy,
scheduleNotifications: scheduleNotifications,
tags: tags,
effectiveFrom: effectiveFrom,
context: context
Expand Down Expand Up @@ -229,6 +235,7 @@ public final class Task {
category: Category? = nil,
schedule: Schedule? = nil,
completionPolicy: AllowedCompletionPolicy? = nil,
scheduleNotifications: Bool? = nil, // swiftlint:disable:this discouraged_optional_boolean
tags: [String]? = nil, // swiftlint:disable:this discouraged_optional_collection
effectiveFrom: Date = .now,
with contextClosure: ((inout Context) -> Void)? = nil
Expand All @@ -240,6 +247,7 @@ public final class Task {
category: category,
schedule: schedule,
completionPolicy: completionPolicy,
scheduleNotifications: scheduleNotifications,
effectiveFrom: effectiveFrom,
with: contextClosure
)
Expand All @@ -252,6 +260,7 @@ public final class Task {
category: Category? = nil,
schedule: Schedule? = nil,
completionPolicy: AllowedCompletionPolicy? = nil,
scheduleNotifications: Bool? = nil, // swiftlint:disable:this discouraged_optional_boolean
tags: [String]? = nil, // swiftlint:disable:this discouraged_optional_collection
effectiveFrom: Date = .now,
with contextClosure: ((inout Context) -> Void)? = nil
Expand All @@ -275,6 +284,7 @@ public final class Task {
|| didChange(schedule, for: \.schedule)
|| didChange(completionPolicy, for: \.completionPolicy)
|| didChange(tags, for: \.tags)
|| didChange(scheduleNotifications, for: \.scheduleNotifications)
|| didChange(context?.userInfo, for: \.userInfo) else {
return (self, false) // nothing changed
}
Expand All @@ -300,6 +310,7 @@ public final class Task {
category: category ?? self.category,
schedule: schedule ?? self.schedule,
completionPolicy: completionPolicy ?? self.completionPolicy,
scheduleNotifications: scheduleNotifications ?? self.scheduleNotifications,
tags: tags ?? self.tags,
effectiveFrom: effectiveFrom,
context: context ?? Context()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ public struct SchedulerSampleData: PreviewModifier {
category: .questionnaire,
schedule: .daily(hour: 17, minute: 0, startingAt: .today),
completionPolicy: .sameDay,
scheduleNotifications: false, // TODO: or should it?
tags: [],
effectiveFrom: .today // make sure test task always starts from the start of today
)
Expand Down

0 comments on commit 036e52a

Please sign in to comment.