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"
|
|
}
|
|
}
|
|
}
|
|
|