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.
This commit is contained in:
Jose Olarte III
2026-01-09 20:02:40 +08:00
parent 243cbd08f1
commit d2a1041cc4
3 changed files with 283 additions and 2 deletions

View File

@@ -7,6 +7,7 @@
// //
import Foundation import Foundation
import UIKit
import Capacitor import Capacitor
import UserNotifications import UserNotifications
import BackgroundTasks import BackgroundTasks
@@ -84,6 +85,17 @@ public class DailyNotificationPlugin: CAPPlugin {
NSLog("DNP-ROLLOVER: Observer registered for DailyNotificationDelivered notification") NSLog("DNP-ROLLOVER: Observer registered for DailyNotificationDelivered notification")
print("DNP-ROLLOVER: Observer registered for DailyNotificationDelivered notification") print("DNP-ROLLOVER: Observer registered for DailyNotificationDelivered notification")
// Register for app becoming active to check for missed rollovers
NotificationCenter.default.addObserver(
self,
selector: #selector(handleAppBecameActive(_:)),
name: UIApplication.didBecomeActiveNotification,
object: nil
)
NSLog("DNP-ROLLOVER: Observer registered for app becoming active")
print("DNP-ROLLOVER: Observer registered for app becoming active")
NSLog("DNP-DEBUG: DailyNotificationPlugin.load() completed - initialization done") NSLog("DNP-DEBUG: DailyNotificationPlugin.load() completed - initialization done")
print("DNP-PLUGIN: Daily Notification Plugin loaded on iOS") print("DNP-PLUGIN: Daily Notification Plugin loaded on iOS")
@@ -1434,6 +1446,23 @@ public class DailyNotificationPlugin: CAPPlugin {
} }
} }
/**
* Handle app becoming active (foreground)
*
* This is called when the app becomes active to check for missed rollovers
* that occurred while the app was backgrounded.
*
* @param notification NSNotification for app becoming active
*/
@objc private func handleAppBecameActive(_ notification: Notification) {
NSLog("DNP-ROLLOVER: handleAppBecameActive called")
print("DNP-ROLLOVER: handleAppBecameActive called")
// Perform lightweight rollover check when app becomes active
// This handles cases where notifications fired while app was backgrounded
reactivationManager?.performActiveRolloverCheck()
}
/** /**
* Process rollover for delivered notification * Process rollover for delivered notification
* *

View File

@@ -94,6 +94,35 @@ class DailyNotificationReactivationManager {
// MARK: - Recovery Execution // MARK: - Recovery Execution
/**
* Perform lightweight rollover check when app becomes active
*
* This is called when the app becomes active (foreground) to check for
* missed rollovers that occurred while the app was backgrounded.
*
* This is a lightweight check that only:
* 1. Checks for delivered notifications and triggers rollover
* 2. Detects and processes missed rollovers
*
* It does NOT perform full recovery (missed notification marking, rescheduling, etc.)
* Full recovery only happens on app launch.
*
* This handles the "inactive app" scenario where notifications fire while
* the app is backgrounded and rollover doesn't happen.
*/
func performActiveRolloverCheck() {
Task {
NSLog("\(Self.TAG): Performing active rollover check (app became active)")
// Check for delivered notifications and trigger rollover
await checkAndProcessDeliveredNotifications()
// Check for missed rollovers (notifications that should have rolled over)
let rolloverResult = await detectAndProcessMissedRollovers()
NSLog("\(Self.TAG): Active rollover check completed: processed=\(rolloverResult.processedCount), failed=\(rolloverResult.failedCount), checked=\(rolloverResult.totalChecked)")
}
}
/** /**
* Perform recovery on app launch * Perform recovery on app launch
* *
@@ -171,7 +200,22 @@ class DailyNotificationReactivationManager {
self.updateLastLaunchTime() self.updateLastLaunchTime()
return return
case .warmStart: case .warmStart:
NSLog("\(Self.TAG): Warm start detected - no recovery needed") NSLog("\(Self.TAG): Warm start detected - checking for missed rollovers")
// Even in warm start, we need to check for missed rollovers
// This handles cases where notifications fired while app was backgrounded
let warmStartTime = Date()
// Check for delivered notifications and trigger rollover
await self.checkAndProcessDeliveredNotifications()
// Check for missed rollovers (notifications that should have rolled over)
let rolloverResult = await self.detectAndProcessMissedRollovers()
NSLog("\(Self.TAG): Warm start rollover check: processed=\(rolloverResult.processedCount), failed=\(rolloverResult.failedCount), checked=\(rolloverResult.totalChecked)")
let warmEndTime = Date()
let duration = warmEndTime.timeIntervalSince(warmStartTime) * 1000 // ms
NSLog("\(Self.TAG): Warm start rollover check completed: duration=%.0fms", duration)
self.updateLastLaunchTime() self.updateLastLaunchTime()
return return
case .coldStart: case .coldStart:
@@ -398,6 +442,11 @@ class DailyNotificationReactivationManager {
// This handles notifications that were delivered while app was not running // This handles notifications that were delivered while app was not running
await checkAndProcessDeliveredNotifications() await checkAndProcessDeliveredNotifications()
// Step 4.6: Check for missed rollovers (notifications that should have rolled over)
// This handles notifications that fired but rollover didn't happen (app was terminated)
let rolloverResult = await detectAndProcessMissedRollovers()
NSLog("\(Self.TAG): Missed rollover recovery: processed=\(rolloverResult.processedCount), failed=\(rolloverResult.failedCount), checked=\(rolloverResult.totalChecked)")
// Record recovery in history // Record recovery in history
let result = RecoveryResult( let result = RecoveryResult(
missedCount: missedCount, missedCount: missedCount,
@@ -734,6 +783,11 @@ class DailyNotificationReactivationManager {
let verificationResult = try await verifyFutureNotifications() let verificationResult = try await verifyFutureNotifications()
NSLog("\(Self.TAG): Final verification: found=\(verificationResult.notificationsFound), missing=\(verificationResult.notificationsMissing)") NSLog("\(Self.TAG): Final verification: found=\(verificationResult.notificationsFound), missing=\(verificationResult.notificationsMissing)")
// Step 8: Check for missed rollovers (notifications that should have rolled over)
// This handles notifications that fired but rollover didn't happen (app was terminated)
let rolloverResult = await detectAndProcessMissedRollovers()
NSLog("\(Self.TAG): Missed rollover recovery: processed=\(rolloverResult.processedCount), failed=\(rolloverResult.failedCount), checked=\(rolloverResult.totalChecked)")
// Record recovery in history // Record recovery in history
let result = RecoveryResult( let result = RecoveryResult(
missedCount: missedCount, missedCount: missedCount,
@@ -1084,6 +1138,185 @@ class DailyNotificationReactivationManager {
print("DNP-ROLLOVER: RECOVERY_COMPLETE processed=\(processedCount) skipped=\(skippedCount)") print("DNP-ROLLOVER: RECOVERY_COMPLETE processed=\(processedCount) skipped=\(skippedCount)")
} }
/**
* Detect and process missed rollovers on app launch
*
* This method identifies notifications that should have rolled over but didn't,
* and schedules the next notification(s) for them.
*
* Detection Logic:
* 1. Find notifications where scheduledTime < currentTime (should have fired)
* 2. Check if next notification exists (in storage or pending)
* 3. Check if rollover was already processed (via lastRolloverTime)
* 4. If no next notification and rollover not processed, schedule it
*
* This handles cases where:
* - Notification fired while app was terminated
* - Notification was dismissed before app launched
* - Rollover didn't happen because app wasn't active
*
* Error Handling:
* - Individual notification errors are caught and counted
* - Partial results returned if some operations fail
* - All errors logged but don't stop recovery process
*
* @return RolloverRecoveryResult with counts of processed rollovers
*/
private func detectAndProcessMissedRollovers() async -> RolloverRecoveryResult {
NSLog("DNP-ROLLOVER: MISSED_ROLLOVER_CHECK_START")
print("DNP-ROLLOVER: MISSED_ROLLOVER_CHECK_START")
let currentTime = Int64(Date().timeIntervalSince1970 * 1000)
let currentTimeStr = formatTime(currentTime)
// Step 1: Get all notifications from storage
let allNotifications: [NotificationContent]
do {
allNotifications = storage.getAllNotifications()
} catch {
// Non-fatal: Log error and return empty result
NSLog("\(Self.TAG): Error getting notifications from storage (non-fatal): \(error.localizedDescription)")
return RolloverRecoveryResult(processedCount: 0, failedCount: 0, totalChecked: 0)
}
// Step 2: Get pending notifications from system
let pendingRequests: [UNNotificationRequest]
do {
pendingRequests = try await notificationCenter.pendingNotificationRequests()
} catch {
// Non-fatal: Log error and continue with empty pending list
NSLog("\(Self.TAG): Error getting pending notifications (non-fatal): \(error.localizedDescription)")
return RolloverRecoveryResult(processedCount: 0, failedCount: 0, totalChecked: allNotifications.count)
}
let pendingIds = Set(pendingRequests.map { $0.identifier })
// Step 3: Find notifications that should have rolled over
var missedRollovers: [NotificationContent] = []
for notification in allNotifications {
// Check if notification should have fired (scheduledTime < currentTime)
if notification.scheduledTime >= currentTime {
continue // Future notification, skip
}
// Check if rollover was already processed
// Only skip if rollover was processed AND next notification exists
// This handles cases where rollover was attempted but failed
let lastRolloverTime = await storage.getLastRolloverTime(for: notification.id)
// Calculate next scheduled time first to check if it exists
var nextScheduledTime = scheduler.calculateNextScheduledTime(notification.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: MISSED_ROLLOVER_NEXT_IN_PAST id=\(notification.id) next_time=\(nextTimeStr) current_time=\(currentTimeStr), calculating forward")
print("DNP-ROLLOVER: MISSED_ROLLOVER_NEXT_IN_PAST id=\(notification.id) next_time=\(nextTimeStr) current_time=\(currentTimeStr), calculating forward")
nextScheduledTime = scheduler.calculateNextScheduledTime(nextScheduledTime)
}
// Check if next notification actually exists
let toleranceMs: Int64 = 60 * 1000 // 1 minute tolerance
var nextNotificationExists = false
// Quick check in storage (exclude original)
for existing in allNotifications {
if existing.id == notification.id {
continue
}
if abs(existing.scheduledTime - nextScheduledTime) <= toleranceMs {
nextNotificationExists = true
break
}
}
// Quick check in pending
if !nextNotificationExists {
for pending in pendingRequests {
if pending.identifier == notification.id {
continue
}
if let trigger = pending.trigger as? UNCalendarNotificationTrigger,
let nextDate = trigger.nextTriggerDate() {
let pendingTime = Int64(nextDate.timeIntervalSince1970 * 1000)
if abs(pendingTime - nextScheduledTime) <= toleranceMs {
nextNotificationExists = true
break
}
}
}
}
// If rollover was processed AND next notification exists, skip
// Otherwise, process it (either rollover wasn't attempted, or it failed)
if let lastTime = lastRolloverTime, lastTime >= notification.scheduledTime, nextNotificationExists {
let lastTimeStr = formatTime(lastTime)
NSLog("DNP-ROLLOVER: MISSED_ROLLOVER_SKIP id=\(notification.id) already_processed last_rollover=\(lastTimeStr) next_exists=true")
print("DNP-ROLLOVER: MISSED_ROLLOVER_SKIP id=\(notification.id) already_processed last_rollover=\(lastTimeStr) next_exists=true")
continue // Already processed and next notification exists
}
// If rollover was attempted but next notification doesn't exist, log and continue processing
if let lastTime = lastRolloverTime, lastTime >= notification.scheduledTime, !nextNotificationExists {
let lastTimeStr = formatTime(lastTime)
NSLog("DNP-ROLLOVER: MISSED_ROLLOVER_RETRY id=\(notification.id) rollover_attempted=\(lastTimeStr) but_next_missing, will_retry")
print("DNP-ROLLOVER: MISSED_ROLLOVER_RETRY id=\(notification.id) rollover_attempted=\(lastTimeStr) but_next_missing, will_retry")
// Continue to process - rollover was attempted but failed
}
// Re-check if next notification exists (we already calculated nextScheduledTime above)
// This is the final check before adding to missed rollovers list
if !nextNotificationExists {
let nextTimeStr = formatTime(nextScheduledTime)
NSLog("DNP-ROLLOVER: MISSED_ROLLOVER_DETECTED id=\(notification.id) scheduled_time=\(formatTime(notification.scheduledTime)) next_time=\(nextTimeStr)")
print("DNP-ROLLOVER: MISSED_ROLLOVER_DETECTED id=\(notification.id) scheduled_time=\(formatTime(notification.scheduledTime)) next_time=\(nextTimeStr)")
missedRollovers.append(notification)
}
}
NSLog("DNP-ROLLOVER: MISSED_ROLLOVER_FOUND count=\(missedRollovers.count) current_time=\(currentTimeStr)")
print("DNP-ROLLOVER: MISSED_ROLLOVER_FOUND count=\(missedRollovers.count) current_time=\(currentTimeStr)")
// Step 4: Process missed rollovers
var processedCount = 0
var failedCount = 0
for notification in missedRollovers {
let scheduledTimeStr = formatTime(notification.scheduledTime)
NSLog("DNP-ROLLOVER: MISSED_ROLLOVER_PROCESS id=\(notification.id) scheduled_time=\(scheduledTimeStr)")
print("DNP-ROLLOVER: MISSED_ROLLOVER_PROCESS id=\(notification.id) scheduled_time=\(scheduledTimeStr)")
// Schedule next notification using existing rollover logic
// Note: fetcher parameter is unused - scheduler uses fetchScheduler instead
let scheduled = await scheduler.scheduleNextNotification(
notification,
storage: storage,
fetcher: nil // Unused - fetchScheduler handles prefetch scheduling
)
if scheduled {
processedCount += 1
NSLog("DNP-ROLLOVER: MISSED_ROLLOVER_SUCCESS id=\(notification.id)")
print("DNP-ROLLOVER: MISSED_ROLLOVER_SUCCESS id=\(notification.id)")
} else {
failedCount += 1
NSLog("DNP-ROLLOVER: MISSED_ROLLOVER_FAILED id=\(notification.id)")
print("DNP-ROLLOVER: MISSED_ROLLOVER_FAILED id=\(notification.id)")
}
}
NSLog("DNP-ROLLOVER: MISSED_ROLLOVER_COMPLETE processed=\(processedCount) failed=\(failedCount) total_checked=\(allNotifications.count)")
print("DNP-ROLLOVER: MISSED_ROLLOVER_COMPLETE processed=\(processedCount) failed=\(failedCount) total_checked=\(allNotifications.count)")
return RolloverRecoveryResult(
processedCount: processedCount,
failedCount: failedCount,
totalChecked: allNotifications.count
)
}
/** /**
* Format time for logging * Format time for logging
* *
@@ -1132,6 +1365,15 @@ struct VerificationResult {
let missingIds: [String] let missingIds: [String]
} }
/**
* Rollover recovery result
*/
struct RolloverRecoveryResult {
let processedCount: Int
let failedCount: Int
let totalChecked: Int
}
/** /**
* Reactivation errors * Reactivation errors
*/ */

View File

@@ -474,7 +474,17 @@ class DailyNotificationScheduler {
} }
// Calculate next occurrence using DST-safe calculation // Calculate next occurrence using DST-safe calculation
let nextScheduledTime = calculateNextScheduledTime(content.scheduledTime) 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 nextScheduledTimeStr = formatTime(nextScheduledTime)
let hoursUntilNext = Double(nextScheduledTime - currentTime) / 1000.0 / 60.0 / 60.0 let hoursUntilNext = Double(nextScheduledTime - currentTime) / 1000.0 / 60.0 / 60.0