Harden watch sync and App Group storage
This commit is contained in:
@@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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.
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 —
|
||||||
|
|||||||
Reference in New Issue
Block a user