Files
daily-notification-plugin/ios/Plugin/DailyNotificationReactivationManager.swift
Matthew 3649e76c49 feat(ios): add error handling and integration tests
Implement comprehensive error handling and integration test suite:

Error Handling (Section 8):
- Add iOS-specific error codes to DailyNotificationErrorCodes:
  - NOTIFICATION_PERMISSION_DENIED
  - PENDING_NOTIFICATION_LIMIT_EXCEEDED
  - BG_TASK_NOT_REGISTERED
  - BG_TASK_EXECUTION_FAILED
  - BACKGROUND_REFRESH_DISABLED
- Add helper methods for iOS-specific error responses
- Enhance error handling in ReactivationManager:
  - Database errors handled gracefully (non-fatal)
  - Notification center errors handled gracefully (non-fatal)
  - Scheduling errors handled gracefully (non-fatal)
  - All errors logged, app continues normally
  - Partial results returned when operations fail
- Update plugin methods to use iOS-specific error codes:
  - getNotificationPermissionStatus uses NOTIFICATION_PERMISSION_DENIED

Integration Tests (Section 9.2):
- Add DailyNotificationRecoveryIntegrationTests:
  - Full recovery flow tests (cold start, termination)
  - Error handling tests (database, notification center, scheduling)
  - App stability tests (no crashes, concurrent operations)
  - Partial recovery tests
  - Timeout handling tests
- Test coverage:
  - 10 integration tests covering recovery scenarios
  - Error handling verification
  - App stability verification
  - Concurrent operation safety

Completes sections 8.1, 8.2, and 9.2 of iOS implementation checklist.
2025-12-09 02:46:13 -08:00

910 lines
36 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
/**
* Manages recovery of notifications on app launch
* Phase 1: Cold start recovery only
*
* Implements:
* - [Plugin Requirements §3.1.2 - App Cold Start](../docs/alarms/03-plugin-requirements.md#312-app-cold-start) (iOS equivalent)
* Platform Reference: [iOS §3.1.1](../docs/alarms/01-platform-capability-reference.md#311-notifications-survive-app-termination)
*
* @author Matthew Raymer
* @version 1.0.0 - Phase 1: Cold start recovery
*/
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
* Phase 3: Includes boot detection and recovery
*
* Scenario detection implemented:
* - .none: Empty database (first launch)
* - .coldStart: Notifications exist, may need verification
* - .warmStart: Notifications match DB state (optimization, no recovery)
* - .termination: App terminated, notifications cleared
*
* Phase 3: Boot detection added
*
* Runs asynchronously with timeout to avoid blocking app startup
*
* Rollback Safety: If recovery fails, app continues normally
*/
func performRecovery() {
Task {
let startTime = Date()
do {
try await withTimeout(seconds: Self.RECOVERY_TIMEOUT_SECONDS) {
NSLog("\(Self.TAG): Starting app launch recovery")
// Phase 3: Check for boot scenario first
let isBoot = detectBootScenario()
if isBoot {
NSLog("\(Self.TAG): Boot scenario detected - performing boot recovery")
let bootStartTime = Date()
let result = try await 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 recordRecoveryHistory(result, scenario: .boot, startTime: bootStartTime, endTime: bootEndTime)
// Update last launch time after boot recovery
updateLastLaunchTime()
return
}
// Step 1: Detect scenario
let scenario = try await 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)")
updateLastLaunchTime()
return
case .warmStart:
NSLog("\(Self.TAG): Warm start detected - no recovery needed")
updateLastLaunchTime()
return
case .coldStart:
NSLog("\(Self.TAG): Cold start scenario - performing recovery")
let coldStartTime = Date()
let result = try await 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 recordRecoveryHistory(result, scenario: .coldStart, startTime: coldStartTime, endTime: coldEndTime)
updateLastLaunchTime()
case .termination:
// Phase 2: Termination recovery
NSLog("\(Self.TAG): Termination scenario detected - performing full recovery")
let termStartTime = Date()
let result = try await 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 recordRecoveryHistory(result, scenario: .termination, startTime: termStartTime, endTime: termEndTime)
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
*
* Steps:
* 1. Detect missed notifications (scheduled_time < now, not delivered)
* 2. Mark missed notifications in database
* 3. Verify future notifications are scheduled
* 4. Reschedule missing future notifications
*
* @return RecoveryResult with counts
*/
private func performColdStartRecovery() async throws -> RecoveryResult {
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)")
}
}
}
// 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
return result
}
// MARK: - Missed Notification Detection
/**
* Detect missed notifications
*
* @param currentTime Current time for comparison
* @return Array of missed notifications
*
* Note: Internal for testing
*/
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)
// Note: For Phase 1, we'll check if notification is past scheduled time
// In Phase 2, we'll add deliveryStatus tracking
let missed = allNotifications.filter { notification in
notification.scheduledTime < currentTimeMs
// TODO: Add deliveryStatus check when property is added to NotificationContent
}
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 {
// Note: NotificationContent doesn't have deliveryStatus property yet
// For Phase 1, we'll save the notification with updated metadata
// In Phase 2, we'll add deliveryStatus tracking to NotificationContent
// Save to storage (notification already exists, this updates it)
storage.saveNotificationContent(notification)
// Record in history (if history table exists)
// Note: History recording may need to be implemented based on database structure
NSLog("\(Self.TAG): Marked notification \(notification.id) as missed")
// TODO: Add deliveryStatus property to NotificationContent in Phase 2
// TODO: Add lastDeliveryAttempt property to NotificationContent in Phase 2
}
// MARK: - Future Notification Verification
/**
* Verify future notifications are scheduled
*
* @return VerificationResult with comparison details
*
* Note: Internal for testing
*/
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
*
* @param id Notification ID to reschedule
*/
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"
]
}
let registeredIdentifiers = BGTaskScheduler.shared.registeredTaskIdentifiers
let fetchTaskRegistered = registeredIdentifiers.contains("com.timesafari.dailynotification.fetch")
let notifyTaskRegistered = registeredIdentifiers.contains("com.timesafari.dailynotification.notify")
return [
"available": true,
"fetchTaskRegistered": fetchTaskRegistered,
"notifyTaskRegistered": notifyTaskRegistered,
"registeredIdentifiers": Array(registeredIdentifiers.map { $0.rawValue })
]
}
// MARK: - History Recording
/**
* Record recovery history
*
* @param result Recovery result
* @param scenario Recovery scenario
* @param startTime When recovery started
* @param endTime When recovery ended
*/
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
*
* @param error Error that occurred
* @param scenario Optional recovery scenario (if known)
*/
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
}
}
}
// 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
}
}