refactor(ios): unify predictive IDs to epoch ms and narrow batch APIs

Use predictive_<epochMillis> for reminders (next-occurrence ms), testAlarm
fire time, and scheduleNotifications; persist predictiveEpochMillis in
UserDefaults for cancel/update alignment.

clearAllNotifications removes only predictive_* pending and delivered
entries. scheduleNotifications is additive (no global clear) and skips
past timestamps.
This commit is contained in:
Jose Olarte III
2026-05-05 19:07:43 +08:00
parent 9db381c72b
commit 60a5a30728

View File

@@ -32,7 +32,7 @@ public class DailyNotificationPlugin: CAPPlugin {
// Background task identifiers // Background task identifiers
private let fetchTaskIdentifier = "org.timesafari.dailynotification.fetch" private let fetchTaskIdentifier = "org.timesafari.dailynotification.fetch"
private let notifyTaskIdentifier = "org.timesafari.dailynotification.notify" private let notifyTaskIdentifier = "org.timesafari.dailynotification.notify"
/// Prefix for deterministic UNNotificationRequest identifiers built as `predictive_\(Int(timestamp))` so rescheduling the same slot replaces instead of stacking. /// Prefix for deterministic UNNotificationRequest identifiers: `predictive_\(Int(timestamp))` with `timestamp` in epoch milliseconds.
private let predictiveNotificationPrefix = "predictive_" private let predictiveNotificationPrefix = "predictive_"
/// Stable identifier for the dual (New Activity) user notification. Used for replace and cancelDualSchedule; must not conflict with Daily Reminder (`predictive_*`). /// Stable identifier for the dual (New Activity) user notification. Used for replace and cancelDualSchedule; must not conflict with Daily Reminder (`predictive_*`).
private let dualNotificationRequestIdentifier = "org.timesafari.dailynotification.dual" private let dualNotificationRequestIdentifier = "org.timesafari.dailynotification.dual"
@@ -1085,53 +1085,93 @@ public class DailyNotificationPlugin: CAPPlugin {
return interval > 0 ? interval : (24 * 60 * 60) return interval > 0 ? interval : (24 * 60 * 60)
} }
/// Seconds since local midnight for the daily reminder slot; stable for the same HH:mm so re-schedules overwrite. /// Epoch milliseconds for the next local occurrence of `hour`:`minute` (used only to build predictive IDs from HH:mm schedules).
private func predictiveReminderTimestamp(hour: Int, minute: Int) -> Int { private func epochMillisNextDailyOccurrence(hour: Int, minute: Int) -> Int64 {
hour * 3600 + minute * 60 var comp = DateComponents()
comp.hour = hour
comp.minute = minute
comp.second = 0
guard let next = Calendar.current.nextDate(after: Date(), matching: comp, matchingPolicy: .nextTime) else {
return Int64(Date().timeIntervalSince1970 * 1000)
}
return Int64(next.timeIntervalSince1970 * 1000)
} }
private func predictiveReminderRequestId(hour: Int, minute: Int) -> String { private func predictiveNotificationId(epochMillis: Int64) -> String {
let timestamp = predictiveReminderTimestamp(hour: hour, minute: minute) "\(predictiveNotificationPrefix)\(epochMillis)"
return "\(predictiveNotificationPrefix)\(timestamp)"
} }
private func predictiveReminderRequestId(fromTimeHHmm time: String) -> String? { /// Reads persisted millis for a reminder, or derives from stored `time` (HH:mm) for legacy rows.
let timeComponents = time.components(separatedBy: ":") private func predictiveEpochMillis(from reminder: [String: Any]) -> Int64? {
guard timeComponents.count == 2, if let n = reminder["predictiveEpochMillis"] as? NSNumber {
let hour = Int(timeComponents[0]), return n.int64Value
let minute = Int(timeComponents[1]), }
guard let time = reminder["time"] as? String else { return nil }
let parts = time.components(separatedBy: ":")
guard parts.count == 2,
let hour = Int(parts[0]),
let minute = Int(parts[1]),
hour >= 0, hour <= 23, hour >= 0, hour <= 23,
minute >= 0, minute <= 59 else { minute >= 0, minute <= 59 else {
return nil return nil
} }
return predictiveReminderRequestId(hour: hour, minute: minute) return epochMillisNextDailyOccurrence(hour: hour, minute: minute)
} }
// MARK: - Predictive batch API (replace-all UNUserNotificationCenter) // MARK: - Predictive batch API (replace-all UNUserNotificationCenter)
/// Clears all pending and delivered user notifications. Parallel to the DB/scheduler stack; does not clear stored schedules elsewhere. /// Removes only pending and delivered notifications whose identifiers begin with `predictive_`. Does not touch dual/org IDs or other stacks.
@objc func clearAllNotifications(_ call: CAPPluginCall) { @objc func clearAllNotifications(_ call: CAPPluginCall) {
NSLog("DNP-BATCH: clearAllNotifications — removing all pending and delivered notifications") NSLog("DNP-BATCH: clearAllNotifications — removing predictive_* pending and delivered only")
print("DNP-BATCH: clearAllNotifications — removing all pending and delivered notifications") print("DNP-BATCH: clearAllNotifications — removing predictive_* pending and delivered only")
notificationCenter.removeAllPendingNotificationRequests() let center = notificationCenter
notificationCenter.removeAllDeliveredNotifications() let prefix = predictiveNotificationPrefix
call.resolve() let group = DispatchGroup()
group.enter()
center.getPendingNotificationRequests { requests in
let ids = requests.map { $0.identifier }.filter { $0.hasPrefix(prefix) }
center.removePendingNotificationRequests(withIdentifiers: ids)
NSLog("DNP-BATCH: cleared \(ids.count) pending predictive id(s)")
print("DNP-BATCH: cleared \(ids.count) pending predictive id(s)")
group.leave()
}
group.enter()
center.getDeliveredNotifications { notifications in
let ids = notifications.map { $0.request.identifier }.filter { $0.hasPrefix(prefix) }
center.removeDeliveredNotifications(withIdentifiers: ids)
NSLog("DNP-BATCH: cleared \(ids.count) delivered predictive id(s)")
print("DNP-BATCH: cleared \(ids.count) delivered predictive id(s)")
group.leave()
}
group.notify(queue: .main) {
call.resolve()
}
} }
/// Replaces all pending notifications with one-shot reminders at the given epoch millis. Clears pending first. /// Add one-shot reminders at epoch-ms timestamps. Does not remove other requests; identical IDs replace pending entries. Caller should clear first if needed.
/// Adds/overwrites predictive notifications using deterministic IDs.
/// Does NOT clear existing notifications. Caller is responsible for lifecycle.
@objc func scheduleNotifications(_ call: CAPPluginCall) { @objc func scheduleNotifications(_ call: CAPPluginCall) {
guard let timestamps = call.getArray("timestamps", Double.self) else { guard let timestamps = call.getArray("timestamps", Double.self) else {
call.reject("Missing timestamps") call.reject("Missing timestamps")
return return
} }
NSLog("DNP-BATCH: scheduleNotifications — removeAllPending then scheduling \(timestamps.count) request(s)") NSLog("DNP-BATCH: scheduleNotifications — additive scheduling for \(timestamps.count) timestamp(s)")
print("DNP-BATCH: scheduleNotifications — removeAllPending then scheduling \(timestamps.count) request(s)") print("DNP-BATCH: scheduleNotifications — additive scheduling for \(timestamps.count) timestamp(s)")
notificationCenter.removeAllPendingNotificationRequests()
for ts in timestamps { for ts in timestamps {
let date = Date(timeIntervalSince1970: ts / 1000) let date = Date(timeIntervalSince1970: ts / 1000)
let interval = max(date.timeIntervalSinceNow, 1) guard date.timeIntervalSinceNow > 0 else {
NSLog("DNP-BATCH: skip stale timestamp ts=\(ts) (not in the future)")
print("DNP-BATCH: skip stale timestamp ts=\(ts) (not in the future)")
continue
}
let interval = date.timeIntervalSinceNow
let trigger = UNTimeIntervalNotificationTrigger( let trigger = UNTimeIntervalNotificationTrigger(
timeInterval: interval, timeInterval: interval,
@@ -1142,7 +1182,7 @@ public class DailyNotificationPlugin: CAPPlugin {
content.title = "Reminder" content.title = "Reminder"
content.body = "You have a scheduled notification" content.body = "You have a scheduled notification"
let id = "predictive_\(Int(ts))" let id = "\(predictiveNotificationPrefix)\(Int(ts))"
NSLog("DNP-BATCH: scheduling ts=\(ts) interval=\(interval)s id=\(id)") NSLog("DNP-BATCH: scheduling ts=\(ts) interval=\(interval)s id=\(id)")
print("DNP-BATCH: scheduling ts=\(ts) interval=\(interval)s id=\(id)") print("DNP-BATCH: scheduling ts=\(ts) interval=\(interval)s id=\(id)")
@@ -1221,7 +1261,9 @@ public class DailyNotificationPlugin: CAPPlugin {
repeats: repeatDaily repeats: repeatDaily
) )
let requestId = predictiveReminderRequestId(hour: hour, minute: minute) let epochMillis = epochMillisNextDailyOccurrence(hour: hour, minute: minute)
let requestId = predictiveNotificationId(epochMillis: epochMillis)
let request = UNNotificationRequest( let request = UNNotificationRequest(
identifier: requestId, identifier: requestId,
content: content, content: content,
@@ -1238,7 +1280,8 @@ public class DailyNotificationPlugin: CAPPlugin {
vibration: vibration, vibration: vibration,
priority: priority, priority: priority,
repeatDaily: repeatDaily, repeatDaily: repeatDaily,
timezone: timezone timezone: timezone,
predictiveEpochMillis: epochMillis
) )
// Delegate to UNUserNotificationCenter to schedule notification // Delegate to UNUserNotificationCenter to schedule notification
@@ -1259,11 +1302,11 @@ public class DailyNotificationPlugin: CAPPlugin {
return return
} }
// Cancel the notification (deterministic id from stored HH:mm) // Cancel the notification (ID from stored epoch millis)
let reminders = getRemindersFromUserDefaults() let reminders = getRemindersFromUserDefaults()
if let stored = reminders.first(where: { ($0["id"] as? String) == reminderId }), if let stored = reminders.first(where: { ($0["id"] as? String) == reminderId }),
let time = stored["time"] as? String, let epochMillis = predictiveEpochMillis(from: stored) {
let requestId = predictiveReminderRequestId(fromTimeHHmm: time) { let requestId = predictiveNotificationId(epochMillis: epochMillis)
notificationCenter.removePendingNotificationRequests(withIdentifiers: [requestId]) notificationCenter.removePendingNotificationRequests(withIdentifiers: [requestId])
} }
@@ -1285,10 +1328,9 @@ public class DailyNotificationPlugin: CAPPlugin {
var result: [[String: Any]] = [] var result: [[String: Any]] = []
for reminder in reminders { for reminder in reminders {
let expectedId: String? = { let expectedId: String? = self.predictiveEpochMillis(from: reminder).map {
guard let time = reminder["time"] as? String else { return nil } self.predictiveNotificationId(epochMillis: $0)
return self.predictiveReminderRequestId(fromTimeHHmm: time) }
}()
let isScheduled = reminderRequests.contains { expectedId != nil && $0.identifier == expectedId } let isScheduled = reminderRequests.contains { expectedId != nil && $0.identifier == expectedId }
var reminderInfo = reminder var reminderInfo = reminder
reminderInfo["isScheduled"] = isScheduled reminderInfo["isScheduled"] = isScheduled
@@ -1307,11 +1349,11 @@ public class DailyNotificationPlugin: CAPPlugin {
return return
} }
// Cancel existing reminder (slot before UserDefaults update) // Cancel existing reminder (before UserDefaults update)
let remindersBeforeUpdate = getRemindersFromUserDefaults() let remindersBeforeUpdate = getRemindersFromUserDefaults()
if let stored = remindersBeforeUpdate.first(where: { ($0["id"] as? String) == reminderId }), if let stored = remindersBeforeUpdate.first(where: { ($0["id"] as? String) == reminderId }),
let oldTime = stored["time"] as? String, let epochMillis = predictiveEpochMillis(from: stored) {
let oldRequestId = predictiveReminderRequestId(fromTimeHHmm: oldTime) { let oldRequestId = predictiveNotificationId(epochMillis: epochMillis)
notificationCenter.removePendingNotificationRequests(withIdentifiers: [oldRequestId]) notificationCenter.removePendingNotificationRequests(withIdentifiers: [oldRequestId])
} }
@@ -1378,7 +1420,9 @@ public class DailyNotificationPlugin: CAPPlugin {
repeats: repeatDaily ?? true repeats: repeatDaily ?? true
) )
let requestId = predictiveReminderRequestId(hour: hour, minute: minute) let epochMillis = epochMillisNextDailyOccurrence(hour: hour, minute: minute)
let requestId = predictiveNotificationId(epochMillis: epochMillis)
let request = UNNotificationRequest( let request = UNNotificationRequest(
identifier: requestId, identifier: requestId,
content: content, content: content,
@@ -1391,6 +1435,18 @@ public class DailyNotificationPlugin: CAPPlugin {
if let error = error { if let error = error {
call.reject("Failed to reschedule updated reminder: \(error.localizedDescription)") call.reject("Failed to reschedule updated reminder: \(error.localizedDescription)")
} else { } else {
self.updateReminderInUserDefaults(
id: reminderId,
title: nil,
body: nil,
time: nil,
sound: nil,
vibration: nil,
priority: nil,
repeatDaily: nil,
timezone: nil,
predictiveEpochMillis: epochMillis
)
call.resolve() call.resolve()
} }
} }
@@ -1411,7 +1467,8 @@ public class DailyNotificationPlugin: CAPPlugin {
vibration: Bool, vibration: Bool,
priority: String, priority: String,
repeatDaily: Bool, repeatDaily: Bool,
timezone: String? timezone: String?,
predictiveEpochMillis: Int64
) { ) {
let reminderData: [String: Any] = [ let reminderData: [String: Any] = [
"id": id, "id": id,
@@ -1423,6 +1480,7 @@ public class DailyNotificationPlugin: CAPPlugin {
"priority": priority, "priority": priority,
"repeatDaily": repeatDaily, "repeatDaily": repeatDaily,
"timezone": timezone ?? "", "timezone": timezone ?? "",
"predictiveEpochMillis": predictiveEpochMillis,
"createdAt": Date().timeIntervalSince1970, "createdAt": Date().timeIntervalSince1970,
"lastTriggered": 0 "lastTriggered": 0
] ]
@@ -1455,7 +1513,8 @@ public class DailyNotificationPlugin: CAPPlugin {
vibration: Bool?, vibration: Bool?,
priority: String?, priority: String?,
repeatDaily: Bool?, repeatDaily: Bool?,
timezone: String? timezone: String?,
predictiveEpochMillis: Int64? = nil
) { ) {
var reminders = UserDefaults.standard.array(forKey: "daily_reminders") as? [[String: Any]] ?? [] var reminders = UserDefaults.standard.array(forKey: "daily_reminders") as? [[String: Any]] ?? []
@@ -1469,6 +1528,9 @@ public class DailyNotificationPlugin: CAPPlugin {
if let priority = priority { reminders[i]["priority"] = priority } if let priority = priority { reminders[i]["priority"] = priority }
if let repeatDaily = repeatDaily { reminders[i]["repeatDaily"] = repeatDaily } if let repeatDaily = repeatDaily { reminders[i]["repeatDaily"] = repeatDaily }
if let timezone = timezone { reminders[i]["timezone"] = timezone } if let timezone = timezone { reminders[i]["timezone"] = timezone }
if let predictiveEpochMillis = predictiveEpochMillis {
reminders[i]["predictiveEpochMillis"] = predictiveEpochMillis
}
break break
} }
} }
@@ -1637,9 +1699,9 @@ public class DailyNotificationPlugin: CAPPlugin {
// Create time interval trigger (fires in X seconds) // Create time interval trigger (fires in X seconds)
let trigger = UNTimeIntervalNotificationTrigger(timeInterval: TimeInterval(validSeconds), repeats: false) let trigger = UNTimeIntervalNotificationTrigger(timeInterval: TimeInterval(validSeconds), repeats: false)
// Deterministic id from scheduled fire time (Unix seconds) so repeat scheduling replaces instead of stacking let fireDate = Date().addingTimeInterval(TimeInterval(validSeconds))
let timestamp = Int(Date().timeIntervalSince1970) + validSeconds let epochMillis = Int64(fireDate.timeIntervalSince1970 * 1000)
let requestId = "\(predictiveNotificationPrefix)\(timestamp)" let requestId = predictiveNotificationId(epochMillis: epochMillis)
let request = UNNotificationRequest( let request = UNNotificationRequest(
identifier: requestId, identifier: requestId,
content: notificationContent, content: notificationContent,