fix(ios): use deterministic predictive notification identifiers

Replace reminder_* and random test alarm IDs with predictive_<Int>
so UNUserNotificationCenter replaces pending requests instead of
stacking. Daily reminders key off local HH:mm; testAlarm uses the
scheduled fire second. Dual notification id unchanged.
This commit is contained in:
Jose Olarte III
2026-05-05 14:53:06 +08:00
parent a5395082f6
commit 26f1ff0e08

View File

@@ -32,7 +32,9 @@ 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"
/// Stable identifier for the dual (New Activity) user notification. Used for replace and cancelDualSchedule; must not conflict with Daily Reminder (`reminder_<id>`). /// Prefix for deterministic UNNotificationRequest identifiers built as `predictive_\(Int(timestamp))` so rescheduling the same slot replaces instead of stacking.
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_*`).
private let dualNotificationRequestIdentifier = "org.timesafari.dailynotification.dual" private let dualNotificationRequestIdentifier = "org.timesafari.dailynotification.dual"
/// UserDefaults key for persisted dual schedule config (userNotification + relationship) for relationship resolution when fetch completes. /// UserDefaults key for persisted dual schedule config (userNotification + relationship) for relationship resolution when fetch completes.
private let dualScheduleConfigKey = "dual_schedule_config" private let dualScheduleConfigKey = "dual_schedule_config"
@@ -428,7 +430,7 @@ public class DailyNotificationPlugin: CAPPlugin {
} }
} }
/// Cancel the dual (New Activity) schedule only. Does not affect Daily Reminder (reminder_*). /// Cancel the dual (New Activity) schedule only. Does not affect Daily Reminder (`predictive_*`).
@objc func cancelDualSchedule(_ call: CAPPluginCall) { @objc func cancelDualSchedule(_ call: CAPPluginCall) {
do { do {
performCancelDualSchedule() performCancelDualSchedule()
@@ -1083,6 +1085,28 @@ 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.
private func predictiveReminderTimestamp(hour: Int, minute: Int) -> Int {
hour * 3600 + minute * 60
}
private func predictiveReminderRequestId(hour: Int, minute: Int) -> String {
let timestamp = predictiveReminderTimestamp(hour: hour, minute: minute)
return "\(predictiveNotificationPrefix)\(timestamp)"
}
private func predictiveReminderRequestId(fromTimeHHmm time: String) -> String? {
let timeComponents = time.components(separatedBy: ":")
guard timeComponents.count == 2,
let hour = Int(timeComponents[0]),
let minute = Int(timeComponents[1]),
hour >= 0, hour <= 23,
minute >= 0, minute <= 59 else {
return nil
}
return predictiveReminderRequestId(hour: hour, minute: minute)
}
// MARK: - Static Daily Reminder Methods // MARK: - Static Daily Reminder Methods
@objc func scheduleDailyReminder(_ call: CAPPluginCall) { @objc func scheduleDailyReminder(_ call: CAPPluginCall) {
@@ -1140,8 +1164,9 @@ public class DailyNotificationPlugin: CAPPlugin {
repeats: repeatDaily repeats: repeatDaily
) )
let requestId = predictiveReminderRequestId(hour: hour, minute: minute)
let request = UNNotificationRequest( let request = UNNotificationRequest(
identifier: "reminder_\(id)", identifier: requestId,
content: content, content: content,
trigger: trigger trigger: trigger
) )
@@ -1177,8 +1202,13 @@ public class DailyNotificationPlugin: CAPPlugin {
return return
} }
// Cancel the notification // Cancel the notification (deterministic id from stored HH:mm)
notificationCenter.removePendingNotificationRequests(withIdentifiers: ["reminder_\(reminderId)"]) let reminders = getRemindersFromUserDefaults()
if let stored = reminders.first(where: { ($0["id"] as? String) == reminderId }),
let time = stored["time"] as? String,
let requestId = predictiveReminderRequestId(fromTimeHHmm: time) {
notificationCenter.removePendingNotificationRequests(withIdentifiers: [requestId])
}
// Delegate to storage for reminder removal // Delegate to storage for reminder removal
removeReminderFromUserDefaults(id: reminderId) removeReminderFromUserDefaults(id: reminderId)
@@ -1191,14 +1221,18 @@ public class DailyNotificationPlugin: CAPPlugin {
// Get pending notifications // Get pending notifications
notificationCenter.getPendingNotificationRequests { requests in notificationCenter.getPendingNotificationRequests { requests in
let reminderRequests = requests.filter { $0.identifier.hasPrefix("reminder_") } let reminderRequests = requests.filter { $0.identifier.hasPrefix(self.predictiveNotificationPrefix) }
// Get stored reminder data from UserDefaults // Get stored reminder data from UserDefaults
let reminders = self.getRemindersFromUserDefaults() let reminders = self.getRemindersFromUserDefaults()
var result: [[String: Any]] = [] var result: [[String: Any]] = []
for reminder in reminders { for reminder in reminders {
let isScheduled = reminderRequests.contains { $0.identifier == "reminder_\(reminder["id"] as! String)" } let expectedId: String? = {
guard let time = reminder["time"] as? String else { return nil }
return self.predictiveReminderRequestId(fromTimeHHmm: time)
}()
let isScheduled = reminderRequests.contains { expectedId != nil && $0.identifier == expectedId }
var reminderInfo = reminder var reminderInfo = reminder
reminderInfo["isScheduled"] = isScheduled reminderInfo["isScheduled"] = isScheduled
result.append(reminderInfo) result.append(reminderInfo)
@@ -1216,8 +1250,13 @@ public class DailyNotificationPlugin: CAPPlugin {
return return
} }
// Cancel existing reminder // Cancel existing reminder (slot before UserDefaults update)
notificationCenter.removePendingNotificationRequests(withIdentifiers: ["reminder_\(reminderId)"]) let remindersBeforeUpdate = getRemindersFromUserDefaults()
if let stored = remindersBeforeUpdate.first(where: { ($0["id"] as? String) == reminderId }),
let oldTime = stored["time"] as? String,
let oldRequestId = predictiveReminderRequestId(fromTimeHHmm: oldTime) {
notificationCenter.removePendingNotificationRequests(withIdentifiers: [oldRequestId])
}
// Update in UserDefaults // Update in UserDefaults
let title = call.getString("title") let title = call.getString("title")
@@ -1282,8 +1321,9 @@ public class DailyNotificationPlugin: CAPPlugin {
repeats: repeatDaily ?? true repeats: repeatDaily ?? true
) )
let requestId = predictiveReminderRequestId(hour: hour, minute: minute)
let request = UNNotificationRequest( let request = UNNotificationRequest(
identifier: "reminder_\(reminderId)", identifier: requestId,
content: content, content: content,
trigger: trigger trigger: trigger
) )
@@ -1540,10 +1580,11 @@ 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)
// Create notification request with unique ID // Deterministic id from scheduled fire time (Unix seconds) so repeat scheduling replaces instead of stacking
let notificationId = "test_alarm_\(Date().timeIntervalSince1970)" let timestamp = Int(Date().timeIntervalSince1970) + validSeconds
let requestId = "\(predictiveNotificationPrefix)\(timestamp)"
let request = UNNotificationRequest( let request = UNNotificationRequest(
identifier: notificationId, identifier: requestId,
content: notificationContent, content: notificationContent,
trigger: trigger trigger: trigger
) )