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.
412 lines
12 KiB
412 lines
12 KiB
/**
|
|
* 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<String>()
|
|
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)")
|
|
}
|
|
}
|
|
|
|
|