/** * DailyNotificationTTLEnforcer.swift * * iOS TTL-at-fire enforcement for notification freshness * Implements the skip rule: if (T - fetchedAt) > ttlSeconds → skip arming * * @author Matthew Raymer * @version 1.0.0 */ import Foundation /** * Enforces TTL-at-fire rules for notification freshness on iOS * * This class implements the critical freshness enforcement: * - Before arming for T, if (T − fetchedAt) > ttlSeconds → skip * - Logs TTL violations for debugging * - Supports both SQLite and UserDefaults storage * - Provides freshness validation before scheduling */ class DailyNotificationTTLEnforcer { // MARK: - Constants private static let TAG = "DailyNotificationTTLEnforcer" private static let LOG_CODE_TTL_VIOLATION = "TTL_VIOLATION" // Default TTL values private static let DEFAULT_TTL_SECONDS: TimeInterval = 3600 // 1 hour private static let MIN_TTL_SECONDS: TimeInterval = 60 // 1 minute private static let MAX_TTL_SECONDS: TimeInterval = 86400 // 24 hours // MARK: - Properties private let database: DailyNotificationDatabase? private let useSharedStorage: Bool // MARK: - Initialization /** * Initialize TTL enforcer * * @param database SQLite database (nil if using UserDefaults) * @param useSharedStorage Whether to use SQLite or UserDefaults */ init(database: DailyNotificationDatabase?, useSharedStorage: Bool) { self.database = database self.useSharedStorage = useSharedStorage print("\(Self.TAG): TTL enforcer initialized with \(useSharedStorage ? "SQLite" : "UserDefaults")") } // MARK: - Freshness Validation /** * Check if notification content is fresh enough to arm * * @param slotId Notification slot ID * @param scheduledTime T (slot time) - when notification should fire * @param fetchedAt When content was fetched * @return true if content is fresh enough to arm */ func isContentFresh(slotId: String, scheduledTime: Date, fetchedAt: Date) -> Bool { do { let ttlSeconds = getTTLSeconds() // Calculate age at fire time let ageAtFireTime = scheduledTime.timeIntervalSince(fetchedAt) let ageAtFireSeconds = ageAtFireTime let isFresh = ageAtFireSeconds <= ttlSeconds if !isFresh { logTTLViolation(slotId: slotId, scheduledTime: scheduledTime, fetchedAt: fetchedAt, ageAtFireSeconds: ageAtFireSeconds, ttlSeconds: ttlSeconds) } print("\(Self.TAG): TTL check for \(slotId): age=\(Int(ageAtFireSeconds))s, ttl=\(Int(ttlSeconds))s, fresh=\(isFresh)") return isFresh } catch { print("\(Self.TAG): Error checking content freshness: \(error)") // Default to allowing arming if check fails return true } } /** * Check if notification content is fresh enough to arm (using stored fetchedAt) * * @param slotId Notification slot ID * @param scheduledTime T (slot time) - when notification should fire * @return true if content is fresh enough to arm */ func isContentFresh(slotId: String, scheduledTime: Date) -> Bool { do { guard let fetchedAt = getFetchedAt(slotId: slotId) else { print("\(Self.TAG): No fetchedAt found for slot: \(slotId)") return false } return isContentFresh(slotId: slotId, scheduledTime: scheduledTime, fetchedAt: fetchedAt) } catch { print("\(Self.TAG): Error checking content freshness for slot: \(slotId), error: \(error)") return false } } /** * Validate freshness before arming notification * * @param notificationContent Notification content to validate * @return true if notification should be armed */ func validateBeforeArming(_ notificationContent: NotificationContent) -> Bool { do { let slotId = notificationContent.id let scheduledTime = Date(timeIntervalSince1970: notificationContent.scheduledTime / 1000) let fetchedAt = Date(timeIntervalSince1970: notificationContent.fetchedAt / 1000) print("\(Self.TAG): Validating freshness before arming: slot=\(slotId), scheduled=\(scheduledTime), fetched=\(fetchedAt)") let isFresh = isContentFresh(slotId: slotId, scheduledTime: scheduledTime, fetchedAt: fetchedAt) if !isFresh { print("\(Self.TAG): Skipping arming due to TTL violation: \(slotId)") return false } print("\(Self.TAG): Content is fresh, proceeding with arming: \(slotId)") return true } catch { print("\(Self.TAG): Error validating freshness before arming: \(error)") return false } } // MARK: - TTL Configuration /** * Get TTL seconds from configuration * * @return TTL in seconds */ private func getTTLSeconds() -> TimeInterval { do { if useSharedStorage, let database = database { return getTTLFromSQLite(database: database) } else { return getTTLFromUserDefaults() } } catch { print("\(Self.TAG): Error getting TTL seconds: \(error)") return Self.DEFAULT_TTL_SECONDS } } /** * Get TTL from SQLite database * * @param database SQLite database instance * @return TTL in seconds */ private func getTTLFromSQLite(database: DailyNotificationDatabase) -> TimeInterval { do { // This would typically query the database for TTL configuration // For now, we'll return the default value let ttlSeconds = Self.DEFAULT_TTL_SECONDS // Validate TTL range let validatedTTL = max(Self.MIN_TTL_SECONDS, min(Self.MAX_TTL_SECONDS, ttlSeconds)) return validatedTTL } catch { print("\(Self.TAG): Error getting TTL from SQLite: \(error)") return Self.DEFAULT_TTL_SECONDS } } /** * Get TTL from UserDefaults * * @return TTL in seconds */ private func getTTLFromUserDefaults() -> TimeInterval { do { let ttlSeconds = UserDefaults.standard.double(forKey: "ttlSeconds") let finalTTL = ttlSeconds > 0 ? ttlSeconds : Self.DEFAULT_TTL_SECONDS // Validate TTL range let validatedTTL = max(Self.MIN_TTL_SECONDS, min(Self.MAX_TTL_SECONDS, finalTTL)) return validatedTTL } catch { print("\(Self.TAG): Error getting TTL from UserDefaults: \(error)") return Self.DEFAULT_TTL_SECONDS } } // MARK: - FetchedAt Retrieval /** * Get fetchedAt timestamp for a slot * * @param slotId Notification slot ID * @return FetchedAt timestamp */ private func getFetchedAt(slotId: String) -> Date? { do { if useSharedStorage, let database = database { return getFetchedAtFromSQLite(database: database, slotId: slotId) } else { return getFetchedAtFromUserDefaults(slotId: slotId) } } catch { print("\(Self.TAG): Error getting fetchedAt for slot: \(slotId), error: \(error)") return nil } } /** * Get fetchedAt from SQLite database * * @param database SQLite database instance * @param slotId Notification slot ID * @return FetchedAt timestamp */ private func getFetchedAtFromSQLite(database: DailyNotificationDatabase, slotId: String) -> Date? { do { // This would typically query the database for fetchedAt // For now, we'll return nil return nil } catch { print("\(Self.TAG): Error getting fetchedAt from SQLite: \(error)") return nil } } /** * Get fetchedAt from UserDefaults * * @param slotId Notification slot ID * @return FetchedAt timestamp */ private func getFetchedAtFromUserDefaults(slotId: String) -> Date? { do { let timestamp = UserDefaults.standard.double(forKey: "last_fetch_\(slotId)") return timestamp > 0 ? Date(timeIntervalSince1970: timestamp / 1000) : nil } catch { print("\(Self.TAG): Error getting fetchedAt from UserDefaults: \(error)") return nil } } // MARK: - TTL Violation Logging /** * Log TTL violation with detailed information * * @param slotId Notification slot ID * @param scheduledTime When notification was scheduled to fire * @param fetchedAt When content was fetched * @param ageAtFireSeconds Age of content at fire time * @param ttlSeconds TTL limit in seconds */ private func logTTLViolation(slotId: String, scheduledTime: Date, fetchedAt: Date, ageAtFireSeconds: TimeInterval, ttlSeconds: TimeInterval) { do { let violationMessage = String(format: "TTL violation: slot=%@, scheduled=%@, fetched=%@, age=%.0fs, ttl=%.0fs", slotId, scheduledTime.description, fetchedAt.description, ageAtFireSeconds, ttlSeconds) print("\(Self.TAG): \(Self.LOG_CODE_TTL_VIOLATION): \(violationMessage)") // Store violation for analytics storeTTLViolation(slotId: slotId, scheduledTime: scheduledTime, fetchedAt: fetchedAt, ageAtFireSeconds: ageAtFireSeconds, ttlSeconds: ttlSeconds) } catch { print("\(Self.TAG): Error logging TTL violation: \(error)") } } /** * Store TTL violation for analytics */ private func storeTTLViolation(slotId: String, scheduledTime: Date, fetchedAt: Date, ageAtFireSeconds: TimeInterval, ttlSeconds: TimeInterval) { do { if useSharedStorage, let database = database { storeTTLViolationInSQLite(database: database, slotId: slotId, scheduledTime: scheduledTime, fetchedAt: fetchedAt, ageAtFireSeconds: ageAtFireSeconds, ttlSeconds: ttlSeconds) } else { storeTTLViolationInUserDefaults(slotId: slotId, scheduledTime: scheduledTime, fetchedAt: fetchedAt, ageAtFireSeconds: ageAtFireSeconds, ttlSeconds: ttlSeconds) } } catch { print("\(Self.TAG): Error storing TTL violation: \(error)") } } /** * Store TTL violation in SQLite database */ private func storeTTLViolationInSQLite(database: DailyNotificationDatabase, slotId: String, scheduledTime: Date, fetchedAt: Date, ageAtFireSeconds: TimeInterval, ttlSeconds: TimeInterval) { do { // This would typically insert into the database // For now, we'll just log the action print("\(Self.TAG): Storing TTL violation in SQLite for slot: \(slotId)") } catch { print("\(Self.TAG): Error storing TTL violation in SQLite: \(error)") } } /** * Store TTL violation in UserDefaults */ private func storeTTLViolationInUserDefaults(slotId: String, scheduledTime: Date, fetchedAt: Date, ageAtFireSeconds: TimeInterval, ttlSeconds: TimeInterval) { do { let violationKey = "ttl_violation_\(slotId)_\(Int(scheduledTime.timeIntervalSince1970))" let violationValue = "\(Int(fetchedAt.timeIntervalSince1970 * 1000)),\(Int(ageAtFireSeconds)),\(Int(ttlSeconds)),\(Int(Date().timeIntervalSince1970 * 1000))" UserDefaults.standard.set(violationValue, forKey: violationKey) } catch { print("\(Self.TAG): Error storing TTL violation in UserDefaults: \(error)") } } // MARK: - Statistics /** * Get TTL violation statistics * * @return Statistics string */ func getTTLViolationStats() -> String { do { if useSharedStorage, let database = database { return getTTLViolationStatsFromSQLite(database: database) } else { return getTTLViolationStatsFromUserDefaults() } } catch { print("\(Self.TAG): Error getting TTL violation stats: \(error)") return "Error retrieving TTL violation statistics" } } /** * Get TTL violation statistics from SQLite */ private func getTTLViolationStatsFromSQLite(database: DailyNotificationDatabase) -> String { do { // This would typically query the database for violation count // For now, we'll return a placeholder return "TTL violations: 0" } catch { print("\(Self.TAG): Error getting TTL violation stats from SQLite: \(error)") return "Error retrieving TTL violation statistics" } } /** * Get TTL violation statistics from UserDefaults */ private func getTTLViolationStatsFromUserDefaults() -> String { do { let allKeys = UserDefaults.standard.dictionaryRepresentation().keys let violationCount = allKeys.filter { $0.hasPrefix("ttl_violation_") }.count return "TTL violations: \(violationCount)" } catch { print("\(Self.TAG): Error getting TTL violation stats from UserDefaults: \(error)") return "Error retrieving TTL violation statistics" } } }