You can not select more than 25 topics
			Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
		
		
		
		
		
			
		
			
				
					
					
						
							393 lines
						
					
					
						
							14 KiB
						
					
					
				
			
		
		
		
			
			
			
				
					
				
				
					
				
			
		
		
	
	
							393 lines
						
					
					
						
							14 KiB
						
					
					
				
								/**
							 | 
						|
								 * 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"
							 | 
						|
								        }
							 | 
						|
								    }
							 | 
						|
								}
							 | 
						|
								
							 |