Complete final 2 Phase 2 iOS enhancements - delivery tracking properties. Changes: - NotificationContent: Add delivery tracking properties - deliveryStatus: String? (e.g., "scheduled", "delivered", "missed", "error") - lastDeliveryAttempt: Int64? (milliseconds since epoch) - Updated Codable support (CodingKeys, init, encode) - Updated toDictionary/fromDictionary for backward compatibility - Properties are optional with default nil (backward compatible) - DailyNotificationReactivationManager: Use delivery tracking - detectMissedNotifications(): Filter by deliveryStatus != "delivered" - markMissedNotification(): Set deliveryStatus="missed" and lastDeliveryAttempt - Removed 2 TODOs, fully implemented Phase 2 Progress: 8 of 8 enhancements COMPLETE ✅ - ✅ Rolling window maintenance - ✅ TTL validation - ✅ Database statistics - ✅ Metrics recording - ✅ CoreData history - ✅ Fetcher instances clarified - ✅ deliveryStatus property (this commit) - ✅ lastDeliveryAttempt property (this commit) Verification: - TypeScript typecheck: PASS - Tests: PASS (115 tests, 8 test suites) - No linter errors - Backward compatible (optional parameters with defaults)
1179 lines
49 KiB
Swift
1179 lines
49 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 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 - no recovery needed")
|
|
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()
|
|
|
|
// 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)")
|
|
|
|
// 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)")
|
|
}
|
|
|
|
/**
|
|
* 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]
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
}
|
|
}
|
|
|