/** * DailyNotificationStorage.swift * * Storage management for notification content and settings * Implements tiered storage: Key-Value (quick) + DB (structured) + Files (large assets) * * @author Matthew Raymer * @version 1.0.0 */ import Foundation /** * Manages storage for notification content and settings * * This class implements the tiered storage approach: * - Tier 1: UserDefaults for quick access to settings and recent data * - Tier 2: In-memory cache for structured notification content * - Tier 3: File system for large assets (future use) */ class DailyNotificationStorage { // MARK: - Constants private static let TAG = "DailyNotificationStorage" private static let PREFS_NAME = "DailyNotificationPrefs" private static let KEY_NOTIFICATIONS = "notifications" private static let KEY_SETTINGS = "settings" private static let KEY_LAST_FETCH = "last_fetch" private static let KEY_ADAPTIVE_SCHEDULING = "adaptive_scheduling" private static let MAX_CACHE_SIZE = 100 // Maximum notifications to keep in memory private static let CACHE_CLEANUP_INTERVAL: TimeInterval = 24 * 60 * 60 // 24 hours private static let MAX_STORAGE_ENTRIES = 100 // Maximum total storage entries private static let RETENTION_PERIOD_MS: TimeInterval = 14 * 24 * 60 * 60 * 1000 // 14 days private static let BATCH_CLEANUP_SIZE = 50 // Clean up in batches // MARK: - Properties private let userDefaults: UserDefaults private var notificationCache: [String: NotificationContent] = [:] private var notificationList: [NotificationContent] = [] private let storageQueue = DispatchQueue(label: "storage.queue", attributes: .concurrent) private let logger: DailyNotificationLogger? // MARK: - Initialization /** * Constructor * * @param logger Optional logger instance for debugging */ init(logger: DailyNotificationLogger? = nil) { self.userDefaults = UserDefaults(suiteName: Self.PREFS_NAME) ?? UserDefaults.standard self.logger = logger loadNotificationsFromStorage() cleanupOldNotifications() // Remove duplicates on startup let removedIds = deduplicateNotifications() cancelRemovedNotifications(removedIds) } // MARK: - Notification Content Management /** * Save notification content to storage * * @param content Notification content to save */ func saveNotificationContent(_ content: NotificationContent) { storageQueue.async(flags: .barrier) { self.logger?.log(.debug, "DN|STORAGE_SAVE_START id=\(content.id)") // Add to cache self.notificationCache[content.id] = content // Add to list and sort by scheduled time self.notificationList.removeAll { $0.id == content.id } self.notificationList.append(content) self.notificationList.sort { $0.scheduledTime < $1.scheduledTime } // Apply storage cap and retention policy self.enforceStorageLimits() // Persist to UserDefaults self.saveNotificationsToStorage() self.logger?.log(.debug, "DN|STORAGE_SAVE_OK id=\(content.id) total=\(self.notificationList.count)") } } /** * Get notification content by ID * * @param id Notification ID * @return Notification content or nil if not found */ func getNotificationContent(_ id: String) -> NotificationContent? { return storageQueue.sync { return notificationCache[id] } } /** * Get the last notification that was delivered * * @return Last notification or nil if none exists */ func getLastNotification() -> NotificationContent? { return storageQueue.sync { if notificationList.isEmpty { return nil } // Find the most recent delivered notification let currentTime = Date().timeIntervalSince1970 * 1000 for notification in notificationList.reversed() { if notification.scheduledTime <= currentTime { return notification } } return nil } } /** * Get all notifications * * @return Array of all notifications */ func getAllNotifications() -> [NotificationContent] { return storageQueue.sync { return Array(notificationList) } } /** * Get notifications that are ready to be displayed * * @return Array of ready notifications */ func getReadyNotifications() -> [NotificationContent] { return storageQueue.sync { let currentTime = Date().timeIntervalSince1970 * 1000 return notificationList.filter { $0.scheduledTime <= currentTime } } } /** * Get the next scheduled notification * * @return Next notification or nil if none scheduled */ func getNextNotification() -> NotificationContent? { return storageQueue.sync { let currentTime = Date().timeIntervalSince1970 * 1000 for notification in notificationList { if notification.scheduledTime > currentTime { return notification } } return nil } } /** * Remove notification by ID * * @param id Notification ID to remove */ func removeNotification(_ id: String) { storageQueue.async(flags: .barrier) { self.notificationCache.removeValue(forKey: id) self.notificationList.removeAll { $0.id == id } self.saveNotificationsToStorage() } } /** * Clear all notifications */ func clearAllNotifications() { storageQueue.async(flags: .barrier) { self.notificationCache.removeAll() self.notificationList.removeAll() self.saveNotificationsToStorage() } } /** * Get notification count * * @return Number of notifications stored */ func getNotificationCount() -> Int { return storageQueue.sync { return notificationList.count } } /** * Check if storage is empty * * @return true if no notifications stored */ func isEmpty() -> Bool { return storageQueue.sync { return notificationList.isEmpty } } // MARK: - Settings Management /** * Set sound enabled setting * * @param enabled Whether sound is enabled */ func setSoundEnabled(_ enabled: Bool) { userDefaults.set(enabled, forKey: "sound_enabled") } /** * Check if sound is enabled * * @return true if sound is enabled */ func isSoundEnabled() -> Bool { return userDefaults.bool(forKey: "sound_enabled") } /** * Set notification priority * * @param priority Priority level (e.g., "high", "normal", "low") */ func setPriority(_ priority: String) { userDefaults.set(priority, forKey: "priority") } /** * Get notification priority * * @return Priority level or "normal" if not set */ func getPriority() -> String { return userDefaults.string(forKey: "priority") ?? "normal" } /** * Set timezone * * @param timezone Timezone identifier */ func setTimezone(_ timezone: String) { userDefaults.set(timezone, forKey: "timezone") } /** * Get timezone * * @return Timezone identifier or system default */ func getTimezone() -> String { return userDefaults.string(forKey: "timezone") ?? TimeZone.current.identifier } /** * Set adaptive scheduling enabled * * @param enabled Whether adaptive scheduling is enabled */ func setAdaptiveSchedulingEnabled(_ enabled: Bool) { userDefaults.set(enabled, forKey: Self.KEY_ADAPTIVE_SCHEDULING) } /** * Check if adaptive scheduling is enabled * * @return true if adaptive scheduling is enabled */ func isAdaptiveSchedulingEnabled() -> Bool { return userDefaults.bool(forKey: Self.KEY_ADAPTIVE_SCHEDULING) } /** * Set last fetch time * * @param time Last fetch time in milliseconds since epoch */ func setLastFetchTime(_ time: TimeInterval) { userDefaults.set(time, forKey: Self.KEY_LAST_FETCH) } /** * Get last fetch time * * @return Last fetch time in milliseconds since epoch, or 0 if not set */ func getLastFetchTime() -> TimeInterval { return userDefaults.double(forKey: Self.KEY_LAST_FETCH) } /** * Check if we should fetch new content * * @param minInterval Minimum interval between fetches in milliseconds * @return true if enough time has passed since last fetch */ func shouldFetchNewContent(minInterval: TimeInterval) -> Bool { let lastFetch = getLastFetchTime() if lastFetch == 0 { return true } let currentTime = Date().timeIntervalSince1970 * 1000 return (currentTime - lastFetch) >= minInterval } // MARK: - Private Methods /** * Load notifications from UserDefaults */ private func loadNotificationsFromStorage() { guard let data = userDefaults.data(forKey: Self.KEY_NOTIFICATIONS), let jsonArray = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] else { return } notificationList = jsonArray.compactMap { NotificationContent.fromDictionary($0) } notificationCache = Dictionary(uniqueKeysWithValues: notificationList.map { ($0.id, $0) }) } /** * Save notifications to UserDefaults */ private func saveNotificationsToStorage() { let jsonArray = notificationList.map { $0.toDictionary() } if let data = try? JSONSerialization.data(withJSONObject: jsonArray) { userDefaults.set(data, forKey: Self.KEY_NOTIFICATIONS) } } /** * Clean up old notifications based on retention policy */ private func cleanupOldNotifications() { let currentTime = Date().timeIntervalSince1970 * 1000 let cutoffTime = currentTime - Self.RETENTION_PERIOD_MS notificationList.removeAll { notification in let age = currentTime - notification.scheduledTime return age > Self.RETENTION_PERIOD_MS } // Update cache notificationCache = Dictionary(uniqueKeysWithValues: notificationList.map { ($0.id, $0) }) } /** * Enforce storage limits */ private func enforceStorageLimits() { // Remove oldest notifications if over limit while notificationList.count > Self.MAX_STORAGE_ENTRIES { let oldest = notificationList.removeFirst() notificationCache.removeValue(forKey: oldest.id) } } /** * Deduplicate notifications * * @return Array of removed notification IDs */ private func deduplicateNotifications() -> [String] { var seen = Set() var removed: [String] = [] notificationList = notificationList.filter { notification in if seen.contains(notification.id) { removed.append(notification.id) return false } seen.insert(notification.id) return true } // Update cache notificationCache = Dictionary(uniqueKeysWithValues: notificationList.map { ($0.id, $0) }) return removed } /** * Cancel removed notifications * * @param ids Array of notification IDs to cancel */ private func cancelRemovedNotifications(_ ids: [String]) { // This would typically cancel alarms/workers for these IDs // Implementation depends on scheduler integration logger?.log(.debug, "DN|STORAGE_DEDUP removed=\(ids.count)") } }