From 9e81c8947166445185e3d17349580e082533e627 Mon Sep 17 00:00:00 2001 From: Jose Olarte III Date: Tue, 9 Jun 2026 19:15:16 +0800 Subject: [PATCH] fix(ios): separate Daily Reminder identifiers from api_ namespace Daily Reminders now use daily_ for UNNotificationCenter requests instead of sharing api_ 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. --- ios/Plugin/DailyNotificationPlugin.swift | 83 ++++++++++++++---------- src/definitions.ts | 10 +-- 2 files changed, 53 insertions(+), 40 deletions(-) diff --git a/ios/Plugin/DailyNotificationPlugin.swift b/ios/Plugin/DailyNotificationPlugin.swift index 619e209..e59c680 100644 --- a/ios/Plugin/DailyNotificationPlugin.swift +++ b/ios/Plugin/DailyNotificationPlugin.swift @@ -32,9 +32,11 @@ public class DailyNotificationPlugin: CAPPlugin { // Background task identifiers private let fetchTaskIdentifier = "org.timesafari.dailynotification.fetch" 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_" - /// 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" /// UserDefaults key for persisted dual schedule config (userNotification + relationship) for relationship resolution when fetch completes. 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) { do { performCancelDualSchedule() @@ -1085,7 +1087,7 @@ public class DailyNotificationPlugin: CAPPlugin { 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 { var comp = DateComponents() comp.hour = hour @@ -1101,26 +1103,29 @@ public class DailyNotificationPlugin: CAPPlugin { "\(apiNotificationPrefix)\(epochMillis)" } - /// Reads persisted millis for a reminder, or derives from stored `time` (HH:mm) for legacy rows. - private func apiEpochMillis(from reminder: [String: Any]) -> Int64? { - if let n = reminder["predictiveEpochMillis"] as? NSNumber { - return n.int64Value + private func dailyReminderNotificationId(reminderId: String) -> String { + "\(dailyReminderNotificationPrefix)\(reminderId)" + } + + /// Pre-separation Daily Reminders used `api_`. 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 } - 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) + notificationCenter.removePendingNotificationRequests(withIdentifiers: ids) } // 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) { NSLog("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 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( identifier: requestId, @@ -1302,13 +1313,10 @@ public class DailyNotificationPlugin: CAPPlugin { return } - // Cancel the notification (ID from stored epoch millis) + // Cancel pending Daily Reminder notification(s) for this logical id. let reminders = getRemindersFromUserDefaults() - if let stored = reminders.first(where: { ($0["id"] as? String) == reminderId }), - let epochMillis = apiEpochMillis(from: stored) { - let requestId = apiNotificationId(epochMillis: epochMillis) - notificationCenter.removePendingNotificationRequests(withIdentifiers: [requestId]) - } + let stored = reminders.first(where: { ($0["id"] as? String) == reminderId }) + cancelDailyReminderNotificationRequests(reminderId: reminderId, storedReminder: stored) // Delegate to storage for reminder removal removeReminderFromUserDefaults(id: reminderId) @@ -1321,17 +1329,23 @@ public class DailyNotificationPlugin: CAPPlugin { // Get pending notifications 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 let reminders = self.getRemindersFromUserDefaults() var result: [[String: Any]] = [] for reminder in reminders { - let expectedId: String? = self.apiEpochMillis(from: reminder).map { - self.apiNotificationId(epochMillis: $0) + guard let logicalId = reminder["id"] as? String else { + 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 reminderInfo["isScheduled"] = isScheduled result.append(reminderInfo) @@ -1351,11 +1365,8 @@ public class DailyNotificationPlugin: CAPPlugin { // Cancel existing reminder (before UserDefaults update) let remindersBeforeUpdate = getRemindersFromUserDefaults() - if let stored = remindersBeforeUpdate.first(where: { ($0["id"] as? String) == reminderId }), - let epochMillis = apiEpochMillis(from: stored) { - let oldRequestId = apiNotificationId(epochMillis: epochMillis) - notificationCenter.removePendingNotificationRequests(withIdentifiers: [oldRequestId]) - } + let storedBeforeUpdate = remindersBeforeUpdate.first(where: { ($0["id"] as? String) == reminderId }) + cancelDailyReminderNotificationRequests(reminderId: reminderId, storedReminder: storedBeforeUpdate) // Update in UserDefaults let title = call.getString("title") @@ -1421,7 +1432,7 @@ public class DailyNotificationPlugin: CAPPlugin { ) let epochMillis = epochMillisNextDailyOccurrence(hour: hour, minute: minute) - let requestId = apiNotificationId(epochMillis: epochMillis) + let requestId = dailyReminderNotificationId(reminderId: reminderId) let request = UNNotificationRequest( identifier: requestId, diff --git a/src/definitions.ts b/src/definitions.ts index f7dd239..3fec41e 100644 --- a/src/definitions.ts +++ b/src/definitions.ts @@ -608,8 +608,9 @@ export interface DailyNotificationPlugin { */ scheduleApiNotifications(options: { timestamps: number[] }): Promise; /** - * Cancel API-managed notifications (`api_*`) without affecting Daily Reminders, - * user schedules, dual schedules, or fetch jobs. + * Cancel API-managed notifications (`api_*`) without affecting Daily Reminders + * (iOS: `daily_*`; Android: `daily_notification` / caller id), user schedules, + * dual schedules, or fetch jobs. */ clearApiNotifications(): Promise; getNotificationStatus(): Promise; @@ -1044,8 +1045,9 @@ export interface DailyNotificationPlugin { // Static Daily Reminder Methods /** - * Schedule a simple daily reminder notification - * No network content required - just static text + * Schedule a simple daily reminder notification. + * No network content required — just static text. + * iOS uses `daily_` identifiers (separate from API-managed `api_*`). */ scheduleDailyReminder(options: DailyReminderOptions): Promise;