From c3a975eb100b09054d48819acc3fd50537c93d7e Mon Sep 17 00:00:00 2001 From: FuelBoard Contributor Date: Sat, 26 Sep 2026 20:03:29 +0100 Subject: [PATCH] Harden watch sync and App Group storage --- FuelBoard/WatchSyncManager.swift | 18 +++++++- FuelBoardWatch Watch App/ContentView.swift | 13 +++++- .../WatchSyncManager.swift | 17 ++++++- Shared/FuelStore.swift | 46 ++++++++++++++++--- Shared/MirrorFuelProvider.swift | 17 +++++-- 5 files changed, 95 insertions(+), 16 deletions(-) diff --git a/FuelBoard/WatchSyncManager.swift b/FuelBoard/WatchSyncManager.swift index 99b65bb..9731133 100644 --- a/FuelBoard/WatchSyncManager.swift +++ b/FuelBoard/WatchSyncManager.swift @@ -11,6 +11,7 @@ final class WatchSyncManager: NSObject, WCSessionDelegate { static let shared = WatchSyncManager() private var pendingSnapshotPush = false + private var activationRequested = false private override init() { super.init() @@ -18,19 +19,28 @@ final class WatchSyncManager: NSObject, WCSessionDelegate { func activate() { guard WCSession.isSupported() else { return } - pendingSnapshotPush = true let session = WCSession.default + guard session.isWatchAppInstalled else { return } + pendingSnapshotPush = true if session.delegate !== self { session.delegate = self } + guard session.activationState != .activated else { return } + guard !activationRequested else { return } + activationRequested = true session.activate() } func pushSnapshot() { guard WCSession.isSupported() else { return } let session = WCSession.default + guard session.isWatchAppInstalled else { + pendingSnapshotPush = false + return + } guard session.activationState == .activated else { pendingSnapshotPush = true + activate() return } do { @@ -73,6 +83,9 @@ final class WatchSyncManager: NSObject, WCSessionDelegate { } nonisolated func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: (any Error)?) { + Task { @MainActor in + self.activationRequested = false + } if let error { print("WATCH-SYNC activation failed: \(error.localizedDescription)") return @@ -88,7 +101,8 @@ final class WatchSyncManager: NSObject, WCSessionDelegate { nonisolated func sessionDidDeactivate(_ session: WCSession) { Task { @MainActor in - WCSession.default.activate() + self.activationRequested = false + self.activate() } } diff --git a/FuelBoardWatch Watch App/ContentView.swift b/FuelBoardWatch Watch App/ContentView.swift index ebcf30d..941bece 100644 --- a/FuelBoardWatch Watch App/ContentView.swift +++ b/FuelBoardWatch Watch App/ContentView.swift @@ -157,6 +157,7 @@ private enum WatchFuelCache { static let appGroupSuite = "group.com.apt.fuelboard" static let favouritesKey = "fuelboard.favourites" static let stationsKey = "fuelboard.stations" + static let stationsFileName = "fuelboard.stations.json" static let fuelKey = "fuelboard.selectedFuel" static let distanceUnitKey = "fuelboard.distanceUnit" static let priceStyleKey = "fuelboard.priceDisplayStyle" @@ -228,7 +229,17 @@ private enum WatchFuelCache { } static func loadStations() -> [WatchStation] { - guard let data = defaults()?.data(forKey: stationsKey), + let fileData: Data? + if let container = FileManager.default.containerURL( + forSecurityApplicationGroupIdentifier: appGroupSuite + ) { + let url = container.appendingPathComponent(stationsFileName) + fileData = try? Data(contentsOf: url) + } else { + fileData = nil + } + let data = fileData ?? defaults()?.data(forKey: stationsKey) + guard let data, let stations = try? JSONDecoder().decode([WatchStation].self, from: data) else { return [] } diff --git a/FuelBoardWatch Watch App/WatchSyncManager.swift b/FuelBoardWatch Watch App/WatchSyncManager.swift index ed6b500..41aef08 100644 --- a/FuelBoardWatch Watch App/WatchSyncManager.swift +++ b/FuelBoardWatch Watch App/WatchSyncManager.swift @@ -10,6 +10,7 @@ final class WatchSyncManager: NSObject, WCSessionDelegate { private let suiteName = "group.com.apt.fuelboard" private let snapshotRequestKey = "fuelboard.snapshotRequest" + private var activationRequested = false private override init() { super.init() @@ -21,14 +22,21 @@ final class WatchSyncManager: NSObject, WCSessionDelegate { if session.delegate !== self { session.delegate = self } + guard session.activationState != .activated else { return } + guard !activationRequested else { return } + activationRequested = true session.activate() } func requestRefresh(at date: Date = Date()) { guard WCSession.isSupported() else { return } + let session = WCSession.default + guard session.activationState == .activated else { + activate() + return + } let raw = String(date.timeIntervalSince1970) let payload = ["fuelboard.watchRefreshRequest": raw] - let session = WCSession.default if session.isReachable { session.sendMessage(payload, replyHandler: nil, errorHandler: nil) @@ -39,8 +47,12 @@ final class WatchSyncManager: NSObject, WCSessionDelegate { func requestSnapshot() { guard WCSession.isSupported() else { return } - let payload = [snapshotRequestKey: String(Date().timeIntervalSince1970)] let session = WCSession.default + guard session.activationState == .activated else { + activate() + return + } + let payload = [snapshotRequestKey: String(Date().timeIntervalSince1970)] if session.isReachable { session.sendMessage(payload, replyHandler: nil, errorHandler: nil) @@ -82,6 +94,7 @@ final class WatchSyncManager: NSObject, WCSessionDelegate { } func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: (any Error)?) { + activationRequested = false if let error { print("WATCH-SYNC activation failed: \(error.localizedDescription)") return diff --git a/Shared/FuelStore.swift b/Shared/FuelStore.swift index 76196d8..4c43d8f 100644 --- a/Shared/FuelStore.swift +++ b/Shared/FuelStore.swift @@ -419,13 +419,37 @@ struct FuelStore { static let watchRefreshHandledKey = "fuelboard.watchRefreshHandled" // TimeInterval (seconds since 1970) static let relaySourceKey = "fuelboard.relaySource" // String — "api" | "csv" static let stationCountKey = "fuelboard.stationCount" // String — station count - static let dataUpdatedKey = "fuelboard.dataUpdated" // String — govUK dataset update time + static let dataUpdatedKey = "fuelboard.dataUpdated" // govUK dataset update time + + // Large shared payloads belong in the App Group container, not + // UserDefaults/CFPreferences (which has a roughly 4 MB limit). + static let stationsFileName = "fuelboard.stations.json" + + static func appGroupFileURL(_ name: String) -> URL? { + FileManager.default + .containerURL(forSecurityApplicationGroupIdentifier: appGroupSuite)? + .appendingPathComponent(name, isDirectory: false) + } + + static func loadAppGroupFile(_ name: String) -> Data? { + guard let url = appGroupFileURL(name) else { return nil } + return try? Data(contentsOf: url) + } + + @discardableResult + static func saveAppGroupFile(_ data: Data, name: String) -> Bool { + guard let url = appGroupFileURL(name) else { return false } + do { + try data.write(to: url, options: .atomic) + return true + } catch { + return false + } + } // MARK: Stations - // The full-UK dataset (~2.9 MB) lives in app-group UserDefaults only — - // keychain is for small values and cannot hold it. Read order is - // defaults-first for stations (keychain may hold a legacy small set from - // older builds; the full country dump always wins). + // The full-UK dataset is stored as an App Group file. UserDefaults is + // reserved for small preferences and has a roughly 4 MB total limit. /// Stations from `stations` tied with `price` for `fuel` (within 0.01p), /// sorted nearest-first from `lat`/`lng`. Used to offer multiple @@ -444,10 +468,19 @@ struct FuelStore { } static func loadStations() -> [FuelStation] { + if let data = loadAppGroupFile(stationsFileName), + let stations = try? JSONDecoder().decode([FuelStation].self, from: data), + !stations.isEmpty { + return stations.map(sanitized) + } + // Migrate the legacy App Group UserDefaults value once. if let defaults = UserDefaults(suiteName: appGroupSuite), let data = defaults.data(forKey: stationsKey), let stations = try? JSONDecoder().decode([FuelStation].self, from: data), !stations.isEmpty { + if saveAppGroupFile(data, name: stationsFileName) { + defaults.removeObject(forKey: stationsKey) + } return stations.map(sanitized) } if let data = keychainData(service: stationsKey), @@ -468,8 +501,7 @@ struct FuelStore { static func saveStations(_ stations: [FuelStation]) { if let data = try? JSONEncoder().encode(stations) { - UserDefaults(suiteName: appGroupSuite)?.set(data, forKey: stationsKey) - // Intentionally NOT written to keychain — 2.9 MB exceeds its limits. + _ = saveAppGroupFile(data, name: stationsFileName) } } diff --git a/Shared/MirrorFuelProvider.swift b/Shared/MirrorFuelProvider.swift index 32184b2..80b6dc5 100644 --- a/Shared/MirrorFuelProvider.swift +++ b/Shared/MirrorFuelProvider.swift @@ -37,10 +37,11 @@ struct MirrorFuelProvider: FuelPriceProviding { /// surfaced via `LiveChainProvider.latestMeta` in Settings → About. static var latestMeta: RelayMeta? - /// App-group cache of the last fetched FULL dump, keyed by snapshot day. + /// App-group file cache of the last fetched FULL dump, keyed by snapshot day. /// The mirror pushes once/day, so between pushes the app reuses the /// cached dump instead of re-downloading ~2.8 MB at every 12 h gate. static let liveDumpCacheKey = "fuelboard.liveDumpCache" + static let liveDumpCacheFileName = "fuelboard.liveDumpCache.json" struct DumpCache: Codable { let day: String @@ -48,18 +49,26 @@ struct MirrorFuelProvider: FuelPriceProviding { } static func loadDumpCache() -> DumpCache? { + if let raw = FuelStore.loadAppGroupFile(liveDumpCacheFileName), + let cache = try? JSONDecoder().decode(DumpCache.self, from: raw) { + return cache + } + + // Migrate the legacy UserDefaults cache when present. guard let defaults = UserDefaults(suiteName: FuelStore.appGroupSuite), let raw = defaults.data(forKey: liveDumpCacheKey), let cache = try? JSONDecoder().decode(DumpCache.self, from: raw) else { return nil } + if FuelStore.saveAppGroupFile(raw, name: liveDumpCacheFileName) { + defaults.removeObject(forKey: liveDumpCacheKey) + } return cache } static func saveDumpCache(day: String, data: Data) { - guard let defaults = UserDefaults(suiteName: FuelStore.appGroupSuite), - let raw = try? JSONEncoder().encode(DumpCache(day: day, data: data)) else { return } - defaults.set(raw, forKey: liveDumpCacheKey) + guard let raw = try? JSONEncoder().encode(DumpCache(day: day, data: data)) else { return } + _ = FuelStore.saveAppGroupFile(raw, name: liveDumpCacheFileName) } /// True when the cached dump is already the freshest the mirror has —