Files
fuelboard/Shared/MirrorFuelProvider.swift
FuelBoard Contributor cfd435d12a banner: top-floating; fix offline-trigger cache; onboarding prompt timing
- Banner floats at the very top (over the nav/title area), never pushing the
  content below and staying clear of the list/pill.
- Offline banner now fires on refresh in airplane mode / no data: the mirror
  live chain bypassed the HTTP cache (+10s timeout) so an offline fetch really
  fails instead of silently re-serving a cached pointer+dump as a 'success'
  (which kept dataStatus .live and hid the banner).
- Onboarding permissions fire at the Continue/Allow tap BEFORE advancing, so
  the system prompt appears after the page is read and never covers the next
  page's animation (grant auto-advances).
2026-08-19 10:17:24 +01:00

197 lines
9.2 KiB
Swift

// MirrorFuelProvider.swift — GitHub price-mirror provider (P0 live chain).
//
// The mirror (aptonline/fuelboard-data, pushed daily by the LAN relay) is the
// PRODUCTION data source: its day files are the raw relay /api/v1/stations
// envelope, so the shared decode works verbatim. Fetching from
// raw.githubusercontent.com makes live prices work fully off-LAN — App
// Review cannot reach a LAN relay, and consumers never have one to reach —
// and the bundled dump (app target) is the no-network last resort.
//
// Chain (LiveChainProvider) — GitHub first, EVERYWHERE:
// GitHub mirror → [dev-only LAN relay] → (app target: bundled dump in the
// ContentView catch, widget: cache → placeholder)
// The relay is NOT part of the consumer chain at all: it is unreachable from
// any phone outside the developer's LAN, and even attempting it from a
// consumer phone would fire the iOS Local Network permission prompt for an
// address that can never be reached. It remains reachable only behind the
// hidden dev flag `fuelboard.relayFallback` (Settings → Debug) for home
// testing. The relay keeps its server-side job (GOV.UK ingest + daily mirror
// push) unchanged.
//
// Telemetry (FuelBeacon): raw GitHub fetches are invisible to repo analytics
// (page views/clones only), so every chain fetch opportunistically pings the
// relay's existing widget-diag route (zero relay changes) — but ONLY when the
// same dev flag is on, so consumers never make a local-network attempt.
// /stats app-hit spikes (dev flag on) = GitHub path failing → fallback active.
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 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"
struct DumpCache: Codable {
let day: String
let data: Data
}
static func loadDumpCache() -> DumpCache? {
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
}
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)
}
/// 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 — GitHub first, everywhere. The LAN relay is NOT part
/// of the consumer chain: consumers can never reach it (and must never even
/// attempt it — that would prompt for Local Network access on a stranger's
/// phone). It only joins behind the hidden dev flag `fuelboard.relayFallback`
/// (Settings → Debug) for the developer's own home testing. Whichever leg
/// serves is recorded for the About section + telemetry.
struct LiveChainProvider: FuelPriceProviding {
var mirror: FuelPriceProviding
var relay: FuelPriceProviding
/// Which leg served the last fetch — Settings → About metadata + beacon.
static var latestMeta: RelayMeta?
static var lastSource: String?
init(mirror: FuelPriceProviding = MirrorFuelProvider(),
relay: FuelPriceProviding = RelayFuelProvider()) {
self.mirror = mirror
self.relay = relay
}
func fetchStations(near lat: Double?, lng: Double?, fuel: FuelType, radiusKM: Double?) async throws -> [FuelStation] {
do {
let stations = try await mirror.fetchStations(near: lat, lng: lng, fuel: fuel, radiusKM: radiusKM)
Self.latestMeta = MirrorFuelProvider.latestMeta
Self.lastSource = "github"
FuelBeacon.fire(source: "github", n: stations.count)
return stations
} catch {
// Dev-only fallback: consumers never reach this branch — the flag
// is off by default and the flag itself lives in the Debug section.
guard FuelStore.loadRelayFallbackEnabled() else { throw error }
let stations = try await relay.fetchStations(near: lat, lng: lng, fuel: fuel, radiusKM: radiusKM)
Self.latestMeta = RelayFuelProvider.latestMeta
Self.lastSource = "relay"
FuelBeacon.fire(source: "relay", n: stations.count)
return stations
}
}
}
// MARK: - Telemetry beacon
/// Fire-and-forget attribution for the LIVE chain (see header note). Never
/// awaited, never user-visible; a failed ping is silent. Fires ONLY when the
/// dev relay flag is on — consumers must never make a local-network attempt,
/// and an off-LAN ping failing silently is itself the reachability datum.
/// Disabled in unit tests via `isEnabled`.
enum FuelBeacon {
static var isEnabled = true
static var shouldFire: Bool {
isEnabled && FuelStore.loadRelayFallbackEnabled()
}
static func fire(source: String, n: Int, timeout: TimeInterval = 2) {
guard shouldFire,
let url = beaconURL(source: source, n: n) else { return }
var request = RelayFuelProvider.relayRequest(url, client: "app", timeout: timeout)
request.cachePolicy = .reloadIgnoringLocalCacheData
Task { _ = try? await URLSession.shared.data(for: request) }
}
/// Testable URL construction — hits the relay's existing widget-diag
/// beacon route with app attribution (zero relay changes).
static func beaconURL(source: String, n: Int, base: URL = RelayFuelProvider().baseURL) -> URL? {
guard var components = URLComponents(
url: base.appendingPathComponent("api/v1/widget-diag"),
resolvingAgainstBaseURL: false
) else { return nil }
components.queryItems = [
URLQueryItem(name: "intent", value: "app-live"),
URLQueryItem(name: "source", value: source),
URLQueryItem(name: "n", value: String(n)),
]
return components.url
}
}