feat(ios): add Core Data DAO layer and unit tests
Implement comprehensive data access layer for Core Data entities: - Add NotificationContentDAO, NotificationDeliveryDAO, and NotificationConfigDAO with full CRUD operations and query helpers - Add DailyNotificationDataConversions utility for type conversions (Date ↔ Int64, Int ↔ Int32, JSON, optional strings) - Update PersistenceController with entity verification and migration policies - Add comprehensive unit tests for all DAO classes and data conversions - Update Core Data model with NotificationContent, NotificationDelivery, and NotificationConfig entities (relationships and indexes) - Integrate ReactivationManager into DailyNotificationPlugin.load() DAO Features: - Create/Insert methods with dictionary support - Read/Query methods with predicates (by timesafariDid, notificationType, scheduledTime range, deliveryStatus, etc.) - Update methods (touch, updateDeliveryStatus, recordUserInteraction) - Delete methods (by ID, by key, delete all) - Relationship management (NotificationContent ↔ NotificationDelivery) - Cascade delete support Test Coverage: - 328 lines: DailyNotificationDataConversionsTests (time, numeric, string, JSON) - 490 lines: NotificationContentDAOTests (CRUD, queries, updates) - 415 lines: NotificationDeliveryDAOTests (CRUD, relationships, cascade delete) - 412 lines: NotificationConfigDAOTests (CRUD, queries, active filtering) All tests use in-memory Core Data stack for isolation and speed. Completes sections 4.4, 4.5, and 6.0 of iOS implementation checklist.
This commit is contained in:
@@ -0,0 +1,738 @@
|
||||
//
|
||||
// DailyNotificationReactivationManager.swift
|
||||
// DailyNotificationPlugin
|
||||
//
|
||||
// Created by Matthew Raymer on 2025-12-08
|
||||
// Copyright © 2025 TimeSafari. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import UserNotifications
|
||||
import BackgroundTasks
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
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 result = try await performBootRecovery()
|
||||
NSLog("\(Self.TAG): Boot recovery completed: missed=\(result.missedCount), rescheduled=\(result.rescheduledCount), verified=\(result.verifiedCount), errors=\(result.errors)")
|
||||
// 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:
|
||||
let result = try await performColdStartRecovery()
|
||||
NSLog("\(Self.TAG): App launch recovery completed: missed=\(result.missedCount), rescheduled=\(result.rescheduledCount), verified=\(result.verifiedCount), errors=\(result.errors)")
|
||||
updateLastLaunchTime()
|
||||
case .termination:
|
||||
// Phase 2: Termination recovery
|
||||
NSLog("\(Self.TAG): Termination scenario detected - performing full recovery")
|
||||
let result = try await handleTerminationRecovery()
|
||||
NSLog("\(Self.TAG): Termination recovery completed: missed=\(result.missedCount), rescheduled=\(result.rescheduledCount), verified=\(result.verifiedCount), errors=\(result.errors)")
|
||||
updateLastLaunchTime()
|
||||
}
|
||||
}
|
||||
} catch is TimeoutError {
|
||||
NSLog("\(Self.TAG): Recovery timed out after \(Self.RECOVERY_TIMEOUT_SECONDS) seconds (non-fatal)")
|
||||
} catch {
|
||||
// Rollback: Log error but don't crash
|
||||
NSLog("\(Self.TAG): Recovery failed (non-fatal): \(error.localizedDescription)")
|
||||
// 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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
let allNotifications = storage.getAllNotifications()
|
||||
|
||||
if allNotifications.isEmpty {
|
||||
return .none // First launch
|
||||
}
|
||||
|
||||
// Step 2: Get pending notifications from UNUserNotificationCenter
|
||||
let pendingRequests = try await notificationCenter.pendingNotificationRequests()
|
||||
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)
|
||||
|
||||
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()
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
try await recordRecoveryHistory(result, scenario: .coldStart)
|
||||
|
||||
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
|
||||
let allNotifications = storage.getAllNotifications()
|
||||
|
||||
// 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
|
||||
let pendingRequests = try await notificationCenter.pendingNotificationRequests()
|
||||
let pendingIds = Set(pendingRequests.map { $0.identifier })
|
||||
|
||||
// Get all notifications from storage that are scheduled for future
|
||||
let currentTimeMs = Int64(Date().timeIntervalSince1970 * 1000)
|
||||
let allNotifications = storage.getAllNotifications()
|
||||
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
|
||||
guard let notification = storage.getNotificationContent(id: id) else {
|
||||
throw ReactivationError.notificationNotFound(id: id)
|
||||
}
|
||||
|
||||
// Reschedule using scheduler
|
||||
let success = await scheduler.scheduleNotification(notification)
|
||||
|
||||
if !success {
|
||||
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()
|
||||
|
||||
// Record recovery in history
|
||||
let result = RecoveryResult(
|
||||
missedCount: missedCount,
|
||||
rescheduledCount: rescheduledCount,
|
||||
verifiedCount: verificationResult.notificationsFound,
|
||||
errors: missedErrors + rescheduleErrors
|
||||
)
|
||||
|
||||
try await recordRecoveryHistory(result, scenario: .termination)
|
||||
|
||||
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()
|
||||
|
||||
// Record as boot recovery in history
|
||||
try await recordRecoveryHistory(result, scenario: .boot)
|
||||
|
||||
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
|
||||
*/
|
||||
private func recordRecoveryHistory(_ result: RecoveryResult, scenario: RecoveryScenario) async throws {
|
||||
// Note: History recording implementation depends on database structure
|
||||
// For Phase 1, we'll log the recovery result
|
||||
let diagJson = """
|
||||
{
|
||||
"scenario": "\(scenario.rawValue)",
|
||||
"missedCount": \(result.missedCount),
|
||||
"rescheduledCount": \(result.rescheduledCount),
|
||||
"verifiedCount": \(result.verifiedCount),
|
||||
"errors": \(result.errors)
|
||||
}
|
||||
"""
|
||||
|
||||
NSLog("\(Self.TAG): Recovery history: \(diagJson)")
|
||||
|
||||
// TODO: Record in history table when database structure supports it
|
||||
}
|
||||
|
||||
/**
|
||||
* Record recovery failure
|
||||
*
|
||||
* @param error Error that occurred
|
||||
*/
|
||||
private func recordRecoveryFailure(_ error: Error) async throws {
|
||||
let diagJson = """
|
||||
{
|
||||
"error": "\(error.localizedDescription)",
|
||||
"errorType": "\(type(of: error))"
|
||||
}
|
||||
"""
|
||||
|
||||
NSLog("\(Self.TAG): Recovery failure: \(diagJson)")
|
||||
|
||||
// TODO: Record in history table when database structure supports it
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user