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
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)"
}
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
/// 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)
}
return epochMillisNextDailyOccurrence(hour: hour, minute: minute)
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)
}
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,

View File

@@ -608,8 +608,9 @@ export interface DailyNotificationPlugin {
*/
scheduleApiNotifications(options: { timestamps: number[] }): Promise<void>;
/**
* 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<void>;
getNotificationStatus(): Promise<NotificationStatus>;
@@ -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_<reminderId>` identifiers (separate from API-managed `api_*`).
*/
scheduleDailyReminder(options: DailyReminderOptions): Promise<void>;