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:
@@ -7,6 +7,7 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import UIKit
|
||||
import Capacitor
|
||||
import UserNotifications
|
||||
import BackgroundTasks
|
||||
@@ -84,6 +85,17 @@ public class DailyNotificationPlugin: CAPPlugin {
|
||||
NSLog("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")
|
||||
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
|
||||
*
|
||||
|
||||
@@ -94,6 +94,35 @@ class DailyNotificationReactivationManager {
|
||||
|
||||
// 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
|
||||
*
|
||||
@@ -171,7 +200,22 @@ class DailyNotificationReactivationManager {
|
||||
self.updateLastLaunchTime()
|
||||
return
|
||||
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()
|
||||
return
|
||||
case .coldStart:
|
||||
@@ -398,6 +442,11 @@ class DailyNotificationReactivationManager {
|
||||
// This handles notifications that were delivered while app was not running
|
||||
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
|
||||
let result = RecoveryResult(
|
||||
missedCount: missedCount,
|
||||
@@ -734,6 +783,11 @@ class DailyNotificationReactivationManager {
|
||||
let verificationResult = try await verifyFutureNotifications()
|
||||
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
|
||||
let result = RecoveryResult(
|
||||
missedCount: missedCount,
|
||||
@@ -1084,6 +1138,185 @@ class DailyNotificationReactivationManager {
|
||||
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
|
||||
*
|
||||
@@ -1132,6 +1365,15 @@ struct VerificationResult {
|
||||
let missingIds: [String]
|
||||
}
|
||||
|
||||
/**
|
||||
* Rollover recovery result
|
||||
*/
|
||||
struct RolloverRecoveryResult {
|
||||
let processedCount: Int
|
||||
let failedCount: Int
|
||||
let totalChecked: Int
|
||||
}
|
||||
|
||||
/**
|
||||
* Reactivation errors
|
||||
*/
|
||||
|
||||
@@ -474,7 +474,17 @@ class DailyNotificationScheduler {
|
||||
}
|
||||
|
||||
// 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 hoursUntilNext = Double(nextScheduledTime - currentTime) / 1000.0 / 60.0 / 60.0
|
||||
|
||||
|
||||
Reference in New Issue
Block a user