Files
daily-notification-plugin/ios/Plugin/DailyNotificationScheduler.swift
Jose Olarte III d2a1041cc4 feat(ios): add missed rollover recovery for background/inactive app scenarios
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.
2026-01-09 20:02:40 +08:00

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