// MirrorFuelProvider.swift — GitHub price-mirror provider (P0 live chain). // // The mirror (aptonline/fuelboard-data, pushed daily by the ingestion relay) // is the PRODUCTION data source: its day files use the same /api/v1/stations // envelope, so the shared decoder works verbatim. Fetching from // raw.githubusercontent.com makes live prices work fully off-LAN — App Review // cannot reach a private LAN relay, and consumers never have one to reach — // with the bundled dump (app target) as the no-network last resort. // // Chain (LiveChainProvider) — public HTTPS only: // GitHub mirror → (app target: bundled dump in the ContentView catch, // widget: cache → placeholder) // No local-network fallback or private IP is compiled into the app binary. import Foundation // MARK: - Mirror provider (GitHub raw) /// Fetches CURRENT prices from the GitHub mirror. Same envelope as the /// relay, so `FuelPriceProvider.decodeStations` applies unchanged. struct MirrorFuelProvider: FuelPriceProviding { var baseURL = FuelHistoryStore.mirrorBase /// Envelope metadata from the most recent successful mirror fetch — /// surfaced via `LiveChainProvider.latestMeta` in Settings → About. static var latestMeta: RelayMeta? /// 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 let data: Data } 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 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 — /// avoids the ~2.8 MB re-download when the mirror hasn't pushed a new day. static func canReuseCache(cachedDay: String?, latestDay: String?) -> Bool { guard let cachedDay, let latestDay else { return false } return cachedDay == latestDay } /// Focused-path projection, replacing what the relay's radius endpoint /// used to do server-side: stations WITH the fuel, within radius of the /// point, nearest first, bounded. The alert path re-checks radius and /// fuel itself afterwards, so this only narrows the payload. static func focused(_ stations: [FuelStation], near lat: Double?, lng: Double?, fuel: FuelType, radiusKM: Double?, limit: Int = 500) -> [FuelStation] { guard let lat, let lng, let radiusKM else { return stations } return Array( stations .filter { $0.prices[fuel] != nil && $0.distanceKM(to: lat, lng2: lng) <= radiusKM } .sorted { $0.distanceKM(to: lat, lng2: lng) < $1.distanceKM(to: lat, lng2: lng) } .prefix(limit) ) } func fetchStations(near lat: Double?, lng: Double?, fuel: FuelType, radiusKM: Double?) async throws -> [FuelStation] { // 1. Tiny pointer fetch — the freshest snapshot day. guard let latest = await FuelHistoryStore.fetchLatest(base: baseURL), let day = latest.availableTo ?? latest.date else { throw FuelProviderError.mirrorUnavailable } // 2. Reuse the cached full dump when the mirror hasn't pushed a new day. let cache = Self.loadDumpCache() let data: Data if Self.canReuseCache(cachedDay: cache?.day, latestDay: day), let cached = cache?.data { data = cached } else { // Bypass the HTTP cache + short timeout: an offline refresh must // FAIL (→ offline banner), never re-serve a stale cached dump as // a "successful" live fetch. var req = URLRequest(url: FuelHistoryStore.historyFileURL(day: day, base: baseURL)) req.cachePolicy = .reloadIgnoringLocalCacheData req.timeoutInterval = 10 let (fetched, response) = try await URLSession.shared.data(for: req) guard (response as? HTTPURLResponse)?.statusCode == 200 else { throw FuelProviderError.mirrorUnavailable } Self.saveDumpCache(day: day, data: fetched) data = fetched } let stations = try FuelPriceProvider.decodeStations(from: data) Self.latestMeta = FuelPriceProvider.decodeRelayMeta(from: data) return Self.focused(stations, near: lat, lng: lng, fuel: fuel, radiusKM: radiusKM) } } // MARK: - Live chain provider /// The live price chain — public GitHub mirror only. No private LAN fallback is /// compiled into the app binary, so App Store/TestFlight builds cannot trigger /// Local Network permission or leak a private IP. struct LiveChainProvider: FuelPriceProviding { var mirror: FuelPriceProviding /// Metadata surfaced in Settings → About. static var latestMeta: RelayMeta? static var lastSource: String? init(mirror: FuelPriceProviding = MirrorFuelProvider()) { self.mirror = mirror } func fetchStations(near lat: Double?, lng: Double?, fuel: FuelType, radiusKM: Double?) async throws -> [FuelStation] { let stations = try await mirror.fetchStations(near: lat, lng: lng, fuel: fuel, radiusKM: radiusKM) Self.latestMeta = MirrorFuelProvider.latestMeta Self.lastSource = "github" return stations } }