fix(ios): separate Daily Reminder identifiers from api_ namespace

Daily Reminders now use daily_<reminderId> for UNNotificationCenter
requests instead of sharing api_<epochMillis> with batch API notifications.
clearApiNotifications no longer removes new daily reminders.

Add legacy api_* cleanup on schedule/cancel/update and transitional
getScheduledReminders matching via stored predictiveEpochMillis.
Update TypeScript JSDoc for iOS identifier families.
This commit is contained in:
Jose Olarte III
2026-06-09 19:15:16 +08:00
parent 6e017aad09
commit 9e81c89471
2 changed files with 53 additions and 40 deletions

View File

@@ -32,9 +32,11 @@ 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: `api_\(Int(timestamp))` with `timestamp` in epoch milliseconds. /// Prefix for API-managed batch notification identifiers: `api_\(epochMillis)`.
private let apiNotificationPrefix = "api_" private let apiNotificationPrefix = "api_"
/// Stable identifier for the dual (New Activity) user notification. Used for replace and cancelDualSchedule; must not conflict with Daily Reminder (`api_*`). /// Prefix for Daily Reminder UNNotificationRequest identifiers: `daily_\(logicalReminderId)`.
private let dailyReminderNotificationPrefix = "daily_"
/// Stable identifier for the dual (New Activity) user notification. Used for replace and cancelDualSchedule; must not conflict with Daily Reminder (`daily_*`).
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"
@@ -430,7 +432,7 @@ public class DailyNotificationPlugin: CAPPlugin {
} }
} }
/// Cancel the dual (New Activity) schedule only. Does not affect Daily Reminder (`api_*`). /// Cancel the dual (New Activity) schedule only. Does not affect Daily Reminder (`daily_*`).
@objc func cancelDualSchedule(_ call: CAPPluginCall) { @objc func cancelDualSchedule(_ call: CAPPluginCall) {
do { do {
performCancelDualSchedule() performCancelDualSchedule()
@@ -1085,7 +1087,7 @@ public class DailyNotificationPlugin: CAPPlugin {
return interval > 0 ? interval : (24 * 60 * 60) return interval > 0 ? interval : (24 * 60 * 60)
} }
/// Epoch milliseconds for the next local occurrence of `hour`:`minute` (used only to build API notification IDs from HH:mm schedules). /// Epoch milliseconds for the next local occurrence of `hour`:`minute` (persisted as `predictiveEpochMillis` for reminder alignment).
private func epochMillisNextDailyOccurrence(hour: Int, minute: Int) -> Int64 { private func epochMillisNextDailyOccurrence(hour: Int, minute: Int) -> Int64 {
var comp = DateComponents() var comp = DateComponents()
comp.hour = hour comp.hour = hour
@@ -1101,26 +1103,29 @@ public class DailyNotificationPlugin: CAPPlugin {
"\(apiNotificationPrefix)\(epochMillis)" "\(apiNotificationPrefix)\(epochMillis)"
} }
/// Reads persisted millis for a reminder, or derives from stored `time` (HH:mm) for legacy rows. private func dailyReminderNotificationId(reminderId: String) -> String {
private func apiEpochMillis(from reminder: [String: Any]) -> Int64? { "\(dailyReminderNotificationPrefix)\(reminderId)"
if let n = reminder["predictiveEpochMillis"] as? NSNumber { }
return n.int64Value
/// Pre-separation Daily Reminders used `api_<epochMillis>`. Used only to cancel stale pending requests.
private func legacyApiReminderNotificationId(from reminder: [String: Any]) -> String? {
guard let n = reminder["predictiveEpochMillis"] as? NSNumber else { return nil }
return apiNotificationId(epochMillis: n.int64Value)
}
private func cancelDailyReminderNotificationRequests(reminderId: String, storedReminder: [String: Any]?) {
var ids = [dailyReminderNotificationId(reminderId: reminderId)]
if let storedReminder = storedReminder,
let legacyId = legacyApiReminderNotificationId(from: storedReminder),
!ids.contains(legacyId) {
ids.append(legacyId)
} }
guard let time = reminder["time"] as? String else { return nil } notificationCenter.removePendingNotificationRequests(withIdentifiers: ids)
let parts = time.components(separatedBy: ":")
guard parts.count == 2,
let hour = Int(parts[0]),
let minute = Int(parts[1]),
hour >= 0, hour <= 23,
minute >= 0, minute <= 59 else {
return nil
}
return epochMillisNextDailyOccurrence(hour: hour, minute: minute)
} }
// MARK: - API batch notifications (replace-all UNUserNotificationCenter) // MARK: - API batch notifications (replace-all UNUserNotificationCenter)
/// Removes only pending and delivered notifications whose identifiers begin with `api_`. Does not touch dual/org IDs or other stacks. /// Removes only pending and delivered API-managed notifications (`api_*`). Does not touch Daily Reminders (`daily_*`), dual/org IDs, or other stacks.
@objc func clearApiNotifications(_ call: CAPPluginCall) { @objc func clearApiNotifications(_ call: CAPPluginCall) {
NSLog("DNP-BATCH: clearApiNotifications — removing api_* pending and delivered only") NSLog("DNP-BATCH: clearApiNotifications — removing api_* pending and delivered only")
print("DNP-BATCH: clearApiNotifications — removing api_* pending and delivered only") print("DNP-BATCH: clearApiNotifications — removing api_* pending and delivered only")
@@ -1262,7 +1267,13 @@ public class DailyNotificationPlugin: CAPPlugin {
) )
let epochMillis = epochMillisNextDailyOccurrence(hour: hour, minute: minute) let epochMillis = epochMillisNextDailyOccurrence(hour: hour, minute: minute)
let requestId = apiNotificationId(epochMillis: epochMillis) let requestId = dailyReminderNotificationId(reminderId: id)
// Cancel any pre-separation `api_*` pending request for this logical reminder id.
if let existing = getRemindersFromUserDefaults().first(where: { ($0["id"] as? String) == id }),
let legacyId = legacyApiReminderNotificationId(from: existing) {
notificationCenter.removePendingNotificationRequests(withIdentifiers: [legacyId])
}
let request = UNNotificationRequest( let request = UNNotificationRequest(
identifier: requestId, identifier: requestId,
@@ -1302,13 +1313,10 @@ public class DailyNotificationPlugin: CAPPlugin {
return return
} }
// Cancel the notification (ID from stored epoch millis) // Cancel pending Daily Reminder notification(s) for this logical id.
let reminders = getRemindersFromUserDefaults() let reminders = getRemindersFromUserDefaults()
if let stored = reminders.first(where: { ($0["id"] as? String) == reminderId }), let stored = reminders.first(where: { ($0["id"] as? String) == reminderId })
let epochMillis = apiEpochMillis(from: stored) { cancelDailyReminderNotificationRequests(reminderId: reminderId, storedReminder: stored)
let requestId = apiNotificationId(epochMillis: epochMillis)
notificationCenter.removePendingNotificationRequests(withIdentifiers: [requestId])
}
// Delegate to storage for reminder removal // Delegate to storage for reminder removal
removeReminderFromUserDefaults(id: reminderId) removeReminderFromUserDefaults(id: reminderId)
@@ -1321,17 +1329,23 @@ 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(self.apiNotificationPrefix) } let pendingIds = Set(requests.map { $0.identifier })
// 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 expectedId: String? = self.apiEpochMillis(from: reminder).map { guard let logicalId = reminder["id"] as? String else {
self.apiNotificationId(epochMillis: $0) var reminderInfo = reminder
reminderInfo["isScheduled"] = false
result.append(reminderInfo)
continue
} }
let isScheduled = reminderRequests.contains { expectedId != nil && $0.identifier == expectedId } let expectedId = self.dailyReminderNotificationId(reminderId: logicalId)
let legacyId = self.legacyApiReminderNotificationId(from: reminder)
let isScheduled = pendingIds.contains(expectedId)
|| (legacyId != nil && pendingIds.contains(legacyId!))
var reminderInfo = reminder var reminderInfo = reminder
reminderInfo["isScheduled"] = isScheduled reminderInfo["isScheduled"] = isScheduled
result.append(reminderInfo) result.append(reminderInfo)
@@ -1351,11 +1365,8 @@ public class DailyNotificationPlugin: CAPPlugin {
// Cancel existing reminder (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 }), let storedBeforeUpdate = remindersBeforeUpdate.first(where: { ($0["id"] as? String) == reminderId })
let epochMillis = apiEpochMillis(from: stored) { cancelDailyReminderNotificationRequests(reminderId: reminderId, storedReminder: storedBeforeUpdate)
let oldRequestId = apiNotificationId(epochMillis: epochMillis)
notificationCenter.removePendingNotificationRequests(withIdentifiers: [oldRequestId])
}
// Update in UserDefaults // Update in UserDefaults
let title = call.getString("title") let title = call.getString("title")
@@ -1421,7 +1432,7 @@ public class DailyNotificationPlugin: CAPPlugin {
) )
let epochMillis = epochMillisNextDailyOccurrence(hour: hour, minute: minute) let epochMillis = epochMillisNextDailyOccurrence(hour: hour, minute: minute)
let requestId = apiNotificationId(epochMillis: epochMillis) let requestId = dailyReminderNotificationId(reminderId: reminderId)
let request = UNNotificationRequest( let request = UNNotificationRequest(
identifier: requestId, identifier: requestId,

View File

@@ -608,8 +608,9 @@ export interface DailyNotificationPlugin {
*/ */
scheduleApiNotifications(options: { timestamps: number[] }): Promise<void>; scheduleApiNotifications(options: { timestamps: number[] }): Promise<void>;
/** /**
* Cancel API-managed notifications (`api_*`) without affecting Daily Reminders, * Cancel API-managed notifications (`api_*`) without affecting Daily Reminders
* user schedules, dual schedules, or fetch jobs. * (iOS: `daily_*`; Android: `daily_notification` / caller id), user schedules,
* dual schedules, or fetch jobs.
*/ */
clearApiNotifications(): Promise<void>; clearApiNotifications(): Promise<void>;
getNotificationStatus(): Promise<NotificationStatus>; getNotificationStatus(): Promise<NotificationStatus>;
@@ -1044,8 +1045,9 @@ export interface DailyNotificationPlugin {
// Static Daily Reminder Methods // Static Daily Reminder Methods
/** /**
* Schedule a simple daily reminder notification * Schedule a simple daily reminder notification.
* No network content required - just static text * No network content required just static text.
* iOS uses `daily_<reminderId>` identifiers (separate from API-managed `api_*`).
*/ */
scheduleDailyReminder(options: DailyReminderOptions): Promise<void>; scheduleDailyReminder(options: DailyReminderOptions): Promise<void>;