Files
daily-notification-plugin/ios/Plugin/DailyNotificationTTLEnforcer.swift
Matthew Raymer 5eebae9556 feat(ios): implement Phase 2.1 iOS background tasks with T–lead prefetch
- Add DailyNotificationBackgroundTaskManager with BGTaskScheduler integration
- Add DailyNotificationTTLEnforcer for iOS freshness validation
- Add DailyNotificationRollingWindow for iOS capacity management
- Add DailyNotificationDatabase with SQLite schema and WAL mode
- Add NotificationContent data structure for iOS
- Update DailyNotificationPlugin with background task integration
- Add phase2-1-ios-background-tasks.ts usage examples

This implements the critical Phase 2.1 iOS background execution:
- BGTaskScheduler integration for T–lead prefetch
- Single-attempt prefetch with 12s timeout
- ETag/304 caching support for efficient content updates
- Background execution constraints handling
- Integration with existing TTL enforcement and rolling window
- iOS-specific capacity limits and notification management

Files: 7 changed, 2088 insertions(+), 299 deletions(-)
2025-09-08 10:30:13 +00:00

394 lines
14 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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"
}
}
}