Implement enhanced app launch recovery to detect and schedule missed rollover notifications that occurred while the app was terminated, backgrounded, or inactive. Key improvements: - Detect missed rollovers on app launch by checking for past notifications without next scheduled notification - Add active rollover check when app becomes active (handles inactive app scenario where notifications fire silently) - Calculate forward to future time when next scheduled time is in the past (handles delays > rollover interval) - Enhance duplicate detection to exclude original notification from checks - Retry rollover if previous attempt failed (rollover time set but no next notification exists) Changes: - DailyNotificationReactivationManager: Add detectAndProcessMissedRollovers() method and performActiveRolloverCheck() for app becoming active - DailyNotificationReactivationManager: Enhance warm start scenario to check for missed rollovers - DailyNotificationScheduler: Add forward calculation loop when next scheduled time is in the past - DailyNotificationPlugin: Register observer for UIApplication.didBecomeActiveNotification to trigger rollover check when app becomes active Fixes rollover scheduling for: - App terminated: Rollover now detected and scheduled on next launch - App inactive/backgrounded: Rollover detected when app becomes active - Delayed recovery: Handles cases where app reopened after rollover interval has passed by calculating forward to next future time All scenarios now properly schedule rollover notifications regardless of app state when notification fires.
609 lines
27 KiB
Swift
609 lines
27 KiB
Swift
/**
|
|
* DailyNotificationScheduler.swift
|
|
*
|
|
* Handles scheduling and timing of daily notifications using UNUserNotificationCenter
|
|
* Implements calendar-based triggers with timing tolerance (±180s) and permission auto-healing
|
|
*
|
|
* @author Matthew Raymer
|
|
* @version 1.0.0
|
|
*/
|
|
|
|
import Foundation
|
|
import UserNotifications
|
|
|
|
/**
|
|
* Protocol for scheduling background fetches
|
|
*/
|
|
protocol DailyNotificationFetchScheduling {
|
|
func scheduleFetch(atMillis: Int64)
|
|
func scheduleImmediateFetch()
|
|
}
|
|
|
|
/**
|
|
* No-op implementation for when fetcher is not available
|
|
*/
|
|
final class NoopFetcherScheduler: DailyNotificationFetchScheduling {
|
|
func scheduleFetch(atMillis: Int64) { /* intentionally noop */ }
|
|
func scheduleImmediateFetch() { /* intentionally noop */ }
|
|
}
|
|
|
|
/**
|
|
* Manages scheduling of daily notifications using UNUserNotificationCenter
|
|
*
|
|
* This class handles the scheduling aspect of the prefetch → cache → schedule → display pipeline.
|
|
* It supports calendar-based triggers with iOS timing tolerance (±180s).
|
|
*/
|
|
class DailyNotificationScheduler {
|
|
|
|
// MARK: - Constants
|
|
|
|
private static let TAG = "DailyNotificationScheduler"
|
|
private static let NOTIFICATION_CATEGORY_ID = "DAILY_NOTIFICATION"
|
|
private static let TIMING_TOLERANCE_SECONDS: TimeInterval = 180 // ±180 seconds tolerance
|
|
|
|
// MARK: - Properties
|
|
|
|
private let notificationCenter: UNUserNotificationCenter
|
|
private var scheduledNotifications: Set<String> = []
|
|
private let schedulerQueue = DispatchQueue(label: "com.timesafari.dailynotification.scheduler", attributes: .concurrent)
|
|
|
|
// TTL enforcement
|
|
private weak var ttlEnforcer: DailyNotificationTTLEnforcer?
|
|
|
|
// Fetch scheduling
|
|
private let fetchScheduler: DailyNotificationFetchScheduling
|
|
|
|
// MARK: - Initialization
|
|
|
|
/**
|
|
* Initialize scheduler
|
|
*
|
|
* @param fetchScheduler Optional fetch scheduler (defaults to NoopFetcherScheduler)
|
|
*/
|
|
init(fetchScheduler: DailyNotificationFetchScheduling = NoopFetcherScheduler()) {
|
|
self.notificationCenter = UNUserNotificationCenter.current()
|
|
self.fetchScheduler = fetchScheduler
|
|
setupNotificationCategory()
|
|
}
|
|
|
|
/**
|
|
* Set TTL enforcer for freshness validation
|
|
*
|
|
* @param ttlEnforcer TTL enforcement instance
|
|
*/
|
|
func setTTLEnforcer(_ ttlEnforcer: DailyNotificationTTLEnforcer) {
|
|
self.ttlEnforcer = ttlEnforcer
|
|
print("\(Self.TAG): TTL enforcer set for freshness validation")
|
|
}
|
|
|
|
// MARK: - Notification Category Setup
|
|
|
|
/**
|
|
* Setup notification category for actions
|
|
*/
|
|
private func setupNotificationCategory() {
|
|
let category = UNNotificationCategory(
|
|
identifier: Self.NOTIFICATION_CATEGORY_ID,
|
|
actions: [],
|
|
intentIdentifiers: [],
|
|
options: []
|
|
)
|
|
|
|
notificationCenter.setNotificationCategories([category])
|
|
print("\(Self.TAG): Notification category setup complete")
|
|
}
|
|
|
|
// MARK: - Permission Management
|
|
|
|
/**
|
|
* Check notification permission status
|
|
*
|
|
* @return Authorization status
|
|
*/
|
|
func checkPermissionStatus() async -> UNAuthorizationStatus {
|
|
let settings = await notificationCenter.notificationSettings()
|
|
return settings.authorizationStatus
|
|
}
|
|
|
|
/**
|
|
* Request notification permissions
|
|
*
|
|
* @return true if permissions granted
|
|
*/
|
|
func requestPermissions() async -> Bool {
|
|
do {
|
|
let granted = try await notificationCenter.requestAuthorization(options: [.alert, .sound, .badge])
|
|
print("\(Self.TAG): Permission request result: \(granted)")
|
|
return granted
|
|
} catch {
|
|
print("\(Self.TAG): Permission request failed: \(error)")
|
|
return false
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Auto-heal permissions: Check and request if needed
|
|
*
|
|
* @return Authorization status after auto-healing
|
|
*/
|
|
func autoHealPermissions() async -> UNAuthorizationStatus {
|
|
let status = await checkPermissionStatus()
|
|
|
|
switch status {
|
|
case .notDetermined:
|
|
// Request permissions
|
|
let granted = await requestPermissions()
|
|
return granted ? .authorized : .denied
|
|
case .denied:
|
|
// Cannot auto-heal denied permissions
|
|
return .denied
|
|
case .authorized, .provisional, .ephemeral:
|
|
return status
|
|
@unknown default:
|
|
return .notDetermined
|
|
}
|
|
}
|
|
|
|
// MARK: - Scheduling
|
|
|
|
/**
|
|
* Schedule a notification for delivery
|
|
*
|
|
* @param content Notification content to schedule
|
|
* @return true if scheduling was successful
|
|
*/
|
|
func scheduleNotification(_ content: NotificationContent) async -> Bool {
|
|
do {
|
|
print("\(Self.TAG): Scheduling notification: \(content.id)")
|
|
|
|
// Permission auto-healing
|
|
let permissionStatus = await autoHealPermissions()
|
|
if permissionStatus != .authorized && permissionStatus != .provisional {
|
|
print("\(Self.TAG): Notifications denied, cannot schedule")
|
|
// Log error code for debugging
|
|
print("\(Self.TAG): Error code: \(DailyNotificationErrorCodes.NOTIFICATIONS_DENIED)")
|
|
return false
|
|
}
|
|
|
|
// TTL validation before arming
|
|
if let ttlEnforcer = ttlEnforcer {
|
|
let okToArm = ttlEnforcer.validateBeforeArming(content)
|
|
if !okToArm {
|
|
print("\(Self.TAG): TTL validation failed, skipping schedule for \(content.id)")
|
|
return false
|
|
}
|
|
}
|
|
|
|
// Cancel any existing notification for this ID
|
|
await cancelNotification(id: content.id)
|
|
|
|
// Create notification content
|
|
let notificationContent = UNMutableNotificationContent()
|
|
notificationContent.title = content.title ?? "Daily Update"
|
|
notificationContent.body = content.body ?? "Your daily notification is ready"
|
|
notificationContent.sound = .default
|
|
notificationContent.categoryIdentifier = Self.NOTIFICATION_CATEGORY_ID
|
|
notificationContent.userInfo = [
|
|
"notification_id": content.id,
|
|
"scheduled_time": content.scheduledTime,
|
|
"fetched_at": content.fetchedAt
|
|
]
|
|
|
|
// Create calendar trigger for daily scheduling
|
|
let scheduledDate = content.getScheduledTimeAsDate()
|
|
let calendar = Calendar.current
|
|
let dateComponents = calendar.dateComponents([.year, .month, .day, .hour, .minute], from: scheduledDate)
|
|
|
|
let trigger = UNCalendarNotificationTrigger(
|
|
dateMatching: dateComponents,
|
|
repeats: false
|
|
)
|
|
|
|
// Create notification request
|
|
let request = UNNotificationRequest(
|
|
identifier: content.id,
|
|
content: notificationContent,
|
|
trigger: trigger
|
|
)
|
|
|
|
// Schedule notification
|
|
try await notificationCenter.add(request)
|
|
|
|
schedulerQueue.async(flags: .barrier) {
|
|
self.scheduledNotifications.insert(content.id)
|
|
}
|
|
|
|
// Log pending count for test scripts (matches Android's alarm count logging)
|
|
// Use NSLog to ensure it appears in system logs (print() may not always be captured)
|
|
let pendingCount = await getPendingNotificationCount()
|
|
NSLog("\(Self.TAG): Notification scheduled successfully for \(scheduledDate), id=\(content.id), pendingCount=\(pendingCount)")
|
|
print("\(Self.TAG): Notification scheduled successfully for \(scheduledDate), id=\(content.id), pendingCount=\(pendingCount)")
|
|
return true
|
|
|
|
} catch {
|
|
print("\(Self.TAG): Error scheduling notification: \(error)")
|
|
return false
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Cancel a notification by ID
|
|
*
|
|
* @param id Notification ID
|
|
*/
|
|
func cancelNotification(id: String) async {
|
|
notificationCenter.removePendingNotificationRequests(withIdentifiers: [id])
|
|
|
|
schedulerQueue.async(flags: .barrier) {
|
|
self.scheduledNotifications.remove(id)
|
|
}
|
|
|
|
print("\(Self.TAG): Notification cancelled: \(id)")
|
|
}
|
|
|
|
/**
|
|
* Cancel all scheduled notifications
|
|
*/
|
|
func cancelAllNotifications() async {
|
|
notificationCenter.removeAllPendingNotificationRequests()
|
|
|
|
schedulerQueue.async(flags: .barrier) {
|
|
self.scheduledNotifications.removeAll()
|
|
}
|
|
|
|
print("\(Self.TAG): All notifications cancelled")
|
|
}
|
|
|
|
// MARK: - Status Queries
|
|
|
|
/**
|
|
* Get pending notification requests
|
|
*
|
|
* @return Array of pending notification identifiers
|
|
*/
|
|
func getPendingNotifications() async -> [String] {
|
|
let requests = await notificationCenter.pendingNotificationRequests()
|
|
return requests.map { $0.identifier }
|
|
}
|
|
|
|
/**
|
|
* Get notification status
|
|
*
|
|
* @param id Notification ID
|
|
* @return true if notification is scheduled
|
|
*/
|
|
func isNotificationScheduled(id: String) async -> Bool {
|
|
let requests = await notificationCenter.pendingNotificationRequests()
|
|
return requests.contains { $0.identifier == id }
|
|
}
|
|
|
|
/**
|
|
* Get count of pending notifications
|
|
*
|
|
* @return Count of pending notifications
|
|
*/
|
|
func getPendingNotificationCount() async -> Int {
|
|
let requests = await notificationCenter.pendingNotificationRequests()
|
|
return requests.count
|
|
}
|
|
|
|
// MARK: - Helper Methods
|
|
|
|
/**
|
|
* Format time for logging
|
|
*
|
|
* @param timestamp Timestamp in milliseconds
|
|
* @return Formatted time string
|
|
*/
|
|
private func formatTime(_ timestamp: Int64) -> String {
|
|
let date = Date(timeIntervalSince1970: Double(timestamp) / 1000.0)
|
|
let formatter = DateFormatter()
|
|
formatter.dateStyle = .medium
|
|
formatter.timeStyle = .short
|
|
return formatter.string(from: date)
|
|
}
|
|
|
|
/**
|
|
* Calculate next occurrence of a daily time
|
|
*
|
|
* Matches Android calculateNextOccurrence() functionality
|
|
*
|
|
* @param hour Hour of day (0-23)
|
|
* @param minute Minute of hour (0-59)
|
|
* @return Timestamp in milliseconds of next occurrence
|
|
*/
|
|
func calculateNextOccurrence(hour: Int, minute: Int) -> Int64 {
|
|
let calendar = Calendar.current
|
|
let now = Date()
|
|
|
|
var components = calendar.dateComponents([.year, .month, .day], from: now)
|
|
components.hour = hour
|
|
components.minute = minute
|
|
components.second = 0
|
|
|
|
var scheduledDate = calendar.date(from: components) ?? now
|
|
|
|
// If time has passed today, schedule for tomorrow
|
|
if scheduledDate <= now {
|
|
scheduledDate = calendar.date(byAdding: .day, value: 1, to: scheduledDate) ?? scheduledDate
|
|
}
|
|
|
|
return Int64(scheduledDate.timeIntervalSince1970 * 1000)
|
|
}
|
|
|
|
/**
|
|
* Get next notification time from pending notifications
|
|
*
|
|
* @return Timestamp in milliseconds of next notification or nil
|
|
*/
|
|
func getNextNotificationTime() async -> Int64? {
|
|
let requests = await notificationCenter.pendingNotificationRequests()
|
|
|
|
// Find the earliest scheduled notification by checking all requests
|
|
var earliestDate: Date? = nil
|
|
var earliestRequestId: String? = nil
|
|
var allTimes: [(String, String)] = []
|
|
|
|
for request in requests {
|
|
var requestTime: Date? = nil
|
|
|
|
if let trigger = request.trigger as? UNCalendarNotificationTrigger,
|
|
let nextDate = trigger.nextTriggerDate() {
|
|
requestTime = nextDate
|
|
} else if let trigger = request.trigger as? UNTimeIntervalNotificationTrigger,
|
|
let nextDate = trigger.nextTriggerDate() {
|
|
requestTime = nextDate
|
|
}
|
|
|
|
if let time = requestTime {
|
|
let timeStr = formatTime(Int64(time.timeIntervalSince1970 * 1000))
|
|
allTimes.append((request.identifier, timeStr))
|
|
|
|
if earliestDate == nil || time < earliestDate! {
|
|
earliestDate = time
|
|
earliestRequestId = request.identifier
|
|
}
|
|
}
|
|
}
|
|
|
|
guard let nextDate = earliestDate else {
|
|
NSLog("DNP-ROLLOVER: GET_NEXT_TIME no_pending_requests")
|
|
print("DNP-ROLLOVER: GET_NEXT_TIME no_pending_requests")
|
|
return nil
|
|
}
|
|
|
|
let nextTime = Int64(nextDate.timeIntervalSince1970 * 1000)
|
|
let nextTimeStr = formatTime(nextTime)
|
|
let allTimesStr = allTimes.map { "\($0.0):\($0.1)" }.joined(separator: ", ")
|
|
NSLog("DNP-ROLLOVER: GET_NEXT_TIME found=\(nextTimeStr) id=\(earliestRequestId ?? "unknown") from \(requests.count) pending: [\(allTimesStr)]")
|
|
print("DNP-ROLLOVER: GET_NEXT_TIME found=\(nextTimeStr) id=\(earliestRequestId ?? "unknown") from \(requests.count) pending: [\(allTimesStr)]")
|
|
|
|
return nextTime
|
|
}
|
|
|
|
/**
|
|
* Calculate next scheduled time from current scheduled time (24 hours later, DST-safe)
|
|
*
|
|
* Matches Android calculateNextScheduledTime() functionality
|
|
* Handles DST transitions automatically using Calendar
|
|
*
|
|
* @param currentScheduledTime Current scheduled time in milliseconds
|
|
* @return Next scheduled time in milliseconds (24 hours later)
|
|
*
|
|
* TESTING: To test with shorter intervals (e.g., 2 minutes), change:
|
|
* - Line ~404: `.hour, value: 24` → `.minute, value: 2`
|
|
* - Line ~407: `(24 * 60 * 60 * 1000)` → `(2 * 60 * 1000)`
|
|
*/
|
|
func calculateNextScheduledTime(_ currentScheduledTime: Int64) -> Int64 {
|
|
let calendar = Calendar.current
|
|
let currentDate = Date(timeIntervalSince1970: Double(currentScheduledTime) / 1000.0)
|
|
let currentTimeStr = formatTime(currentScheduledTime)
|
|
|
|
// Add 24 hours (handles DST transitions automatically)
|
|
// TESTING: Change `.hour, value: 24` to `.minute, value: 2` for 2-minute testing
|
|
guard let nextDate = calendar.date(byAdding: .hour, value: 24, to: currentDate) else {
|
|
// Fallback to simple 24-hour addition if calendar calculation fails
|
|
// TESTING: Change `(24 * 60 * 60 * 1000)` to `(2 * 60 * 1000)` for 2-minute testing
|
|
let fallbackTime = currentScheduledTime + (24 * 60 * 60 * 1000)
|
|
let fallbackTimeStr = formatTime(fallbackTime)
|
|
NSLog("DNP-ROLLOVER: DST_CALC_FAILED current=\(currentTimeStr) using_fallback=\(fallbackTimeStr)")
|
|
print("DNP-ROLLOVER: DST_CALC_FAILED current=\(currentTimeStr) using_fallback=\(fallbackTimeStr)")
|
|
return fallbackTime
|
|
}
|
|
|
|
let nextTime = Int64(nextDate.timeIntervalSince1970 * 1000)
|
|
let nextTimeStr = formatTime(nextTime)
|
|
|
|
// Validate: Log DST transitions for debugging
|
|
let currentHour = calendar.component(.hour, from: currentDate)
|
|
let currentMinute = calendar.component(.minute, from: currentDate)
|
|
let nextHour = calendar.component(.hour, from: nextDate)
|
|
let nextMinute = calendar.component(.minute, from: nextDate)
|
|
|
|
if currentHour != nextHour || currentMinute != nextMinute {
|
|
NSLog("DNP-ROLLOVER: DST_TRANSITION current=\(currentHour):\(String(format: "%02d", currentMinute)) next=\(nextHour):\(String(format: "%02d", nextMinute))")
|
|
print("DNP-ROLLOVER: DST_TRANSITION current=\(currentHour):\(String(format: "%02d", currentMinute)) next=\(nextHour):\(String(format: "%02d", nextMinute))")
|
|
}
|
|
|
|
// Log the calculation result
|
|
let timeDiffMs = nextTime - currentScheduledTime
|
|
let timeDiffHours = Double(timeDiffMs) / 1000.0 / 60.0 / 60.0
|
|
NSLog("DNP-ROLLOVER: CALC_NEXT current=\(currentTimeStr) next=\(nextTimeStr) diff_hours=\(String(format: "%.2f", timeDiffHours))")
|
|
print("DNP-ROLLOVER: CALC_NEXT current=\(currentTimeStr) next=\(nextTimeStr) diff_hours=\(String(format: "%.2f", timeDiffHours))")
|
|
|
|
return nextTime
|
|
}
|
|
|
|
/**
|
|
* Schedule next notification after current one fires (rollover)
|
|
*
|
|
* Matches Android scheduleNextNotification() functionality
|
|
* Implements multi-level duplicate prevention
|
|
*
|
|
* @param content Current notification content that just fired
|
|
* @param storage Storage instance for duplicate checking
|
|
* @param fetcher Optional fetcher for scheduling prefetch (Phase 2)
|
|
* @return true if next notification was scheduled successfully
|
|
*/
|
|
func scheduleNextNotification(
|
|
_ content: NotificationContent,
|
|
storage: DailyNotificationStorage?,
|
|
fetcher: Any? = nil
|
|
) async -> Bool {
|
|
let currentTime = Int64(Date().timeIntervalSince1970 * 1000)
|
|
let currentTimeStr = formatTime(currentTime)
|
|
let currentScheduledTimeStr = formatTime(content.scheduledTime)
|
|
|
|
NSLog("DNP-ROLLOVER: START id=\(content.id) current_time=\(currentTimeStr) scheduled_time=\(currentScheduledTimeStr)")
|
|
print("DNP-ROLLOVER: START id=\(content.id) current_time=\(currentTimeStr) scheduled_time=\(currentScheduledTimeStr)")
|
|
|
|
// Check 1: Rollover state tracking (prevent duplicate rollover attempts)
|
|
if let storage = storage {
|
|
let lastRolloverTime = await storage.getLastRolloverTime(for: content.id)
|
|
|
|
// If rollover was processed recently (< 1 hour ago), skip
|
|
// TESTING: Change `(60 * 60 * 1000)` to `(60 * 1000)` for 1-minute threshold when testing with 2-minute intervals
|
|
if let lastTime = lastRolloverTime,
|
|
(currentTime - lastTime) < (60 * 60 * 1000) {
|
|
let lastTimeStr = formatTime(lastTime)
|
|
let timeSinceRollover = (currentTime - lastTime) / 1000 / 60 // minutes
|
|
NSLog("DNP-ROLLOVER: SKIP id=\(content.id) already_processed last_rollover=\(lastTimeStr) \(timeSinceRollover)min_ago")
|
|
print("DNP-ROLLOVER: SKIP id=\(content.id) already_processed last_rollover=\(lastTimeStr) \(timeSinceRollover)min_ago")
|
|
return false
|
|
}
|
|
}
|
|
|
|
// Calculate next occurrence using DST-safe calculation
|
|
var nextScheduledTime = calculateNextScheduledTime(content.scheduledTime)
|
|
|
|
// If next scheduled time is in the past, keep calculating forward until we get a future time
|
|
// This handles cases where the notification fired more than 2 minutes ago
|
|
while nextScheduledTime < currentTime {
|
|
let nextTimeStr = formatTime(nextScheduledTime)
|
|
NSLog("DNP-ROLLOVER: NEXT_IN_PAST id=\(content.id) next_time=\(nextTimeStr) current_time=\(currentTimeStr), calculating forward")
|
|
print("DNP-ROLLOVER: NEXT_IN_PAST id=\(content.id) next_time=\(nextTimeStr) current_time=\(currentTimeStr), calculating forward")
|
|
nextScheduledTime = calculateNextScheduledTime(nextScheduledTime)
|
|
}
|
|
|
|
let nextScheduledTimeStr = formatTime(nextScheduledTime)
|
|
let hoursUntilNext = Double(nextScheduledTime - currentTime) / 1000.0 / 60.0 / 60.0
|
|
|
|
NSLog("DNP-ROLLOVER: CALCULATED id=\(content.id) next_time=\(nextScheduledTimeStr) hours_until=\(String(format: "%.2f", hoursUntilNext))")
|
|
print("DNP-ROLLOVER: CALCULATED id=\(content.id) next_time=\(nextScheduledTimeStr) hours_until=\(String(format: "%.2f", hoursUntilNext))")
|
|
|
|
// Check 2: Storage-level duplicate check (prevent duplicate notifications)
|
|
if let storage = storage {
|
|
let existingNotifications = storage.getAllNotifications()
|
|
let toleranceMs: Int64 = 60 * 1000 // 1 minute tolerance for DST shifts
|
|
|
|
for existing in existingNotifications {
|
|
if abs(existing.scheduledTime - nextScheduledTime) <= toleranceMs {
|
|
let existingTimeStr = formatTime(existing.scheduledTime)
|
|
let timeDiffMs = abs(existing.scheduledTime - nextScheduledTime)
|
|
NSLog("DNP-ROLLOVER: DUPLICATE_STORAGE id=\(content.id) existing_id=\(existing.id) existing_time=\(existingTimeStr) time_diff_ms=\(timeDiffMs)")
|
|
print("DNP-ROLLOVER: DUPLICATE_STORAGE id=\(content.id) existing_id=\(existing.id) existing_time=\(existingTimeStr) time_diff_ms=\(timeDiffMs)")
|
|
return false // Skip rescheduling to prevent duplicate
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check 3: System-level duplicate check (query UNUserNotificationCenter)
|
|
let pendingNotifications = await notificationCenter.pendingNotificationRequests()
|
|
NSLog("DNP-ROLLOVER: CHECK_SYSTEM id=\(content.id) pending_count=\(pendingNotifications.count)")
|
|
print("DNP-ROLLOVER: CHECK_SYSTEM id=\(content.id) pending_count=\(pendingNotifications.count)")
|
|
|
|
for pending in pendingNotifications {
|
|
if let trigger = pending.trigger as? UNCalendarNotificationTrigger,
|
|
let nextDate = trigger.nextTriggerDate() {
|
|
let pendingTime = Int64(nextDate.timeIntervalSince1970 * 1000)
|
|
let toleranceMs: Int64 = 60 * 1000
|
|
|
|
if abs(pendingTime - nextScheduledTime) <= toleranceMs {
|
|
let pendingTimeStr = formatTime(pendingTime)
|
|
let timeDiffMs = abs(pendingTime - nextScheduledTime)
|
|
NSLog("DNP-ROLLOVER: DUPLICATE_SYSTEM id=\(content.id) system_pending_id=\(pending.identifier) pending_time=\(pendingTimeStr) time_diff_ms=\(timeDiffMs)")
|
|
print("DNP-ROLLOVER: DUPLICATE_SYSTEM id=\(content.id) system_pending_id=\(pending.identifier) pending_time=\(pendingTimeStr) time_diff_ms=\(timeDiffMs)")
|
|
return false
|
|
}
|
|
}
|
|
}
|
|
|
|
// Extract hour:minute from current scheduled time for logging
|
|
let calendar = Calendar.current
|
|
let scheduledDate = content.getScheduledTimeAsDate()
|
|
let hour = calendar.component(.hour, from: scheduledDate)
|
|
let minute = calendar.component(.minute, from: scheduledDate)
|
|
|
|
// Create new notification content for next occurrence
|
|
// Note: Content will be refreshed by prefetch, but we need placeholder
|
|
let nextId = "daily_rollover_\(Int64(Date().timeIntervalSince1970 * 1000))"
|
|
let nextContent = NotificationContent(
|
|
id: nextId,
|
|
title: content.title, // Will be updated by prefetch
|
|
body: content.body, // Will be updated by prefetch
|
|
scheduledTime: nextScheduledTime,
|
|
fetchedAt: Int64(Date().timeIntervalSince1970 * 1000),
|
|
url: content.url,
|
|
payload: content.payload,
|
|
etag: content.etag
|
|
)
|
|
|
|
// Schedule the next notification
|
|
NSLog("DNP-ROLLOVER: SCHEDULING id=\(content.id) next_id=\(nextId) next_time=\(nextScheduledTimeStr)")
|
|
print("DNP-ROLLOVER: SCHEDULING id=\(content.id) next_id=\(nextId) next_time=\(nextScheduledTimeStr)")
|
|
|
|
let scheduled = await scheduleNotification(nextContent)
|
|
|
|
if scheduled {
|
|
// Save notification content to storage so it can be retrieved when rollover fires
|
|
// This is critical: without saving, processRollover won't find the content
|
|
storage?.saveNotificationContent(nextContent)
|
|
NSLog("DNP-ROLLOVER: SAVED id=\(content.id) next_id=\(nextId) content saved to storage")
|
|
print("DNP-ROLLOVER: SAVED id=\(content.id) next_id=\(nextId) content saved to storage")
|
|
|
|
// Verify the notification was actually scheduled
|
|
let pendingCount = await getPendingNotificationCount()
|
|
let isScheduled = await isNotificationScheduled(id: nextId)
|
|
|
|
NSLog("DNP-ROLLOVER: SUCCESS id=\(content.id) next_id=\(nextId) next_time=\(nextScheduledTimeStr) pending_count=\(pendingCount) is_scheduled=\(isScheduled)")
|
|
print("DNP-ROLLOVER: SUCCESS id=\(content.id) next_id=\(nextId) next_time=\(nextScheduledTimeStr) pending_count=\(pendingCount) is_scheduled=\(isScheduled)")
|
|
|
|
// Log time difference verification
|
|
let timeDiffMs = nextScheduledTime - content.scheduledTime
|
|
let timeDiffHours = Double(timeDiffMs) / 1000.0 / 60.0 / 60.0
|
|
NSLog("DNP-ROLLOVER: TIME_VERIFY id=\(content.id) current=\(currentScheduledTimeStr) next=\(nextScheduledTimeStr) diff_hours=\(String(format: "%.2f", timeDiffHours))")
|
|
print("DNP-ROLLOVER: TIME_VERIFY id=\(content.id) current=\(currentScheduledTimeStr) next=\(nextScheduledTimeStr) diff_hours=\(String(format: "%.2f", timeDiffHours))")
|
|
|
|
// Schedule background fetch for next notification (5 minutes before scheduled time)
|
|
let fetchTime = nextScheduledTime - (5 * 60 * 1000) // 5 minutes before
|
|
let currentTime = Int64(Date().timeIntervalSince1970 * 1000)
|
|
|
|
if fetchTime > currentTime {
|
|
print("\(Self.TAG): scheduling fetch at \(fetchTime)")
|
|
fetchScheduler.scheduleFetch(atMillis: fetchTime)
|
|
NSLog("DNP-ROLLOVER: PREFETCH_SCHEDULED id=\(content.id) next_fetch=\(fetchTime) next_notification=\(nextScheduledTime)")
|
|
print("DNP-ROLLOVER: PREFETCH_SCHEDULED id=\(content.id) next_fetch=\(fetchTime) next_notification=\(nextScheduledTime)")
|
|
} else {
|
|
print("\(Self.TAG): scheduling immediate fetch")
|
|
fetchScheduler.scheduleImmediateFetch()
|
|
NSLog("DNP-ROLLOVER: PREFETCH_PAST id=\(content.id) fetch_time=\(fetchTime) current=\(currentTime)")
|
|
print("DNP-ROLLOVER: PREFETCH_PAST id=\(content.id) fetch_time=\(fetchTime) current=\(currentTime)")
|
|
}
|
|
|
|
// Mark rollover as processed
|
|
let rolloverProcessedTime = Int64(Date().timeIntervalSince1970 * 1000)
|
|
await storage?.saveLastRolloverTime(for: content.id, time: rolloverProcessedTime)
|
|
|
|
NSLog("DNP-ROLLOVER: COMPLETE id=\(content.id) next_id=\(nextId) next_time=\(nextScheduledTimeStr)")
|
|
print("DNP-ROLLOVER: COMPLETE id=\(content.id) next_id=\(nextId) next_time=\(nextScheduledTimeStr)")
|
|
|
|
return true
|
|
} else {
|
|
NSLog("DNP-ROLLOVER: ERROR id=\(content.id) scheduling_failed next_time=\(nextScheduledTimeStr)")
|
|
print("DNP-ROLLOVER: ERROR id=\(content.id) scheduling_failed next_time=\(nextScheduledTimeStr)")
|
|
return false
|
|
}
|
|
}
|
|
}
|
|
|