I have a loop that schedules 64 notifications for a calendar app and it works well in iOS, but it seems that on Catalyst it schedules fewer than 64 notifications. The number varies, and to make things worse after a restart of the device it will schedule 64 until it starts to fail again. It usually schedules between 49 and 54. Things I can confirm:
- there are 64 filteredEvents
- the loop does run 64 times
- the limit on Catalyst is actually 100, so it should work
- notifications are authorised (if they weren't I'd get 0 scheduled)
- there is no particular order to the ones that seem to fail
- I can confirm that it's not just that pendingNotificationRequests().count is wrong, the notifications aren't getting delivered.
- I suspected a concurrency issue, perhaps scheduling them too fast, and I tried a delay of 100ms between each call to center.add(request), but it made no difference
- I used to not use the async version of center.add(request) and I hoped adding it would fix the issue, but it seems that was wrong too.
- Setting the limit to 48 seems to work, but I don't know if that is still going to be the case on various devices, since I don't know what is causing this.
- I tried replicating this with a simpler setup and a different project and I'm getting the same issue.
Any suggestions?
import Foundation
import UserNotifications
class UserNotifications {
static let center = UNUserNotificationCenter.current()
static var isUpdatingNotifications = false
static func requestAuthorisation() async {
do { try await center.requestAuthorization(options: [.alert, .sound, .badge]) }
catch { print("Error authorising notifications: \(error)") }
}
static func addScheduledNotification(date: Date,
identifier: String,
title: String?,
body: String? = nil,
sound: String? = nil,
actions: UNNotificationCategory? = nil,
interruptionLevel: UNNotificationInterruptionLevel = .active,
userInfo: [AnyHashable : Any]? = nil) async throws {
let trigger = UNCalendarNotificationTrigger(dateMatching: cal.dateComponents([.second, .minute, .hour, .day, .month, .year], from: date), repeats: false)
let content = content(title: title, body: body, sound: sound, actions: actions)
content.userInfo = userInfo ?? [:]
content.interruptionLevel = interruptionLevel
let request = UNNotificationRequest(identifier: identifier, content: content, trigger: trigger)
try await center.add(request)
}
static func updateNotifications(from: String) async {
guard !isUpdatingNotifications else { return }
isUpdatingNotifications = true
// 1. We clear all delivered and pending notifications
center.removeAllPendingNotificationRequests()
// 2. EventKit has on one occasion not recognised the alarms for birthday events, returning null instead, so we will add an alert here temporarily.
let events = DataStorage.events.map { event in
if event.title.contains("Birthday") && !event.hasAlarms { event.addAlarm(EKAlarm(relativeOffset: -54000.0)) }
return event
}
// 3. We filter all the relevant events and limit to 64. Please note that the limit on the mac is 100, but there's no need for so many.
let filteredEvents = events.filter({ event in
(event.alarms?.contains(where: { alarm in
(alarm.absoluteDate?.timeIntervalSinceNow ?? event.startDate.addingTimeInterval(alarm.relativeOffset).timeIntervalSinceNow) > 0
}) ?? false) && event.currentUserStatus != .declined && event.currentUserStatus != .pending
}).sorted { $0.startDate < $1.startDate }.prefix(64)
// 4. We then schedule 64 notifications
var notificationsScheduled = 0
let limit = 64
outerLoop: for event in filteredEvents {
guard let alarms = event.alarms?.excludingRepeatingAlarms() else { continue }
for alarm in alarms {
if notificationsScheduled >= limit { break outerLoop }
let date = alarm.absoluteDate ?? event.startDate.addingTimeInterval(alarm.relativeOffset)
guard date.isInTheFuture else { continue }
var category: UNNotificationCategory? = nil
if event.videoConferenceDetails != nil {
let action = UNNotificationAction(identifier: "JOIN_MEETING", title: "Join meeting", options: [.foreground])
category = UNNotificationCategory(identifier: "MEETING_INVITE_CATEGORY", actions: [action], intentIdentifiers: [], options: [])
}
do {
try await UserNotifications.addScheduledNotification(date: date,
identifier: UUID().uuidString,
title: event.title,
body: event.body(forAlarm: alarm, alertDate: date),
sound: "Notification5.mp3",
actions: category,
interruptionLevel: .timeSensitive,
userInfo: ["title": event.title ?? "",
"startDate": event.startDate ?? Date(),
"meetingUrl": event.videoConferenceDetails?.0.absoluteString ?? ""])
} catch {
print("Failed to add notification #\(notificationsScheduled)")
}
notificationsScheduled += 1
}
}
print("\(await UserNotifications.center.pendingNotificationRequests().count) notifications scheduled")
isUpdatingNotifications = false
}
}
extension Array where Element == EKAlarm {
func excludingRepeatingAlarms() -> [Element] {
return self.reduce(into: [Element]()) { (result, alarm) in
if !(result.contains(where: { $0.absoluteDate == alarm.absoluteDate && $0.relativeOffset == alarm.relativeOffset })) {
result.append(alarm)
}
}
}
}