Files
daily-notification-plugin/ios/Plugin/DailyNotificationReactivationManager.swift
Jose Olarte III d2a1041cc4 feat(ios): add missed rollover recovery for background/inactive app scenarios
Implement enhanced app launch recovery to detect and schedule missed rollover
notifications that occurred while the app was terminated, backgrounded, or
inactive.

Key improvements:
- Detect missed rollovers on app launch by checking for past notifications
  without next scheduled notification
- Add active rollover check when app becomes active (handles inactive app
  scenario where notifications fire silently)
- Calculate forward to future time when next scheduled time is in the past
  (handles delays > rollover interval)
- Enhance duplicate detection to exclude original notification from checks
- Retry rollover if previous attempt failed (rollover time set but no next
  notification exists)

Changes:
- DailyNotificationReactivationManager: Add detectAndProcessMissedRollovers()
  method and performActiveRolloverCheck() for app becoming active
- DailyNotificationReactivationManager: Enhance warm start scenario to check
  for missed rollovers
- DailyNotificationScheduler: Add forward calculation loop when next scheduled
  time is in the past
- DailyNotificationPlugin: Register observer for UIApplication.didBecomeActiveNotification
  to trigger rollover check when app becomes active

Fixes rollover scheduling for:
- App terminated: Rollover now detected and scheduled on next launch
- App inactive/backgrounded: Rollover detected when app becomes active
- Delayed recovery: Handles cases where app reopened after rollover interval
  has passed by calculating forward to next future time

All scenarios now properly schedule rollover notifications regardless of app
state when notification fires.
2026-01-09 20:02:40 +08:00

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