Harden watch sync and App Group storage

This commit is contained in:
FuelBoard Contributor
2026-09-26 20:03:29 +01:00
parent 004fe2f787
commit c3a975eb10
5 changed files with 95 additions and 16 deletions
+16 -2
View File
@@ -11,6 +11,7 @@ final class WatchSyncManager: NSObject, WCSessionDelegate {
static let shared = WatchSyncManager() static let shared = WatchSyncManager()
private var pendingSnapshotPush = false private var pendingSnapshotPush = false
private var activationRequested = false
private override init() { private override init() {
super.init() super.init()
@@ -18,19 +19,28 @@ final class WatchSyncManager: NSObject, WCSessionDelegate {
func activate() { func activate() {
guard WCSession.isSupported() else { return } guard WCSession.isSupported() else { return }
pendingSnapshotPush = true
let session = WCSession.default let session = WCSession.default
guard session.isWatchAppInstalled else { return }
pendingSnapshotPush = true
if session.delegate !== self { if session.delegate !== self {
session.delegate = self session.delegate = self
} }
guard session.activationState != .activated else { return }
guard !activationRequested else { return }
activationRequested = true
session.activate() session.activate()
} }
func pushSnapshot() { func pushSnapshot() {
guard WCSession.isSupported() else { return } guard WCSession.isSupported() else { return }
let session = WCSession.default let session = WCSession.default
guard session.isWatchAppInstalled else {
pendingSnapshotPush = false
return
}
guard session.activationState == .activated else { guard session.activationState == .activated else {
pendingSnapshotPush = true pendingSnapshotPush = true
activate()
return return
} }
do { do {
@@ -73,6 +83,9 @@ final class WatchSyncManager: NSObject, WCSessionDelegate {
} }
nonisolated func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: (any Error)?) { nonisolated func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: (any Error)?) {
Task { @MainActor in
self.activationRequested = false
}
if let error { if let error {
print("WATCH-SYNC activation failed: \(error.localizedDescription)") print("WATCH-SYNC activation failed: \(error.localizedDescription)")
return return
@@ -88,7 +101,8 @@ final class WatchSyncManager: NSObject, WCSessionDelegate {
nonisolated func sessionDidDeactivate(_ session: WCSession) { nonisolated func sessionDidDeactivate(_ session: WCSession) {
Task { @MainActor in Task { @MainActor in
WCSession.default.activate() self.activationRequested = false
self.activate()
} }
} }
+12 -1
View File
@@ -157,6 +157,7 @@ private enum WatchFuelCache {
static let appGroupSuite = "group.com.apt.fuelboard" static let appGroupSuite = "group.com.apt.fuelboard"
static let favouritesKey = "fuelboard.favourites" static let favouritesKey = "fuelboard.favourites"
static let stationsKey = "fuelboard.stations" static let stationsKey = "fuelboard.stations"
static let stationsFileName = "fuelboard.stations.json"
static let fuelKey = "fuelboard.selectedFuel" static let fuelKey = "fuelboard.selectedFuel"
static let distanceUnitKey = "fuelboard.distanceUnit" static let distanceUnitKey = "fuelboard.distanceUnit"
static let priceStyleKey = "fuelboard.priceDisplayStyle" static let priceStyleKey = "fuelboard.priceDisplayStyle"
@@ -228,7 +229,17 @@ private enum WatchFuelCache {
} }
static func loadStations() -> [WatchStation] { 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 { let stations = try? JSONDecoder().decode([WatchStation].self, from: data) else {
return [] return []
} }
@@ -10,6 +10,7 @@ final class WatchSyncManager: NSObject, WCSessionDelegate {
private let suiteName = "group.com.apt.fuelboard" private let suiteName = "group.com.apt.fuelboard"
private let snapshotRequestKey = "fuelboard.snapshotRequest" private let snapshotRequestKey = "fuelboard.snapshotRequest"
private var activationRequested = false
private override init() { private override init() {
super.init() super.init()
@@ -21,14 +22,21 @@ final class WatchSyncManager: NSObject, WCSessionDelegate {
if session.delegate !== self { if session.delegate !== self {
session.delegate = self session.delegate = self
} }
guard session.activationState != .activated else { return }
guard !activationRequested else { return }
activationRequested = true
session.activate() session.activate()
} }
func requestRefresh(at date: Date = Date()) { func requestRefresh(at date: Date = Date()) {
guard WCSession.isSupported() else { return } guard WCSession.isSupported() else { return }
let session = WCSession.default
guard session.activationState == .activated else {
activate()
return
}
let raw = String(date.timeIntervalSince1970) let raw = String(date.timeIntervalSince1970)
let payload = ["fuelboard.watchRefreshRequest": raw] let payload = ["fuelboard.watchRefreshRequest": raw]
let session = WCSession.default
if session.isReachable { if session.isReachable {
session.sendMessage(payload, replyHandler: nil, errorHandler: nil) session.sendMessage(payload, replyHandler: nil, errorHandler: nil)
@@ -39,8 +47,12 @@ final class WatchSyncManager: NSObject, WCSessionDelegate {
func requestSnapshot() { func requestSnapshot() {
guard WCSession.isSupported() else { return } guard WCSession.isSupported() else { return }
let payload = [snapshotRequestKey: String(Date().timeIntervalSince1970)]
let session = WCSession.default let session = WCSession.default
guard session.activationState == .activated else {
activate()
return
}
let payload = [snapshotRequestKey: String(Date().timeIntervalSince1970)]
if session.isReachable { if session.isReachable {
session.sendMessage(payload, replyHandler: nil, errorHandler: nil) 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)?) { func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: (any Error)?) {
activationRequested = false
if let error { if let error {
print("WATCH-SYNC activation failed: \(error.localizedDescription)") print("WATCH-SYNC activation failed: \(error.localizedDescription)")
return return
+39 -7
View File
@@ -419,13 +419,37 @@ struct FuelStore {
static let watchRefreshHandledKey = "fuelboard.watchRefreshHandled" // TimeInterval (seconds since 1970) static let watchRefreshHandledKey = "fuelboard.watchRefreshHandled" // TimeInterval (seconds since 1970)
static let relaySourceKey = "fuelboard.relaySource" // String — "api" | "csv" static let relaySourceKey = "fuelboard.relaySource" // String — "api" | "csv"
static let stationCountKey = "fuelboard.stationCount" // String — station count 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 // MARK: Stations
// The full-UK dataset (~2.9 MB) lives in app-group UserDefaults only — // The full-UK dataset is stored as an App Group file. UserDefaults is
// keychain is for small values and cannot hold it. Read order is // reserved for small preferences and has a roughly 4 MB total limit.
// defaults-first for stations (keychain may hold a legacy small set from
// older builds; the full country dump always wins).
/// Stations from `stations` tied with `price` for `fuel` (within 0.01p), /// Stations from `stations` tied with `price` for `fuel` (within 0.01p),
/// sorted nearest-first from `lat`/`lng`. Used to offer multiple /// sorted nearest-first from `lat`/`lng`. Used to offer multiple
@@ -444,10 +468,19 @@ struct FuelStore {
} }
static func loadStations() -> [FuelStation] { 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), if let defaults = UserDefaults(suiteName: appGroupSuite),
let data = defaults.data(forKey: stationsKey), let data = defaults.data(forKey: stationsKey),
let stations = try? JSONDecoder().decode([FuelStation].self, from: data), let stations = try? JSONDecoder().decode([FuelStation].self, from: data),
!stations.isEmpty { !stations.isEmpty {
if saveAppGroupFile(data, name: stationsFileName) {
defaults.removeObject(forKey: stationsKey)
}
return stations.map(sanitized) return stations.map(sanitized)
} }
if let data = keychainData(service: stationsKey), if let data = keychainData(service: stationsKey),
@@ -468,8 +501,7 @@ struct FuelStore {
static func saveStations(_ stations: [FuelStation]) { static func saveStations(_ stations: [FuelStation]) {
if let data = try? JSONEncoder().encode(stations) { if let data = try? JSONEncoder().encode(stations) {
UserDefaults(suiteName: appGroupSuite)?.set(data, forKey: stationsKey) _ = saveAppGroupFile(data, name: stationsFileName)
// Intentionally NOT written to keychain — 2.9 MB exceeds its limits.
} }
} }
+13 -4
View File
@@ -37,10 +37,11 @@ struct MirrorFuelProvider: FuelPriceProviding {
/// surfaced via `LiveChainProvider.latestMeta` in Settings → About. /// surfaced via `LiveChainProvider.latestMeta` in Settings → About.
static var latestMeta: RelayMeta? 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 /// 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. /// cached dump instead of re-downloading ~2.8 MB at every 12 h gate.
static let liveDumpCacheKey = "fuelboard.liveDumpCache" static let liveDumpCacheKey = "fuelboard.liveDumpCache"
static let liveDumpCacheFileName = "fuelboard.liveDumpCache.json"
struct DumpCache: Codable { struct DumpCache: Codable {
let day: String let day: String
@@ -48,18 +49,26 @@ struct MirrorFuelProvider: FuelPriceProviding {
} }
static func loadDumpCache() -> DumpCache? { 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), guard let defaults = UserDefaults(suiteName: FuelStore.appGroupSuite),
let raw = defaults.data(forKey: liveDumpCacheKey), let raw = defaults.data(forKey: liveDumpCacheKey),
let cache = try? JSONDecoder().decode(DumpCache.self, from: raw) else { let cache = try? JSONDecoder().decode(DumpCache.self, from: raw) else {
return nil return nil
} }
if FuelStore.saveAppGroupFile(raw, name: liveDumpCacheFileName) {
defaults.removeObject(forKey: liveDumpCacheKey)
}
return cache return cache
} }
static func saveDumpCache(day: String, data: Data) { static func saveDumpCache(day: String, data: Data) {
guard let defaults = UserDefaults(suiteName: FuelStore.appGroupSuite), guard let raw = try? JSONEncoder().encode(DumpCache(day: day, data: data)) else { return }
let raw = try? JSONEncoder().encode(DumpCache(day: day, data: data)) else { return } _ = FuelStore.saveAppGroupFile(raw, name: liveDumpCacheFileName)
defaults.set(raw, forKey: liveDumpCacheKey)
} }
/// True when the cached dump is already the freshest the mirror has — /// True when the cached dump is already the freshest the mirror has —