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