feat(dual): complete scheduleDualNotification; add relationship (contentTimeout/fallbackBehavior)
Plugin (iOS): - Real cron parsing in calculateNextRunTime(from:); stable dual id + replace semantics; UNCalendarNotificationTrigger for daily - cancelDualSchedule() and updateDualScheduleConfig(); persist/clear dual config for relationship Plugin (Android): - cancelDualSchedule() and updateDualScheduleConfig(); FetchWorker.scheduleFetchForDual; ScheduleHelper.cancelDualSchedule; dual_notify_* id - Persist dual config; DualScheduleHelper + Worker dual branch for relationship at fire time Relationship: - iOS: replace pending dual notification when fetch completes (contentTimeout/fallbackBehavior) - Android: resolve config + content cache in Worker for dual_notify_*; show resolved title/body Doc: COMPLETION-PLAN-SCHEDULE-DUAL-NOTIFICATION.md (two types, Edit/updateDualScheduleConfig, §1.3a, status)
This commit is contained in:
@@ -32,6 +32,10 @@ public class DailyNotificationPlugin: CAPPlugin {
|
||||
// Background task identifiers
|
||||
private let fetchTaskIdentifier = "org.timesafari.dailynotification.fetch"
|
||||
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>`).
|
||||
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"
|
||||
|
||||
// Phase 1: Storage and Scheduler components
|
||||
var storage: DailyNotificationStorage?
|
||||
@@ -373,6 +377,7 @@ public class DailyNotificationPlugin: CAPPlugin {
|
||||
try strongSelf.scheduleUserNotification(config: config)
|
||||
}
|
||||
)
|
||||
saveDualScheduleConfig(config)
|
||||
call.resolve()
|
||||
} catch {
|
||||
call.reject("Dual notification scheduling failed: \(error.localizedDescription)")
|
||||
@@ -396,6 +401,123 @@ public class DailyNotificationPlugin: CAPPlugin {
|
||||
}
|
||||
}
|
||||
|
||||
/// Cancel the dual (New Activity) schedule only. Does not affect Daily Reminder (reminder_*).
|
||||
@objc func cancelDualSchedule(_ call: CAPPluginCall) {
|
||||
do {
|
||||
performCancelDualSchedule()
|
||||
call.resolve()
|
||||
} catch {
|
||||
call.reject("Cancel dual schedule failed: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
/// Cancel only the dual content-fetch task and dual user notification. Used by cancelDualSchedule() and updateDualScheduleConfig().
|
||||
private func performCancelDualSchedule() {
|
||||
backgroundTaskScheduler.cancel(taskRequestWithIdentifier: fetchTaskIdentifier)
|
||||
notificationCenter.removePendingNotificationRequests(withIdentifiers: [dualNotificationRequestIdentifier])
|
||||
UserDefaults.standard.removeObject(forKey: dualScheduleConfigKey)
|
||||
print("DNP-PLUGIN: Canceled dual schedule (fetch task + user notification)")
|
||||
}
|
||||
|
||||
@objc func updateDualScheduleConfig(_ call: CAPPluginCall) {
|
||||
guard let config = call.getObject("config"),
|
||||
let contentFetchConfig = config["contentFetch"] as? [String: Any],
|
||||
let userNotificationConfig = config["userNotification"] as? [String: Any] else {
|
||||
call.reject("Dual notification config required")
|
||||
return
|
||||
}
|
||||
do {
|
||||
performCancelDualSchedule()
|
||||
try DailyNotificationScheduleHelper.scheduleDualNotification(
|
||||
contentFetchConfig: contentFetchConfig,
|
||||
userNotificationConfig: userNotificationConfig,
|
||||
scheduleBackgroundFetch: { [weak self] config in
|
||||
guard let strongSelf = self else {
|
||||
throw NSError(domain: "DailyNotificationPlugin", code: -1, userInfo: [NSLocalizedDescriptionKey: "Plugin deallocated"])
|
||||
}
|
||||
try strongSelf.scheduleBackgroundFetch(config: config)
|
||||
},
|
||||
scheduleUserNotification: { [weak self] config in
|
||||
guard let strongSelf = self else {
|
||||
throw NSError(domain: "DailyNotificationPlugin", code: -1, userInfo: [NSLocalizedDescriptionKey: "Plugin deallocated"])
|
||||
}
|
||||
try strongSelf.scheduleUserNotification(config: config)
|
||||
}
|
||||
)
|
||||
saveDualScheduleConfig(config)
|
||||
call.resolve()
|
||||
} catch {
|
||||
call.reject("Update dual schedule failed: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
/// Persist dual schedule config (userNotification + relationship) for relationship resolution when fetch completes.
|
||||
private func saveDualScheduleConfig(_ config: [String: Any]) {
|
||||
guard config["userNotification"] != nil,
|
||||
let jsonData = try? JSONSerialization.data(withJSONObject: config),
|
||||
let jsonString = String(data: jsonData, encoding: .utf8) else { return }
|
||||
UserDefaults.standard.set(jsonString, forKey: dualScheduleConfigKey)
|
||||
}
|
||||
|
||||
/// Replace the pending dual user notification with resolved title/body (from fetched content if within contentTimeout, else default). Call after saving content in handleBackgroundFetch.
|
||||
private func updateDualNotificationWithResolvedContent(fetchedContent: NotificationContent) {
|
||||
guard let configJson = UserDefaults.standard.string(forKey: dualScheduleConfigKey),
|
||||
let configData = configJson.data(using: .utf8),
|
||||
let config = try? JSONSerialization.jsonObject(with: configData) as? [String: Any],
|
||||
let userNotification = config["userNotification"] as? [String: Any] else {
|
||||
return
|
||||
}
|
||||
let relationship = config["relationship"] as? [String: Any]
|
||||
let contentTimeoutMs = (relationship?["contentTimeout"] as? NSNumber)?.intValue ?? 300_000
|
||||
let fallbackBehavior = relationship?["fallbackBehavior"] as? String ?? "show_default"
|
||||
|
||||
let nowMs = Int64(Date().timeIntervalSince1970 * 1000)
|
||||
let useFetched = (nowMs - fetchedContent.fetchedAt) <= contentTimeoutMs
|
||||
let title: String
|
||||
let body: String
|
||||
if useFetched {
|
||||
title = fetchedContent.title
|
||||
body = fetchedContent.body
|
||||
} else if fallbackBehavior == "show_default" {
|
||||
title = userNotification["title"] as? String ?? "Daily Notification"
|
||||
body = userNotification["body"] as? String ?? "Your daily update is ready"
|
||||
} else {
|
||||
return
|
||||
}
|
||||
|
||||
let scheduleStr = userNotification["schedule"] as? String ?? "0 9 * * *"
|
||||
let parts = scheduleStr.trimmingCharacters(in: .whitespaces).split(separator: " ").map(String.init)
|
||||
var hour = 9, minute = 0
|
||||
if parts.count >= 2, let m = Int(parts[0]), let h = Int(parts[1]), m >= 0, m <= 59, h >= 0, h <= 23 {
|
||||
minute = m
|
||||
hour = h
|
||||
}
|
||||
var dateComp = DateComponents()
|
||||
dateComp.hour = hour
|
||||
dateComp.minute = minute
|
||||
dateComp.second = 0
|
||||
let cal = Calendar.current
|
||||
guard let nextDate = cal.nextDate(after: Date(), matching: dateComp, matchingPolicy: .nextTime), nextDate.timeIntervalSinceNow > 0 else {
|
||||
print("DNP-FETCH: Dual notify time already passed, skipping notification update")
|
||||
return
|
||||
}
|
||||
|
||||
let content = UNMutableNotificationContent()
|
||||
content.title = title
|
||||
content.body = body
|
||||
content.sound = (userNotification["sound"] as? Bool ?? true) ? .default : nil
|
||||
let trigger = UNCalendarNotificationTrigger(dateMatching: dateComp, repeats: true)
|
||||
notificationCenter.removePendingNotificationRequests(withIdentifiers: [dualNotificationRequestIdentifier])
|
||||
let request = UNNotificationRequest(identifier: dualNotificationRequestIdentifier, content: content, trigger: trigger)
|
||||
notificationCenter.add(request) { [weak self] err in
|
||||
if let e = err {
|
||||
print("DNP-FETCH: Failed to update dual notification: \(e.localizedDescription)")
|
||||
} else {
|
||||
print("DNP-FETCH: Updated dual notification with \(useFetched ? "fetched" : "default") content")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get health status for dual scheduling system
|
||||
*
|
||||
@@ -537,6 +659,9 @@ public class DailyNotificationPlugin: CAPPlugin {
|
||||
self.storage?.saveLastSuccessfulRun(timestamp: currentTime)
|
||||
}
|
||||
|
||||
// Relationship: update pending dual notification with resolved content (fetched if within contentTimeout, else default)
|
||||
self.updateDualNotificationWithResolvedContent(fetchedContent: content)
|
||||
|
||||
// Phase 3.3: Recovery logic - verify scheduled notifications
|
||||
// Check if notifications are still scheduled after fetch
|
||||
if let reactivationManager = self.reactivationManager {
|
||||
@@ -745,12 +870,29 @@ public class DailyNotificationPlugin: CAPPlugin {
|
||||
content.body = config["body"] as? String ?? "Your daily update is ready"
|
||||
content.sound = (config["sound"] as? Bool ?? true) ? .default : nil
|
||||
|
||||
// Create trigger (simplified - would use proper cron parsing in production)
|
||||
let nextRunTime = calculateNextRunTime(from: config["schedule"] as? String ?? "0 9 * * *")
|
||||
let trigger = UNTimeIntervalNotificationTrigger(timeInterval: nextRunTime, repeats: false)
|
||||
// Parse cron "minute hour * * *" for daily at local time (replace semantics: one dual notification)
|
||||
let scheduleStr = config["schedule"] as? String ?? "0 9 * * *"
|
||||
let parts = scheduleStr.trimmingCharacters(in: .whitespaces).split(separator: " ").map(String.init)
|
||||
let hour: Int
|
||||
let minute: Int
|
||||
if parts.count >= 2, let m = Int(parts[0]), let h = Int(parts[1]), m >= 0, m <= 59, h >= 0, h <= 23 {
|
||||
minute = m
|
||||
hour = h
|
||||
} else {
|
||||
minute = 0
|
||||
hour = 9
|
||||
}
|
||||
var dateComp = DateComponents()
|
||||
dateComp.hour = hour
|
||||
dateComp.minute = minute
|
||||
dateComp.second = 0
|
||||
let trigger = UNCalendarNotificationTrigger(dateMatching: dateComp, repeats: true)
|
||||
|
||||
// Replace any existing dual notification (stable id for cancelDualSchedule and updateDualScheduleConfig)
|
||||
notificationCenter.removePendingNotificationRequests(withIdentifiers: [dualNotificationRequestIdentifier])
|
||||
|
||||
let request = UNNotificationRequest(
|
||||
identifier: "daily-notification-\(Date().timeIntervalSince1970)",
|
||||
identifier: dualNotificationRequestIdentifier,
|
||||
content: content,
|
||||
trigger: trigger
|
||||
)
|
||||
@@ -759,15 +901,31 @@ public class DailyNotificationPlugin: CAPPlugin {
|
||||
if let error = error {
|
||||
print("DNP-NOTIFY-SCHEDULE: Failed to schedule notification: \(error)")
|
||||
} else {
|
||||
print("DNP-NOTIFY-SCHEDULE: Notification scheduled successfully")
|
||||
print("DNP-NOTIFY-SCHEDULE: Notification scheduled successfully (id: \(self.dualNotificationRequestIdentifier))")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse cron "minute hour * * *" (daily) and return seconds from now until next occurrence (device local time).
|
||||
/// Matches Android calculateNextRunTime semantics for parity.
|
||||
private func calculateNextRunTime(from schedule: String) -> TimeInterval {
|
||||
// Simplified implementation - would use proper cron parsing in production
|
||||
// For now, return next day at 9 AM
|
||||
return 86400 // 24 hours
|
||||
let parts = schedule.trimmingCharacters(in: .whitespaces).split(separator: " ").map(String.init)
|
||||
guard parts.count >= 2,
|
||||
let minute = Int(parts[0]), minute >= 0, minute <= 59,
|
||||
let hour = Int(parts[1]), hour >= 0, hour <= 23 else {
|
||||
print("DNP-SCHEDULE: Invalid cron format: \(schedule), defaulting to 24h from now")
|
||||
return 24 * 60 * 60
|
||||
}
|
||||
var comp = DateComponents()
|
||||
comp.hour = hour
|
||||
comp.minute = minute
|
||||
comp.second = 0
|
||||
let cal = Calendar.current
|
||||
guard let next = cal.nextDate(after: Date(), matching: comp, matchingPolicy: .nextTime) else {
|
||||
return 24 * 60 * 60
|
||||
}
|
||||
let interval = next.timeIntervalSinceNow
|
||||
return interval > 0 ? interval : (24 * 60 * 60)
|
||||
}
|
||||
|
||||
// MARK: - Static Daily Reminder Methods
|
||||
@@ -2197,6 +2355,8 @@ public class DailyNotificationPlugin: CAPPlugin {
|
||||
methods.append(CAPPluginMethod(name: "scheduleUserNotification", returnType: CAPPluginReturnPromise))
|
||||
methods.append(CAPPluginMethod(name: "scheduleDualNotification", returnType: CAPPluginReturnPromise))
|
||||
methods.append(CAPPluginMethod(name: "getDualScheduleStatus", returnType: CAPPluginReturnPromise))
|
||||
methods.append(CAPPluginMethod(name: "updateDualScheduleConfig", returnType: CAPPluginReturnPromise))
|
||||
methods.append(CAPPluginMethod(name: "cancelDualSchedule", returnType: CAPPluginReturnPromise))
|
||||
|
||||
return methods
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user