Просмотр исходного кода

Refactor alert storage to no longer use JSON file storage but UserDefaults

Deniz Cengiz 5 месяцев назад
Родитель
Сommit
65a14678e7
1 измененных файлов с 177 добавлено и 30 удалено
  1. 177 30
      Trio/Sources/APS/Storage/AlertStorage.swift

+ 177 - 30
Trio/Sources/APS/Storage/AlertStorage.swift

@@ -19,27 +19,60 @@ protocol AlertHistoryStorage {
 
 final class BaseAlertHistoryStorage: AlertHistoryStorage, Injectable {
     private let processQueue = DispatchQueue.markedQueue(label: "BaseAlertsStorage.processQueue")
-    @Injected() private var storage: FileStorage!
+
+    private let defaults: UserDefaults
+
+    /// Legacy JSON file storage used only for one-time migration from the historical on-disk JSON file.
+    // FIXME: this can be removed in later releases
+    @Injected() private var fileStorage: FileStorage!
+
     @Injected() private var broadcaster: Broadcaster!
 
+    /// Emits `true` whenever there is at least one unacknowledged alert in the last 24 hours.
     let alertNotAck = PassthroughSubject<Bool, Never>()
 
-    init(resolver: Resolver) {
+    private enum Keys {
+        /// UserDefaults key holding the encoded `[AlertEntry]` payload.
+        static let alertsData = "openaps.monitor.alertHistory.data"
+        /// UserDefaults key used as a one-time migration flag.
+        static let alertsMigrationDone = "openaps.monitor.alertHistory.migrated"
+    }
+
+    /// Creates a new alert history storage.
+    ///
+    /// On initialization this performs a one-time migration from the legacy JSON file
+    /// (`OpenAPS.Monitor.alertHistory`, e.g. `"monitor/alerthistory.json"`) into UserDefaults.
+    /// After initialization, all reads/writes happen via UserDefaults only.
+    ///
+    /// - Parameters:
+    ///   - resolver: Swinject resolver used for dependency injection.
+    ///   - userDefaults: The UserDefaults instance used for persistence. Defaults to `.standard`.
+    init(resolver: Resolver, userDefaults: UserDefaults = .standard) {
+        defaults = userDefaults
         injectServices(resolver)
+
+        migrateFromLegacyJSONIfNeeded() // FIXME: this can be removed in later releases
+
         alertNotAck.send(recentNotAck().isNotEmpty)
     }
 
+    /// Stores a new alert entry and notifies observers.
+    ///
+    /// The history is:
+    /// - de-duplicated by `issuedDate`
+    /// - pruned to the last 24 hours
+    /// - sorted with newest first
+    ///
+    /// After persisting, this updates `alertNotAck` and broadcasts the latest list to `AlertObserver`s.
+    /// - Parameter alert: The alert to store.
     func storeAlert(_ alert: AlertEntry) {
         processQueue.sync {
-            let file = OpenAPS.Monitor.alertHistory
-            var uniqEvents: [AlertEntry] = []
-            self.storage.transaction { storage in
-                storage.append(alert, to: file, uniqBy: \.issuedDate)
-                uniqEvents = storage.retrieve(file, as: [AlertEntry].self)?
-                    .filter { $0.issuedDate.addingTimeInterval(1.days.timeInterval) > Date() }
-                    .sorted { $0.issuedDate > $1.issuedDate } ?? [alert]
-                storage.save(Array(uniqEvents), as: file)
-            }
+            var all = loadAll()
+            all.append(alert)
+
+            let uniqEvents = pruneAndSort(dedupeByIssuedDate(all))
+            saveAll(uniqEvents)
+
             alertNotAck.send(self.recentNotAck().isNotEmpty)
             broadcaster.notify(AlertObserver.self, on: processQueue) {
                 $0.AlertDidUpdate(uniqEvents)
@@ -47,57 +80,171 @@ final class BaseAlertHistoryStorage: AlertHistoryStorage, Injectable {
         }
     }
 
+    /// Returns the baseline sync date used by the alert subsystem.
+    ///
+    /// This matches the previous behavior: one day ago from "now".
     func syncDate() -> Date {
         Date().addingTimeInterval(-1.days.timeInterval)
     }
 
+    /// Returns all unacknowledged alerts from the last 24 hours, sorted newest first.
     func recentNotAck() -> [AlertEntry] {
-        storage.retrieve(OpenAPS.Monitor.alertHistory, as: [AlertEntry].self)?
+        loadAll()
             .filter { $0.issuedDate.addingTimeInterval(1.days.timeInterval) > Date() && $0.acknowledgedDate == nil }
-            .sorted { $0.issuedDate > $1.issuedDate } ?? []
+            .sorted { $0.issuedDate > $1.issuedDate }
     }
 
+    /// Acknowledges an alert (by issued date), or stores an error for it.
+    ///
+    /// If `error` is non-nil, the alert is updated with `errorMessage`.
+    /// Otherwise, the alert is marked as acknowledged by setting `acknowledgedDate = Date()`.
+    ///
+    /// After persisting, this updates `alertNotAck`.
+    /// - Parameters:
+    ///   - alert: The issued date of the alert entry to update.
+    ///   - error: Optional error message to store instead of acknowledging.
     func ackAlert(_ alert: Date, _ error: String?) {
         processQueue.sync {
-            var allValues = storage.retrieve(OpenAPS.Monitor.alertHistory, as: [AlertEntry].self) ?? []
-            guard let entryIndex = allValues.firstIndex(where: { $0.issuedDate == alert }) else {
-                return
-            }
+            var all = loadAll()
+            guard let idx = all.firstIndex(where: { $0.issuedDate == alert }) else { return }
 
             if let error {
-                allValues[entryIndex].errorMessage = error
+                all[idx].errorMessage = error
             } else {
-                allValues[entryIndex].acknowledgedDate = Date()
+                all[idx].acknowledgedDate = Date()
             }
-            storage.save(allValues, as: OpenAPS.Monitor.alertHistory)
+
+            let cleaned = pruneAndSort(dedupeByIssuedDate(all))
+            saveAll(cleaned)
             alertNotAck.send(self.recentNotAck().isNotEmpty)
         }
     }
 
+    /// Deletes an alert entry by its identifier and notifies observers.
+    ///
+    /// After persisting, this updates `alertNotAck` and broadcasts the updated list.
+    /// - Parameter identifier: The `alertIdentifier` of the entry to delete.
     func deleteAlert(identifier: String) {
         processQueue.sync {
-            var allValues = storage.retrieve(OpenAPS.Monitor.alertHistory, as: [AlertEntry].self) ?? []
-            guard let entryIndex = allValues.firstIndex(where: { $0.alertIdentifier == identifier }) else {
-                return
-            }
-            allValues.remove(at: entryIndex)
-            storage.save(allValues, as: OpenAPS.Monitor.alertHistory)
+            var all = loadAll()
+            guard let idx = all.firstIndex(where: { $0.alertIdentifier == identifier }) else { return }
+
+            all.remove(at: idx)
+
+            let cleaned = pruneAndSort(dedupeByIssuedDate(all))
+            saveAll(cleaned)
+
             alertNotAck.send(self.recentNotAck().isNotEmpty)
             broadcaster.notify(AlertObserver.self, on: processQueue) {
-                $0.AlertDidUpdate(allValues)
+                $0.AlertDidUpdate(cleaned)
             }
         }
     }
 
+    /// Forces a broadcast of the current alert list (last 24 hours) to observers.
+    ///
+    /// This does not modify the data; it only re-emits state via `alertNotAck` and `AlertObserver`.
     func forceNotification() {
         processQueue.sync {
-            let uniqEvents = storage.retrieve(OpenAPS.Monitor.alertHistory, as: [AlertEntry].self)?
-                .filter { $0.issuedDate.addingTimeInterval(1.days.timeInterval) > Date() }
-                .sorted { $0.issuedDate > $1.issuedDate } ?? []
+            let uniqEvents = pruneAndSort(loadAll())
             alertNotAck.send(self.recentNotAck().isNotEmpty)
             broadcaster.notify(AlertObserver.self, on: processQueue) {
                 $0.AlertDidUpdate(uniqEvents)
             }
         }
     }
+
+    // MARK: - Migration
+
+    /// Migrates alert history from the legacy on-disk JSON file into UserDefaults.
+    ///
+    /// Migration behavior:
+    /// - Runs at most once per install (guarded by `Keys.alertsMigrationDone`).
+    /// - If the new UserDefaults value already exists, migration is considered complete.
+    /// - If legacy alerts exist, they are normalized (dedupe/prune/sort) and stored in UserDefaults.
+    /// - After a successful migration, the legacy file is removed to avoid future drift.
+    private func migrateFromLegacyJSONIfNeeded() { // FIXME: this can be removed in later releases
+        processQueue.sync {
+            // Avoid repeated disk reads forever
+            if defaults.bool(forKey: Keys.alertsMigrationDone) { return }
+
+            // If new store already has data, consider migration done
+            if defaults.data(forKey: Keys.alertsData) != nil {
+                defaults.set(true, forKey: Keys.alertsMigrationDone)
+                return
+            }
+
+            // Read legacy file ("monitor/alerthistory.json") via existing FileStorage
+            let legacyJsonAlerts = fileStorage.retrieve(OpenAPS.Monitor.alertHistory, as: [AlertEntry].self) ?? []
+            guard legacyJsonAlerts.isNotEmpty else {
+                defaults.set(true, forKey: Keys.alertsMigrationDone)
+                return
+            }
+
+            // Normalize before persisting
+            let migrated = pruneAndSort(dedupeByIssuedDate(legacyJsonAlerts))
+            saveAll(migrated)
+
+            // Mark complete FIRST, then cleanup
+            defaults.set(true, forKey: Keys.alertsMigrationDone)
+
+            // Cleanup: remove legacy json so it cannot drift / get re-used accidentally
+            fileStorage.remove(OpenAPS.Monitor.alertHistory)
+        }
+    }
+
+    // MARK: - UserDefaults persistence
+
+    // Uses the same encoder/decoder as file storage to keep Date encoding consistent.
+
+    /// Loads all persisted alerts from UserDefaults.
+    ///
+    /// Decoding uses `JSONCoding.decoder` to match the previous on-disk JSON encoding/decoding behavior.
+    /// If decoding fails, the stored payload is removed so the app can recover cleanly.
+    private func loadAll() -> [AlertEntry] {
+        guard let data = defaults.data(forKey: Keys.alertsData) else { return [] }
+        do {
+            return try JSONCoding.decoder.decode([AlertEntry].self, from: data)
+        } catch {
+            debug(.storage, "Failed to decode alerts from UserDefaults: \(error)")
+            // Clear corrupt payload so app can recover
+            defaults.removeObject(forKey: Keys.alertsData)
+            return []
+        }
+    }
+
+    /// Persists all alerts to UserDefaults.
+    ///
+    /// Encoding uses `JSONCoding.encoder` to match the previous on-disk JSON encoding behavior.
+    private func saveAll(_ alerts: [AlertEntry]) {
+        do {
+            let data = try JSONCoding.encoder.encode(alerts)
+            defaults.set(data, forKey: Keys.alertsData)
+        } catch {
+            debug(.storage, "Failed to encode alerts to UserDefaults: \(error)")
+        }
+    }
+
+    // MARK: - Helpers
+
+    /// Filters the provided alerts to the last 24 hours and sorts them with newest first.
+    private func pruneAndSort(_ alerts: [AlertEntry]) -> [AlertEntry] {
+        alerts
+            .filter { $0.issuedDate.addingTimeInterval(1.days.timeInterval) > Date() }
+            .sorted { $0.issuedDate > $1.issuedDate }
+    }
+
+    /// De-duplicates alert entries by `issuedDate` (keeping the newest occurrence when duplicates exist).
+    ///
+    /// This matches `AlertEntry`'s `Equatable`/`Hashable` semantics (both based on `issuedDate`).
+    private func dedupeByIssuedDate(_ alerts: [AlertEntry]) -> [AlertEntry] {
+        var seen = Set<Date>()
+        var result: [AlertEntry] = []
+        for item in alerts.sorted(by: { $0.issuedDate > $1.issuedDate }) {
+            if seen.insert(item.issuedDate).inserted {
+                result.append(item)
+            }
+        }
+        return result
+    }
 }