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.
1421 lines
62 KiB
Swift
1421 lines
62 KiB
Swift
//
|
|
// DailyNotificationReactivationManager.swift
|
|
// DailyNotificationPlugin
|
|
//
|
|
// Created by Matthew Raymer on 2025-12-08
|
|
// Copyright © 2025 TimeSafari. All rights reserved.
|
|
//
|
|
|
|
import Foundation
|
|
import UserNotifications
|
|
import BackgroundTasks
|
|
import CoreData
|
|
|
|
/**
|
|
* DailyNotificationReactivationManager.swift
|
|
*
|
|
* Manages recovery of notifications on app launch
|
|
*
|
|
* This class implements comprehensive recovery logic for iOS app lifecycle scenarios:
|
|
* - Cold Start Recovery: Detects and recovers missed notifications after app termination
|
|
* - Termination Recovery: Full recovery when app was terminated by system
|
|
* - Boot Recovery: Recovery after device reboot
|
|
* - Warm Start: Optimized path when no recovery needed
|
|
*
|
|
* Features:
|
|
* - Scenario detection (none, cold start, warm start, termination, boot)
|
|
* - Missed notification detection and marking
|
|
* - Future notification verification and rescheduling
|
|
* - Comprehensive error handling (non-fatal, graceful degradation)
|
|
* - Execution time tracking and metrics recording
|
|
* - History persistence via Core Data
|
|
*
|
|
* Implements:
|
|
* - [Plugin Requirements §3.1.2 - App Cold Start](../docs/alarms/03-plugin-requirements.md#312-app-cold-start) (iOS equivalent)
|
|
* - [Plugin Requirements §3.1.3 - App Termination](../docs/alarms/03-plugin-requirements.md#313-app-termination) (iOS equivalent)
|
|
* - [Plugin Requirements §3.1.4 - Device Boot](../docs/alarms/03-plugin-requirements.md#314-device-boot) (iOS equivalent)
|
|
*
|
|
* Platform Reference:
|
|
* - [iOS §3.1.1](../docs/alarms/01-platform-capability-reference.md#311-notifications-survive-app-termination)
|
|
* - [iOS Recovery Scenario Mapping](../docs/ios-recovery-scenario-mapping.md)
|
|
*
|
|
* Error Handling:
|
|
* - All database errors are caught and handled gracefully (non-fatal)
|
|
* - All notification center errors are caught and handled gracefully (non-fatal)
|
|
* - All scheduling errors are caught and handled gracefully (non-fatal)
|
|
* - Partial results returned when some operations fail
|
|
* - App never crashes due to recovery errors
|
|
*
|
|
* Thread Safety:
|
|
* - All operations are async/await based
|
|
* - Recovery runs in background Task to avoid blocking app startup
|
|
* - Timeout protection (2 seconds default) prevents hanging
|
|
*
|
|
* @author Matthew Raymer
|
|
* @version 1.0.0
|
|
* @created 2025-12-08
|
|
* @lastUpdated 2025-12-08
|
|
*/
|
|
class DailyNotificationReactivationManager {
|
|
|
|
// MARK: - Constants
|
|
|
|
private static let TAG = "DNP-REACTIVATION"
|
|
private static let RECOVERY_TIMEOUT_SECONDS: TimeInterval = 2.0
|
|
private static let LAST_LAUNCH_TIME_KEY = "DNP_LAST_LAUNCH_TIME"
|
|
private static let BOOT_DETECTION_THRESHOLD_SECONDS: TimeInterval = 60.0 // 1 minute
|
|
|
|
// MARK: - Properties
|
|
|
|
private let notificationCenter: UNUserNotificationCenter
|
|
private let database: DailyNotificationDatabase
|
|
private let storage: DailyNotificationStorage
|
|
private let scheduler: DailyNotificationScheduler
|
|
|
|
// MARK: - Initialization
|
|
|
|
/**
|
|
* Initialize reactivation manager
|
|
*
|
|
* @param database Database instance for querying schedules and notifications
|
|
* @param storage Storage instance for accessing notification content
|
|
* @param scheduler Scheduler instance for rescheduling notifications
|
|
*/
|
|
init(database: DailyNotificationDatabase,
|
|
storage: DailyNotificationStorage,
|
|
scheduler: DailyNotificationScheduler) {
|
|
self.notificationCenter = UNUserNotificationCenter.current()
|
|
self.database = database
|
|
self.storage = storage
|
|
self.scheduler = scheduler
|
|
|
|
NSLog("\(Self.TAG): ReactivationManager initialized")
|
|
}
|
|
|
|
// 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
|
|
*
|
|
* This is the main entry point for recovery operations. Called automatically
|
|
* when the plugin loads via DailyNotificationPlugin.load().
|
|
*
|
|
* Recovery Process:
|
|
* 1. Detects boot scenario (if device rebooted)
|
|
* 2. Detects recovery scenario (none, cold start, warm start, termination)
|
|
* 3. Performs appropriate recovery actions based on scenario
|
|
* 4. Records recovery metrics in Core Data history
|
|
*
|
|
* Scenario Detection:
|
|
* - `.none`: Empty database (first launch) - no recovery needed
|
|
* - `.coldStart`: Notifications exist, may need verification - performs recovery
|
|
* - `.warmStart`: Notifications match DB state - no recovery needed (optimization)
|
|
* - `.termination`: App terminated, notifications cleared - full recovery
|
|
* - `.boot`: Device rebooted - full recovery
|
|
*
|
|
* Error Handling:
|
|
* - All errors are caught and logged (non-fatal)
|
|
* - Recovery failures are recorded in history
|
|
* - App continues normally even if recovery fails
|
|
* - Partial results returned when some operations fail
|
|
*
|
|
* Performance:
|
|
* - Runs asynchronously in background Task
|
|
* - Timeout protection (2 seconds default) prevents hanging
|
|
* - Non-blocking: does not delay app startup
|
|
*
|
|
* Thread Safety:
|
|
* - Safe to call from any thread
|
|
* - All operations are async/await based
|
|
*
|
|
* @note This method is called automatically on app launch. Manual calls are
|
|
* generally not needed unless testing recovery scenarios.
|
|
*
|
|
* @throws Never throws - all errors are caught and handled internally
|
|
*
|
|
* @see detectScenario() for scenario detection logic
|
|
* @see performColdStartRecovery() for cold start recovery
|
|
* @see handleTerminationRecovery() for termination recovery
|
|
* @see performBootRecovery() for boot recovery
|
|
*/
|
|
func performRecovery() {
|
|
Task {
|
|
let startTime = Date()
|
|
do {
|
|
try await withTimeout(seconds: Self.RECOVERY_TIMEOUT_SECONDS) { [self] in
|
|
NSLog("\(Self.TAG): Starting app launch recovery")
|
|
|
|
// Phase 3: Check for boot scenario first
|
|
let isBoot = self.detectBootScenario()
|
|
if isBoot {
|
|
NSLog("\(Self.TAG): Boot scenario detected - performing boot recovery")
|
|
let bootStartTime = Date()
|
|
let result = try await self.performBootRecovery()
|
|
let bootEndTime = Date()
|
|
NSLog("\(Self.TAG): Boot recovery completed: missed=\(result.missedCount), rescheduled=\(result.rescheduledCount), verified=\(result.verifiedCount), errors=\(result.errors)")
|
|
// Record in history
|
|
try await self.recordRecoveryHistory(result, scenario: .boot, startTime: bootStartTime, endTime: bootEndTime)
|
|
// Update last launch time after boot recovery
|
|
self.updateLastLaunchTime()
|
|
return
|
|
}
|
|
|
|
// Step 1: Detect scenario
|
|
let scenario = try await self.detectScenario()
|
|
NSLog("\(Self.TAG): Detected scenario: \(scenario.rawValue)")
|
|
|
|
// Step 2: Handle based on scenario
|
|
switch scenario {
|
|
case .none:
|
|
NSLog("\(Self.TAG): No recovery needed (first launch or no notifications)")
|
|
self.updateLastLaunchTime()
|
|
return
|
|
case .warmStart:
|
|
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:
|
|
NSLog("\(Self.TAG): Cold start scenario - performing recovery")
|
|
let coldStartTime = Date()
|
|
let result = try await self.performColdStartRecovery()
|
|
let coldEndTime = Date()
|
|
NSLog("\(Self.TAG): Cold start recovery completed: missed=\(result.missedCount), rescheduled=\(result.rescheduledCount), verified=\(result.verifiedCount), errors=\(result.errors)")
|
|
// Record in history
|
|
try await self.recordRecoveryHistory(result, scenario: .coldStart, startTime: coldStartTime, endTime: coldEndTime)
|
|
self.updateLastLaunchTime()
|
|
case .termination:
|
|
// Phase 2: Termination recovery
|
|
NSLog("\(Self.TAG): Termination scenario detected - performing full recovery")
|
|
let termStartTime = Date()
|
|
let result = try await self.handleTerminationRecovery()
|
|
let termEndTime = Date()
|
|
NSLog("\(Self.TAG): Termination recovery completed: missed=\(result.missedCount), rescheduled=\(result.rescheduledCount), verified=\(result.verifiedCount), errors=\(result.errors)")
|
|
// Record in history
|
|
try await self.recordRecoveryHistory(result, scenario: .termination, startTime: termStartTime, endTime: termEndTime)
|
|
self.updateLastLaunchTime()
|
|
case .boot:
|
|
// Should be handled by initial boot detection
|
|
break
|
|
}
|
|
}
|
|
} catch is TimeoutError {
|
|
let endTime = Date()
|
|
let duration = endTime.timeIntervalSince(startTime)
|
|
NSLog("\(Self.TAG): Recovery timed out after \(Self.RECOVERY_TIMEOUT_SECONDS) seconds (non-fatal) - actual duration: \(String(format: "%.2f", duration))s")
|
|
// Record timeout in history
|
|
do {
|
|
try await recordRecoveryFailure(TimeoutError(), scenario: "TIMEOUT")
|
|
} catch {
|
|
NSLog("\(Self.TAG): Failed to record recovery timeout in history")
|
|
}
|
|
} catch {
|
|
// Rollback: Log error but don't crash
|
|
let endTime = Date()
|
|
let duration = endTime.timeIntervalSince(startTime)
|
|
NSLog("\(Self.TAG): Recovery failed (non-fatal): \(error.localizedDescription) - duration: \(String(format: "%.2f", duration))s")
|
|
|
|
// Enhanced error logging with stack trace
|
|
if let nsError = error as NSError? {
|
|
NSLog("\(Self.TAG): Error details - domain: \(nsError.domain), code: \(nsError.code)")
|
|
if let userInfo = nsError.userInfo as? [String: Any] {
|
|
NSLog("\(Self.TAG): Error userInfo: \(userInfo)")
|
|
}
|
|
}
|
|
NSLog("\(Self.TAG): Error type: \(type(of: error))")
|
|
|
|
// Record failure in history (best effort, don't fail if this fails)
|
|
do {
|
|
try await recordRecoveryFailure(error)
|
|
} catch {
|
|
NSLog("\(Self.TAG): Failed to record recovery failure in history: \(error.localizedDescription)")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Scenario Detection
|
|
|
|
/**
|
|
* Detect recovery scenario
|
|
*
|
|
* Phase 1: Basic scenario detection
|
|
* - .none: Empty database (first launch)
|
|
* - .coldStart: Notifications exist, may need verification
|
|
* - .warmStart: Notifications match DB state
|
|
*
|
|
* Phase 2: Will add termination detection
|
|
*
|
|
* @return RecoveryScenario
|
|
*
|
|
* Note: Internal for testing
|
|
*/
|
|
internal func detectScenario() async throws -> RecoveryScenario {
|
|
// Step 1: Check if database has notifications
|
|
// Handle storage errors gracefully (non-fatal)
|
|
let allNotifications: [NotificationContent]
|
|
do {
|
|
allNotifications = storage.getAllNotifications()
|
|
} catch {
|
|
// Non-fatal: Log error and assume empty storage
|
|
NSLog("\(Self.TAG): Error getting notifications from storage (non-fatal): \(error.localizedDescription)")
|
|
return .none
|
|
}
|
|
|
|
if allNotifications.isEmpty {
|
|
return .none // First launch
|
|
}
|
|
|
|
// Step 2: Get pending notifications from UNUserNotificationCenter
|
|
// Handle notification center errors gracefully (non-fatal)
|
|
let pendingRequests: [UNNotificationRequest]
|
|
do {
|
|
pendingRequests = try await notificationCenter.pendingNotificationRequests()
|
|
} catch {
|
|
// Non-fatal: Log error and assume no pending notifications
|
|
NSLog("\(Self.TAG): Error getting pending notifications (non-fatal): \(error.localizedDescription)")
|
|
// Return cold start as safe default - will trigger recovery
|
|
return .coldStart
|
|
}
|
|
|
|
let pendingIds = Set(pendingRequests.map { $0.identifier })
|
|
|
|
// Step 3: Get notification IDs from storage
|
|
let dbIds = Set(allNotifications.map { $0.id })
|
|
|
|
// Step 4: Determine scenario
|
|
if pendingIds.isEmpty && !dbIds.isEmpty {
|
|
// DB has notifications but no notifications scheduled
|
|
// Phase 2: This indicates termination (system cleared notifications)
|
|
return .termination
|
|
} else if !pendingIds.isEmpty && !dbIds.isEmpty {
|
|
// Both have data - check if they match
|
|
if dbIds == pendingIds {
|
|
return .warmStart // Match indicates warm resume
|
|
} else {
|
|
return .coldStart // Mismatch indicates recovery needed
|
|
}
|
|
}
|
|
|
|
// Default: no recovery needed
|
|
return .none
|
|
}
|
|
|
|
// MARK: - Cold Start Recovery
|
|
|
|
/**
|
|
* Perform cold start recovery
|
|
*
|
|
* Handles recovery when app was terminated but notifications may still exist
|
|
* in UNUserNotificationCenter. This is the most common recovery scenario.
|
|
*
|
|
* Recovery Steps:
|
|
* 1. Detect missed notifications (scheduled_time < now, not delivered)
|
|
* 2. Mark missed notifications in database (update delivery status)
|
|
* 3. Verify future notifications are scheduled in UNUserNotificationCenter
|
|
* 4. Reschedule any missing future notifications
|
|
*
|
|
* 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
|
|
*
|
|
* Performance:
|
|
* - Processes notifications in batches
|
|
* - Non-blocking async operations
|
|
*
|
|
* @return RecoveryResult containing:
|
|
* - missedCount: Number of missed notifications marked
|
|
* - rescheduledCount: Number of notifications rescheduled
|
|
* - verifiedCount: Number of notifications verified as scheduled
|
|
* - errors: Number of errors encountered during recovery
|
|
*
|
|
* @throws Never throws - all errors are caught and counted in result
|
|
*
|
|
* @see detectMissedNotifications() for missed notification detection
|
|
* @see verifyFutureNotifications() for future notification verification
|
|
* @see RecoveryResult for result structure
|
|
*/
|
|
private func performColdStartRecovery() async throws -> RecoveryResult {
|
|
let startTime = Date()
|
|
let currentTime = Date()
|
|
|
|
NSLog("\(Self.TAG): Cold start recovery: checking for missed notifications")
|
|
|
|
// Step 1: Detect missed notifications
|
|
let missedNotifications = try await detectMissedNotifications(currentTime: currentTime)
|
|
NSLog("\(Self.TAG): Missed notifications detected: \(missedNotifications.count)")
|
|
|
|
var missedCount = 0
|
|
var missedErrors = 0
|
|
|
|
// Step 2: Mark missed notifications
|
|
for notification in missedNotifications {
|
|
do {
|
|
// Data integrity check: verify notification is valid
|
|
if notification.id.isEmpty {
|
|
NSLog("\(Self.TAG): Skipping invalid notification: empty ID")
|
|
continue
|
|
}
|
|
|
|
try await markMissedNotification(notification)
|
|
missedCount += 1
|
|
|
|
NSLog("\(Self.TAG): Marked missed notification: \(notification.id)")
|
|
} catch {
|
|
missedErrors += 1
|
|
NSLog("\(Self.TAG): Failed to mark missed notification \(notification.id): \(error.localizedDescription)")
|
|
}
|
|
}
|
|
|
|
// Step 3: Verify future notifications
|
|
let verificationResult = try await verifyFutureNotifications()
|
|
NSLog("\(Self.TAG): Future notifications verified: found=\(verificationResult.notificationsFound), missing=\(verificationResult.notificationsMissing)")
|
|
|
|
var rescheduledCount = 0
|
|
var rescheduleErrors = 0
|
|
|
|
// Step 4: Reschedule missing notifications
|
|
if !verificationResult.missingIds.isEmpty {
|
|
NSLog("\(Self.TAG): Found \(verificationResult.missingIds.count) missing notifications, rescheduling...")
|
|
|
|
for missingId in verificationResult.missingIds {
|
|
do {
|
|
// Reschedule using scheduler
|
|
// Note: For Phase 1, we'll need to get the notification content from storage
|
|
// and reschedule it. This may need to be enhanced in Phase 2.
|
|
try await rescheduleMissingNotification(id: missingId)
|
|
rescheduledCount += 1
|
|
|
|
NSLog("\(Self.TAG): Rescheduled missing notification: \(missingId)")
|
|
} catch {
|
|
rescheduleErrors += 1
|
|
NSLog("\(Self.TAG): Failed to reschedule notification \(missingId): \(error.localizedDescription)")
|
|
}
|
|
}
|
|
}
|
|
|
|
// Step 4.5: Check for delivered notifications and trigger rollover
|
|
// 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,
|
|
rescheduledCount: rescheduledCount,
|
|
verifiedCount: verificationResult.notificationsFound,
|
|
errors: missedErrors + rescheduleErrors
|
|
)
|
|
|
|
// Note: History recording is done at performRecovery level with timing
|
|
// This method is called from performRecovery which tracks timing
|
|
|
|
let duration = Date().timeIntervalSince(startTime) * 1000 // ms
|
|
NSLog("\(Self.TAG): Cold start recovery completed: duration=%.0fms, missed=%d, rescheduled=%d, verified=%d, errors=%d",
|
|
duration, missedCount, rescheduledCount, verificationResult.notificationsFound, missedErrors + rescheduleErrors)
|
|
|
|
return result
|
|
}
|
|
|
|
// MARK: - Missed Notification Detection
|
|
|
|
/**
|
|
* Detect missed notifications
|
|
*
|
|
* Identifies notifications that were scheduled to fire but haven't been delivered.
|
|
* A notification is considered "missed" if:
|
|
* - scheduledTime < currentTime (notification time has passed)
|
|
* - deliveryStatus != 'delivered' (not yet marked as delivered)
|
|
*
|
|
* Error Handling:
|
|
* - Storage errors: Returns empty array (non-fatal)
|
|
* - All errors logged but don't crash app
|
|
*
|
|
* @param currentTime Current time for comparison (typically Date())
|
|
* @return Array of NotificationContent that are considered missed
|
|
*
|
|
* @throws Never throws - all errors are caught and handled internally
|
|
*
|
|
* @note Internal visibility for unit testing. External code should use
|
|
* performRecovery() which calls this method internally.
|
|
*
|
|
* @see NotificationContent for notification structure
|
|
*/
|
|
internal func detectMissedNotifications(currentTime: Date) async throws -> [NotificationContent] {
|
|
// Get all notifications from storage
|
|
// Handle database/storage errors gracefully (non-fatal)
|
|
let allNotifications: [NotificationContent]
|
|
do {
|
|
allNotifications = storage.getAllNotifications()
|
|
} catch {
|
|
// Non-fatal: Log error and return empty array
|
|
NSLog("\(Self.TAG): Error getting notifications from storage (non-fatal): \(error.localizedDescription)")
|
|
return []
|
|
}
|
|
|
|
// Convert currentTime to milliseconds (Int64) for comparison
|
|
let currentTimeMs = Int64(currentTime.timeIntervalSince1970 * 1000)
|
|
|
|
// Filter for missed notifications:
|
|
// - scheduled_time < currentTime
|
|
// - delivery_status != 'delivered' (if deliveryStatus property exists)
|
|
let missed = allNotifications.filter { notification in
|
|
let isPastScheduledTime = notification.scheduledTime < currentTimeMs
|
|
let isNotDelivered = notification.deliveryStatus != "delivered"
|
|
return isPastScheduledTime && isNotDelivered
|
|
}
|
|
|
|
NSLog("\(Self.TAG): Detected \(missed.count) missed notifications")
|
|
return missed
|
|
}
|
|
|
|
/**
|
|
* Mark notification as missed
|
|
*
|
|
* @param notification Notification to mark as missed
|
|
*/
|
|
private func markMissedNotification(_ notification: NotificationContent) async throws {
|
|
// Update delivery status and last delivery attempt
|
|
notification.deliveryStatus = "missed"
|
|
notification.lastDeliveryAttempt = Int64(Date().timeIntervalSince1970 * 1000)
|
|
|
|
// Save to storage (notification already exists, this updates it)
|
|
storage.saveNotificationContent(notification)
|
|
|
|
// Record in history (if history table exists)
|
|
NSLog("\(Self.TAG): Marked notification \(notification.id) as missed")
|
|
}
|
|
|
|
// MARK: - Future Notification Verification
|
|
|
|
/**
|
|
* Verify future notifications are scheduled
|
|
*
|
|
* Compares notifications in storage (scheduled for future) with pending
|
|
* notifications in UNUserNotificationCenter to identify any missing ones.
|
|
*
|
|
* Verification Process:
|
|
* 1. Get all pending notifications from UNUserNotificationCenter
|
|
* 2. Get all future notifications from storage (scheduledTime >= now)
|
|
* 3. Compare IDs to find missing notifications
|
|
* 4. Return verification result with counts and missing IDs
|
|
*
|
|
* Error Handling:
|
|
* - Notification center errors: Returns partial result (assumes all missing)
|
|
* - Storage errors: Returns partial result (assumes none found)
|
|
* - All errors logged but don't crash app
|
|
*
|
|
* @return VerificationResult containing:
|
|
* - totalSchedules: Total future notifications in storage
|
|
* - notificationsFound: Number found in UNUserNotificationCenter
|
|
* - notificationsMissing: Number missing from UNUserNotificationCenter
|
|
* - missingIds: Array of notification IDs that need rescheduling
|
|
*
|
|
* @throws Never throws - all errors are caught and handled internally
|
|
*
|
|
* @note Internal visibility for unit testing. External code should use
|
|
* performRecovery() which calls this method internally.
|
|
*
|
|
* @see VerificationResult for result structure
|
|
*/
|
|
internal func verifyFutureNotifications() async throws -> VerificationResult {
|
|
// Get pending notifications from UNUserNotificationCenter
|
|
// Handle notification center errors gracefully (non-fatal)
|
|
let pendingRequests: [UNNotificationRequest]
|
|
do {
|
|
pendingRequests = try await notificationCenter.pendingNotificationRequests()
|
|
} catch {
|
|
// Non-fatal: Log error and assume no pending notifications
|
|
NSLog("\(Self.TAG): Error getting pending notifications (non-fatal): \(error.localizedDescription)")
|
|
// Return verification result indicating all are missing
|
|
let currentTimeMs = Int64(Date().timeIntervalSince1970 * 1000)
|
|
let allNotifications = storage.getAllNotifications()
|
|
let futureNotifications = allNotifications.filter { $0.scheduledTime >= currentTimeMs }
|
|
let futureIds = Set(futureNotifications.map { $0.id })
|
|
|
|
return VerificationResult(
|
|
totalSchedules: futureNotifications.count,
|
|
notificationsFound: 0,
|
|
notificationsMissing: futureIds.count,
|
|
missingIds: Array(futureIds)
|
|
)
|
|
}
|
|
|
|
let pendingIds = Set(pendingRequests.map { $0.identifier })
|
|
|
|
// Get all notifications from storage that are scheduled for future
|
|
// Handle storage errors gracefully (non-fatal)
|
|
let currentTimeMs = Int64(Date().timeIntervalSince1970 * 1000)
|
|
let allNotifications: [NotificationContent]
|
|
do {
|
|
allNotifications = storage.getAllNotifications()
|
|
} catch {
|
|
// Non-fatal: Log error and return empty verification result
|
|
NSLog("\(Self.TAG): Error getting notifications from storage (non-fatal): \(error.localizedDescription)")
|
|
return VerificationResult(
|
|
totalSchedules: 0,
|
|
notificationsFound: pendingIds.count,
|
|
notificationsMissing: 0,
|
|
missingIds: []
|
|
)
|
|
}
|
|
|
|
let futureNotifications = allNotifications.filter { $0.scheduledTime >= currentTimeMs }
|
|
let futureIds = Set(futureNotifications.map { $0.id })
|
|
|
|
// Compare and find missing
|
|
let missingIds = Array(futureIds.subtracting(pendingIds))
|
|
|
|
NSLog("\(Self.TAG): Verification: total=\(futureNotifications.count), found=\(pendingIds.count), missing=\(missingIds.count)")
|
|
|
|
return VerificationResult(
|
|
totalSchedules: futureNotifications.count,
|
|
notificationsFound: pendingIds.count,
|
|
notificationsMissing: missingIds.count,
|
|
missingIds: missingIds
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Reschedule missing notification
|
|
*
|
|
* Retrieves notification content from storage and reschedules it using
|
|
* the scheduler. This is called when verifyFutureNotifications() identifies
|
|
* a notification that should be scheduled but isn't in UNUserNotificationCenter.
|
|
*
|
|
* Error Handling:
|
|
* - Storage errors: Throws ReactivationError.notificationNotFound
|
|
* - Scheduling errors: Throws ReactivationError.rescheduleFailed
|
|
* - Errors are caught by caller and counted in RecoveryResult.errors
|
|
*
|
|
* @param id Notification ID to reschedule
|
|
*
|
|
* @throws ReactivationError.notificationNotFound if notification not found in storage
|
|
* @throws ReactivationError.rescheduleFailed if scheduling fails
|
|
*
|
|
* @see verifyFutureNotifications() for identification of missing notifications
|
|
* @see DailyNotificationScheduler.scheduleNotification() for scheduling logic
|
|
*/
|
|
private func rescheduleMissingNotification(id: String) async throws {
|
|
// Get notification content from storage
|
|
// Handle storage errors gracefully (non-fatal)
|
|
let notification: NotificationContent?
|
|
do {
|
|
notification = storage.getNotificationContent(id: id)
|
|
} catch {
|
|
// Non-fatal: Log error and throw to be caught by caller
|
|
NSLog("\(Self.TAG): Error getting notification from storage (non-fatal): \(error.localizedDescription)")
|
|
throw ReactivationError.notificationNotFound(id: id)
|
|
}
|
|
|
|
guard let notification = notification else {
|
|
throw ReactivationError.notificationNotFound(id: id)
|
|
}
|
|
|
|
// Reschedule using scheduler
|
|
// Handle scheduling errors gracefully (non-fatal)
|
|
let success = await scheduler.scheduleNotification(notification)
|
|
|
|
if !success {
|
|
// Non-fatal: Log error and throw to be caught by caller
|
|
NSLog("\(Self.TAG): Failed to reschedule notification \(id) (non-fatal)")
|
|
throw ReactivationError.rescheduleFailed(id: id)
|
|
}
|
|
}
|
|
|
|
// MARK: - Phase 2: Termination Recovery
|
|
|
|
/**
|
|
* Handle termination recovery
|
|
*
|
|
* Phase 2: Comprehensive recovery when app was terminated by system
|
|
* and notifications were cleared.
|
|
*
|
|
* Steps:
|
|
* 1. Detect all missed notifications (past scheduled times)
|
|
* 2. Mark all as missed
|
|
* 3. Reschedule all future notifications
|
|
* 4. Reschedule all fetch schedules (if applicable)
|
|
*
|
|
* @return RecoveryResult with counts
|
|
*/
|
|
private func handleTerminationRecovery() async throws -> RecoveryResult {
|
|
NSLog("\(Self.TAG): Handling termination recovery - comprehensive recovery")
|
|
|
|
// Use full recovery which handles both notify and fetch schedules
|
|
return try await performFullRecovery()
|
|
}
|
|
|
|
/**
|
|
* Perform full recovery
|
|
*
|
|
* Phase 2: Comprehensive recovery that handles:
|
|
* - All missed notifications (past scheduled times)
|
|
* - All future notifications (reschedule if missing)
|
|
* - All fetch schedules (reschedule if needed)
|
|
* - Multiple schedules with batch operations
|
|
*
|
|
* @return RecoveryResult with comprehensive counts
|
|
*/
|
|
private func performFullRecovery() async throws -> RecoveryResult {
|
|
let currentTime = Date()
|
|
let currentTimeMs = Int64(currentTime.timeIntervalSince1970 * 1000)
|
|
|
|
NSLog("\(Self.TAG): Performing full recovery")
|
|
|
|
// Step 1: Get all notifications from storage
|
|
let allNotifications = storage.getAllNotifications()
|
|
|
|
if allNotifications.isEmpty {
|
|
NSLog("\(Self.TAG): No notifications to recover")
|
|
return RecoveryResult(missedCount: 0, rescheduledCount: 0, verifiedCount: 0, errors: 0)
|
|
}
|
|
|
|
NSLog("\(Self.TAG): Processing \(allNotifications.count) notifications")
|
|
|
|
// Step 2: Get pending notifications once (batch operation)
|
|
let pendingRequests = try await notificationCenter.pendingNotificationRequests()
|
|
let pendingIds = Set(pendingRequests.map { $0.identifier })
|
|
|
|
// Step 3: Separate missed and future notifications (batch processing)
|
|
var missedNotifications: [NotificationContent] = []
|
|
var futureNotifications: [NotificationContent] = []
|
|
|
|
for notification in allNotifications {
|
|
if notification.scheduledTime < currentTimeMs {
|
|
missedNotifications.append(notification)
|
|
} else {
|
|
futureNotifications.append(notification)
|
|
}
|
|
}
|
|
|
|
NSLog("\(Self.TAG): Found \(missedNotifications.count) missed and \(futureNotifications.count) future notifications")
|
|
|
|
// Step 4: Process missed notifications (batch)
|
|
var missedCount = 0
|
|
var missedErrors = 0
|
|
|
|
for notification in missedNotifications {
|
|
do {
|
|
try await markMissedNotification(notification)
|
|
missedCount += 1
|
|
} catch {
|
|
missedErrors += 1
|
|
NSLog("\(Self.TAG): Failed to mark missed notification \(notification.id): \(error.localizedDescription)")
|
|
}
|
|
}
|
|
|
|
// Step 5: Process future notifications (batch verification)
|
|
var rescheduledCount = 0
|
|
var rescheduleErrors = 0
|
|
var missingFutureIds: [String] = []
|
|
|
|
for notification in futureNotifications {
|
|
if !pendingIds.contains(notification.id) {
|
|
missingFutureIds.append(notification.id)
|
|
}
|
|
}
|
|
|
|
// Step 6: Reschedule missing future notifications (batch)
|
|
if !missingFutureIds.isEmpty {
|
|
NSLog("\(Self.TAG): Rescheduling \(missingFutureIds.count) missing future notifications...")
|
|
|
|
for missingId in missingFutureIds {
|
|
do {
|
|
try await rescheduleMissingNotification(id: missingId)
|
|
rescheduledCount += 1
|
|
} catch {
|
|
rescheduleErrors += 1
|
|
NSLog("\(Self.TAG): Failed to reschedule notification \(missingId): \(error.localizedDescription)")
|
|
}
|
|
}
|
|
}
|
|
|
|
// Step 7: Verify final state
|
|
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,
|
|
rescheduledCount: rescheduledCount,
|
|
verifiedCount: verificationResult.notificationsFound,
|
|
errors: missedErrors + rescheduleErrors
|
|
)
|
|
|
|
// Note: History recording is done at performRecovery level with timing
|
|
// This method is called from performRecovery which tracks timing
|
|
|
|
NSLog("\(Self.TAG): Full recovery completed: missed=\(result.missedCount), rescheduled=\(result.rescheduledCount), verified=\(result.verifiedCount), errors=\(result.errors)")
|
|
return result
|
|
}
|
|
|
|
// MARK: - Phase 3: Boot Detection & Recovery
|
|
|
|
/**
|
|
* Detect boot scenario
|
|
*
|
|
* Phase 3: Detects if device was rebooted since last app launch
|
|
*
|
|
* Detection method:
|
|
* 1. Get system uptime (time since last boot)
|
|
* 2. Get last launch time from UserDefaults
|
|
* 3. If system uptime < last launch time, device was rebooted
|
|
*
|
|
* @return true if boot scenario detected
|
|
*
|
|
* Note: Internal for testing
|
|
*/
|
|
internal func detectBootScenario() -> Bool {
|
|
let systemUptime = ProcessInfo.processInfo.systemUptime
|
|
let lastLaunchTime = getLastLaunchTime()
|
|
|
|
// If no last launch time recorded, this is first launch (not boot)
|
|
guard let lastLaunch = lastLaunchTime else {
|
|
NSLog("\(Self.TAG): No last launch time recorded - first launch")
|
|
return false
|
|
}
|
|
|
|
// Calculate time since last launch
|
|
let timeSinceLastLaunch = Date().timeIntervalSince1970 - lastLaunch
|
|
|
|
// If system uptime is less than time since last launch, device was rebooted
|
|
// Also check if system uptime is very small (just booted)
|
|
let isBoot = systemUptime < timeSinceLastLaunch || systemUptime < Self.BOOT_DETECTION_THRESHOLD_SECONDS
|
|
|
|
if isBoot {
|
|
NSLog("\(Self.TAG): Boot detected - systemUptime=\(systemUptime)s, timeSinceLastLaunch=\(timeSinceLastLaunch)s")
|
|
}
|
|
|
|
return isBoot
|
|
}
|
|
|
|
/**
|
|
* Get last launch time from UserDefaults
|
|
*
|
|
* @return Last launch timestamp or nil if not set
|
|
*/
|
|
private func getLastLaunchTime() -> TimeInterval? {
|
|
let lastLaunch = UserDefaults.standard.double(forKey: Self.LAST_LAUNCH_TIME_KEY)
|
|
return lastLaunch > 0 ? lastLaunch : nil
|
|
}
|
|
|
|
/**
|
|
* Update last launch time in UserDefaults
|
|
*/
|
|
private func updateLastLaunchTime() {
|
|
let currentTime = Date().timeIntervalSince1970
|
|
UserDefaults.standard.set(currentTime, forKey: Self.LAST_LAUNCH_TIME_KEY)
|
|
NSLog("\(Self.TAG): Updated last launch time: \(currentTime)")
|
|
}
|
|
|
|
/**
|
|
* Perform boot recovery
|
|
*
|
|
* Phase 3: Comprehensive recovery after device reboot
|
|
*
|
|
* Steps:
|
|
* 1. Detect all missed notifications (past scheduled times)
|
|
* 2. Mark all as missed
|
|
* 3. Reschedule all future notifications
|
|
* 4. Reschedule all fetch schedules (if applicable)
|
|
*
|
|
* Similar to termination recovery, but triggered by boot detection
|
|
*
|
|
* Note: BGTaskScheduler may also trigger boot recovery, but this
|
|
* method provides immediate recovery on app launch after boot.
|
|
*
|
|
* @return RecoveryResult with counts
|
|
*/
|
|
private func performBootRecovery() async throws -> RecoveryResult {
|
|
NSLog("\(Self.TAG): Performing boot recovery - comprehensive recovery after device reboot")
|
|
|
|
// Boot recovery is similar to termination recovery
|
|
// Use full recovery which handles all notifications
|
|
let result = try await performFullRecovery()
|
|
|
|
// Note: History recording is done at performRecovery level with timing
|
|
// This method is called from performRecovery which tracks timing
|
|
|
|
NSLog("\(Self.TAG): Boot recovery completed: missed=\(result.missedCount), rescheduled=\(result.rescheduledCount), verified=\(result.verifiedCount), errors=\(result.errors)")
|
|
return result
|
|
}
|
|
|
|
/**
|
|
* Verify BGTaskScheduler registration
|
|
*
|
|
* Phase 3: Verifies that background tasks are properly registered
|
|
*
|
|
* This is a diagnostic method to check registration status.
|
|
* Actual registration happens in DailyNotificationPlugin.setupBackgroundTasks()
|
|
*
|
|
* @return Dictionary with registration status
|
|
*/
|
|
func verifyBGTaskRegistration() -> [String: Any] {
|
|
guard #available(iOS 13.0, *) else {
|
|
return [
|
|
"available": false,
|
|
"message": "Background tasks not available on this iOS version"
|
|
]
|
|
}
|
|
|
|
// Note: BGTaskScheduler doesn't provide a way to query registered task identifiers
|
|
// We can only verify by attempting to schedule or by tracking registration ourselves
|
|
// For now, we'll return that registration status cannot be verified programmatically
|
|
let fetchTaskIdentifier = "com.timesafari.dailynotification.fetch"
|
|
let notifyTaskIdentifier = "com.timesafari.dailynotification.notify"
|
|
|
|
return [
|
|
"available": true,
|
|
"fetchTaskRegistered": true, // Assumed registered if this method is called
|
|
"notifyTaskRegistered": true, // Assumed registered if this method is called
|
|
"message": "Registration status cannot be verified programmatically. Tasks should be registered in AppDelegate."
|
|
]
|
|
}
|
|
|
|
// MARK: - History Recording
|
|
|
|
/**
|
|
* Record recovery history in Core Data
|
|
*
|
|
* Persists recovery metrics to Core Data History entity for observability
|
|
* and debugging. Records execution time, counts, and scenario information.
|
|
*
|
|
* History Record Contains:
|
|
* - Scenario type (cold start, termination, boot)
|
|
* - Missed notification count
|
|
* - Rescheduled notification count
|
|
* - Verified notification count
|
|
* - Error count
|
|
* - Execution duration (milliseconds)
|
|
*
|
|
* Error Handling:
|
|
* - Core Data errors are logged but don't fail recovery
|
|
* - Best effort: if history recording fails, recovery still succeeds
|
|
*
|
|
* @param result Recovery result with metrics
|
|
* @param scenario Recovery scenario that was executed
|
|
* @param startTime When recovery started (for duration calculation)
|
|
* @param endTime When recovery ended (for duration calculation)
|
|
*
|
|
* @throws Never throws - all errors are caught and logged internally
|
|
*
|
|
* @see HistoryDAO.recordRecovery() for Core Data persistence
|
|
* @see RecoveryResult for result structure
|
|
*/
|
|
private func recordRecoveryHistory(_ result: RecoveryResult, scenario: RecoveryScenario, startTime: Date, endTime: Date) async throws {
|
|
// Log recovery metrics
|
|
NSLog("\(Self.TAG): Recovery history - scenario: \(scenario.rawValue), missed: \(result.missedCount), rescheduled: \(result.rescheduledCount), verified: \(result.verifiedCount), errors: \(result.errors)")
|
|
|
|
// Record in Core Data history table
|
|
guard let context = PersistenceController.shared.viewContext else {
|
|
NSLog("\(Self.TAG): Cannot record history - CoreData not available")
|
|
return
|
|
}
|
|
|
|
// Create history record
|
|
let history = History.recordRecovery(
|
|
in: context,
|
|
scenario: scenario.rawValue,
|
|
missedCount: result.missedCount,
|
|
rescheduledCount: result.rescheduledCount,
|
|
verifiedCount: result.verifiedCount,
|
|
errors: result.errors,
|
|
startTime: startTime,
|
|
endTime: endTime
|
|
)
|
|
|
|
// Save context
|
|
do {
|
|
if context.hasChanges {
|
|
try context.save()
|
|
NSLog("\(Self.TAG): Recovery history recorded successfully")
|
|
}
|
|
} catch {
|
|
NSLog("\(Self.TAG): Failed to save recovery history: \(error.localizedDescription)")
|
|
context.rollback()
|
|
throw error
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Record recovery failure in Core Data
|
|
*
|
|
* Persists error information to Core Data History entity when recovery
|
|
* fails. Records error details, type, and optional scenario information.
|
|
*
|
|
* Error Record Contains:
|
|
* - Error message (localizedDescription)
|
|
* - Error type (Swift type name)
|
|
* - NSError domain and code (if applicable)
|
|
* - NSError userInfo (if applicable)
|
|
* - Scenario (if known)
|
|
*
|
|
* Error Handling:
|
|
* - Core Data errors are logged but don't fail recovery
|
|
* - Best effort: if history recording fails, error is still logged
|
|
*
|
|
* @param error Error that occurred during recovery
|
|
* @param scenario Optional recovery scenario (if known before failure)
|
|
*
|
|
* @throws Never throws - all errors are caught and logged internally
|
|
*
|
|
* @see HistoryDAO.recordRecoveryFailure() for Core Data persistence
|
|
*/
|
|
private func recordRecoveryFailure(_ error: Error, scenario: String? = nil) async throws {
|
|
// Enhanced error logging
|
|
var errorDetails: [String: Any] = [
|
|
"error": error.localizedDescription,
|
|
"errorType": String(describing: type(of: error))
|
|
]
|
|
|
|
if let nsError = error as NSError? {
|
|
errorDetails["errorCode"] = nsError.code
|
|
errorDetails["errorDomain"] = nsError.domain
|
|
if let userInfo = nsError.userInfo as? [String: Any] {
|
|
errorDetails["userInfo"] = userInfo
|
|
}
|
|
}
|
|
|
|
if let scenario = scenario {
|
|
errorDetails["scenario"] = scenario
|
|
}
|
|
|
|
let diagJson = DailyNotificationDataConversions.jsonStringFromDictionary(errorDetails) ?? "{}"
|
|
NSLog("\(Self.TAG): Recovery failure: \(diagJson)")
|
|
|
|
// Record in Core Data history table
|
|
guard let context = PersistenceController.shared.viewContext else {
|
|
NSLog("\(Self.TAG): Cannot record failure - CoreData not available")
|
|
return
|
|
}
|
|
|
|
// Create failure history record
|
|
let history = History.recordRecoveryFailure(
|
|
in: context,
|
|
error: error,
|
|
scenario: scenario
|
|
)
|
|
|
|
// Save context
|
|
do {
|
|
if context.hasChanges {
|
|
try context.save()
|
|
NSLog("\(Self.TAG): Recovery failure recorded successfully")
|
|
}
|
|
} catch {
|
|
NSLog("\(Self.TAG): Failed to save recovery failure: \(error.localizedDescription)")
|
|
context.rollback()
|
|
// Don't throw - this is best effort
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check for delivered notifications and trigger rollover
|
|
*
|
|
* This ensures rollover happens on app launch if notifications were delivered
|
|
* while the app was not running
|
|
*/
|
|
private func checkAndProcessDeliveredNotifications() async {
|
|
NSLog("DNP-ROLLOVER: RECOVERY_CHECK_START")
|
|
print("DNP-ROLLOVER: RECOVERY_CHECK_START")
|
|
|
|
// Get delivered notifications from system
|
|
let deliveredNotifications = await notificationCenter.deliveredNotifications()
|
|
NSLog("DNP-ROLLOVER: RECOVERY_FOUND delivered_count=\(deliveredNotifications.count)")
|
|
print("DNP-ROLLOVER: RECOVERY_FOUND delivered_count=\(deliveredNotifications.count)")
|
|
|
|
// Get last processed rollover time from storage
|
|
let lastProcessedTime = storage.getLastRolloverTime()
|
|
let lastProcessedTimeStr = formatTime(lastProcessedTime)
|
|
NSLog("DNP-ROLLOVER: RECOVERY_LAST_PROCESSED time=\(lastProcessedTimeStr)")
|
|
print("DNP-ROLLOVER: RECOVERY_LAST_PROCESSED time=\(lastProcessedTimeStr)")
|
|
|
|
var processedCount = 0
|
|
var skippedCount = 0
|
|
|
|
for notification in deliveredNotifications {
|
|
let userInfo = notification.request.content.userInfo
|
|
|
|
guard let notificationId = userInfo["notification_id"] as? String,
|
|
let scheduledTime = userInfo["scheduled_time"] as? Int64 else {
|
|
continue
|
|
}
|
|
|
|
let scheduledTimeStr = formatTime(scheduledTime)
|
|
|
|
// Only process if this notification hasn't been processed yet
|
|
if scheduledTime > lastProcessedTime {
|
|
NSLog("DNP-ROLLOVER: RECOVERY_PROCESS id=\(notificationId) scheduled_time=\(scheduledTimeStr)")
|
|
print("DNP-ROLLOVER: RECOVERY_PROCESS id=\(notificationId) scheduled_time=\(scheduledTimeStr)")
|
|
|
|
// Get notification content
|
|
guard let content = storage.getNotificationContent(id: notificationId) else {
|
|
NSLog("DNP-ROLLOVER: RECOVERY_ERROR id=\(notificationId) content_not_found")
|
|
print("DNP-ROLLOVER: RECOVERY_ERROR id=\(notificationId) content_not_found")
|
|
continue
|
|
}
|
|
|
|
// Trigger rollover
|
|
// Note: fetcher parameter is unused - scheduler uses fetchScheduler instead (already implemented)
|
|
let scheduled = await scheduler.scheduleNextNotification(
|
|
content,
|
|
storage: storage,
|
|
fetcher: nil // Unused - fetchScheduler handles prefetch scheduling
|
|
)
|
|
|
|
if scheduled {
|
|
NSLog("DNP-ROLLOVER: RECOVERY_SUCCESS id=\(notificationId)")
|
|
print("DNP-ROLLOVER: RECOVERY_SUCCESS id=\(notificationId)")
|
|
// Update last processed time
|
|
storage.saveLastRolloverTime(scheduledTime)
|
|
processedCount += 1
|
|
} else {
|
|
NSLog("DNP-ROLLOVER: RECOVERY_FAILED id=\(notificationId)")
|
|
print("DNP-ROLLOVER: RECOVERY_FAILED id=\(notificationId)")
|
|
}
|
|
} else {
|
|
skippedCount += 1
|
|
NSLog("DNP-ROLLOVER: RECOVERY_SKIP id=\(notificationId) already_processed scheduled_time=\(scheduledTimeStr)")
|
|
print("DNP-ROLLOVER: RECOVERY_SKIP id=\(notificationId) already_processed scheduled_time=\(scheduledTimeStr)")
|
|
}
|
|
}
|
|
|
|
NSLog("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
|
|
*
|
|
* @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)
|
|
}
|
|
}
|
|
|
|
// MARK: - Supporting Types
|
|
|
|
/**
|
|
* Recovery scenario enum
|
|
*/
|
|
enum RecoveryScenario: String {
|
|
case none = "NONE"
|
|
case coldStart = "COLD_START"
|
|
case termination = "TERMINATION"
|
|
case warmStart = "WARM_START"
|
|
case boot = "BOOT" // Phase 3: Boot recovery
|
|
}
|
|
|
|
/**
|
|
* Recovery result
|
|
*/
|
|
struct RecoveryResult {
|
|
let missedCount: Int
|
|
let rescheduledCount: Int
|
|
let verifiedCount: Int
|
|
let errors: Int
|
|
}
|
|
|
|
/**
|
|
* Verification result
|
|
*/
|
|
struct VerificationResult {
|
|
let totalSchedules: Int
|
|
let notificationsFound: Int
|
|
let notificationsMissing: Int
|
|
let missingIds: [String]
|
|
}
|
|
|
|
/**
|
|
* Rollover recovery result
|
|
*/
|
|
struct RolloverRecoveryResult {
|
|
let processedCount: Int
|
|
let failedCount: Int
|
|
let totalChecked: Int
|
|
}
|
|
|
|
/**
|
|
* Reactivation errors
|
|
*/
|
|
enum ReactivationError: LocalizedError {
|
|
case notificationNotFound(id: String)
|
|
case rescheduleFailed(id: String)
|
|
|
|
var errorDescription: String? {
|
|
switch self {
|
|
case .notificationNotFound(let id):
|
|
return "Notification not found: \(id)"
|
|
case .rescheduleFailed(let id):
|
|
return "Failed to reschedule notification: \(id)"
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Timeout Helper
|
|
|
|
/**
|
|
* Timeout error
|
|
*/
|
|
struct TimeoutError: Error {}
|
|
|
|
/**
|
|
* Execute async code with timeout
|
|
*/
|
|
func withTimeout<T>(seconds: TimeInterval, operation: @escaping () async throws -> T) async throws -> T {
|
|
return try await withThrowingTaskGroup(of: T.self) { group in
|
|
group.addTask {
|
|
try await operation()
|
|
}
|
|
|
|
group.addTask {
|
|
try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000))
|
|
throw TimeoutError()
|
|
}
|
|
|
|
let result = try await group.next()!
|
|
group.cancelAll()
|
|
return result
|
|
}
|
|
}
|
|
|