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:
Jose Olarte III
2026-03-18 21:10:49 +08:00
parent 7a1e58a4b6
commit 7b41ca9e0b
7 changed files with 464 additions and 44 deletions

View File

@@ -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
}