P0: live provider chain — GitHub mirror primary, relay fallback, bundled dump last resort + app telemetry beacon

- MirrorFuelProvider: fetches CURRENT prices from raw.githubusercontent
  (latest.json pointer → history/<day>.json full dump), app-group day-cache
  so the ~2.8 MB dump is re-downloaded only when the mirror pushes a new day
- LiveChainProvider: full path (app refresh) GitHub → relay; focused path
  (background alert checks) relay-first so alerts never pull the full dump
  over mobile data; records which leg served for About + telemetry
- FuelBeacon: fire-and-forget X-Client app ping to the relay's existing
  widget-diag route (zero relay changes) — attribution + cadence on-LAN,
  silent skip off-LAN is the reachability datum; /stats app-hit spike =
  GitHub path failing
- Bundled dump: FuelBoardDump dataset (real 2026-08-15 snapshot, 8,022
  stations) + BundledDumpProvider + scripts/refresh_bundled_dump.sh —
  no-network last resort replaces the demo sample set
- ContentView: About meta from the chain; catch falls back cached → bundled
  → sample
- 89 tests (6 new: chain order x4, cache decision, beacon URL); Release
  build green; beacon route verified against the live relay (204 + logged)
This commit is contained in:
FuelBoard Contributor
2026-08-15 13:00:24 +01:00
parent 54aca14fd4
commit 366e21a1b4
9 changed files with 368 additions and 8 deletions
@@ -0,0 +1,12 @@
{
"info" : {
"author" : "xcode",
"version" : 1
},
"data" : [
{
"filename" : "fuelboard_dump.json",
"idiom" : "universal"
}
]
}
File diff suppressed because one or more lines are too long
+18
View File
@@ -0,0 +1,18 @@
// BundledDumpProvider.swift no-network last resort (P0 live chain).
//
// A REAL snapshot of the mirror dump is bundled into the app (FuelBoardDump
// dataset, refreshed before builds by scripts/refresh_bundled_dump.sh) so the
// app never demos empty and App Review always sees real data even fully
// offline. Same relay envelope, so the shared decode applies unchanged.
import UIKit
enum BundledDumpProvider {
/// The bundled real snapshot, decoded with the shared relay decode
/// nil only if the asset is missing or corrupt (callers then fall back
/// to the England-wide sample set).
static var stations: [FuelStation]? {
guard let asset = NSDataAsset(name: "FuelBoardDump") else { return nil }
return try? FuelPriceProvider.decodeStations(from: asset.data)
}
}
+12 -5
View File
@@ -454,9 +454,10 @@ struct ContentView: View {
stations = fetched stations = fetched
FuelStore.saveStations(fetched) FuelStore.saveStations(fetched)
FuelStore.saveLastRefresh() FuelStore.saveLastRefresh()
// Persist relay envelope metadata (source, station count, GOV.UK // Persist envelope metadata (source, station count, GOV.UK
// dataset update time) for the Settings About section. // dataset update time) for the Settings About section the
if let meta = RelayFuelProvider.latestMeta { // live chain records whichever leg served the fetch.
if let meta = LiveChainProvider.latestMeta {
FuelStore.saveRelayMeta(meta) FuelStore.saveRelayMeta(meta)
} }
// Keep the keychain favourites fresh with the new prices the // Keep the keychain favourites fresh with the new prices the
@@ -467,8 +468,14 @@ struct ContentView: View {
WidgetCenter.shared.reloadAllTimelines() WidgetCenter.shared.reloadAllTimelines()
statusMessage = "Loaded \(fetched.count) stations · \(Date().formatted(date: .omitted, time: .shortened))" statusMessage = "Loaded \(fetched.count) stations · \(Date().formatted(date: .omitted, time: .shortened))"
} catch { } catch {
statusMessage = "Live fetch failed: \(error.localizedDescription). Showing cached/sample data." statusMessage = "Live fetch failed: \(error.localizedDescription). Showing cached data."
stations = FuelStore.loadStations().isEmpty ? SampleFuelProvider.sampleStations : FuelStore.loadStations() if FuelStore.loadStations().isEmpty {
// No cached prices last resort is the bundled REAL dump
// (stale but genuine), then the demo sample set.
stations = BundledDumpProvider.stations ?? SampleFuelProvider.sampleStations
} else {
stations = FuelStore.loadStations()
}
} }
// Keep monitor geofences in sync with the freshest data. // Keep monitor geofences in sync with the freshest data.
monitor.update(stations: stations, favourites: refreshedFavourites, monitor.update(stations: stations, favourites: refreshedFavourites,
@@ -0,0 +1 @@
../../../Shared/MirrorFuelProvider.swift
@@ -0,0 +1,118 @@
import XCTest
@testable import FuelBoardShared
/// P0 live chain: GitHub mirror LAN relay bundled dump.
final class LiveChainTests: XCTestCase {
override func setUp() {
super.setUp()
FuelBeacon.isEnabled = false // chain tests must not fire real pings
}
override func tearDown() {
FuelBeacon.isEnabled = true
super.tearDown()
}
// MARK: Fixtures
private final class StubProvider: FuelPriceProviding {
var result: [FuelStation]
var error: Error?
private(set) var callCount = 0
init(result: [FuelStation]? = nil, error: Error? = nil) {
self.result = result ?? []
self.error = error
}
func fetchStations(near lat: Double?, lng: Double?, fuel: FuelType, radiusKM: Double?) async throws -> [FuelStation] {
callCount += 1
if let error { throw error }
return result
}
}
private func fixtureStation(_ id: String = "s1") -> FuelStation {
FuelStation(id: id, name: "Station \(id)", brand: "Test", address: "1 High St",
postcode: "SW1A 1AA", lat: 53.7, lng: -1.8,
prices: [.e10: 137.9, .e5: 144.9, .diesel: 144.9], priceUpdated: nil)
}
// MARK: Full path (app refresh): GitHub first
func testFullPathPrefersMirror() async throws {
let mirror = StubProvider(result: [fixtureStation()])
let relay = StubProvider(error: FuelProviderError.relayUnavailable)
let chain = LiveChainProvider(mirror: mirror, relay: relay)
let stations = try await chain.fetchStations(near: nil, lng: nil, fuel: .e10, radiusKM: nil)
XCTAssertEqual(stations.count, 1)
XCTAssertEqual(mirror.callCount, 1, "full path must try the GitHub mirror first")
XCTAssertEqual(relay.callCount, 0, "relay must not run when the mirror serves")
XCTAssertEqual(LiveChainProvider.lastSource, "github")
}
func testFullPathFallsBackToRelay() async throws {
let mirror = StubProvider(error: FuelProviderError.mirrorUnavailable)
let relay = StubProvider(result: [fixtureStation("relay")])
let chain = LiveChainProvider(mirror: mirror, relay: relay)
let stations = try await chain.fetchStations(near: nil, lng: nil, fuel: .e10, radiusKM: nil)
XCTAssertEqual(stations.first?.id, "relay")
XCTAssertEqual(relay.callCount, 1, "relay must serve when the mirror is down")
XCTAssertEqual(LiveChainProvider.lastSource, "relay")
}
// MARK: Focused path (alert checks): relay first never pull the full
// ~2.8 MB dump over mobile data for a background alert when the relay is up.
func testFocusedPathPrefersRelay() async throws {
let mirror = StubProvider(result: [fixtureStation("mirror")])
let relay = StubProvider(result: [fixtureStation("relay")])
let chain = LiveChainProvider(mirror: mirror, relay: relay)
_ = try await chain.fetchStations(near: 53.7, lng: -1.8, fuel: .e10, radiusKM: 5)
XCTAssertEqual(relay.callCount, 1, "focused alert fetch must prefer the light relay call")
XCTAssertEqual(mirror.callCount, 0, "mirror full dump must NOT be pulled when the relay is up")
XCTAssertEqual(LiveChainProvider.lastSource, "relay")
}
func testFocusedPathFallsBackToMirror() async throws {
let mirror = StubProvider(result: [fixtureStation("mirror")])
let relay = StubProvider(error: FuelProviderError.relayUnavailable)
let chain = LiveChainProvider(mirror: mirror, relay: relay)
let stations = try await chain.fetchStations(near: 53.7, lng: -1.8, fuel: .e10, radiusKM: 5)
XCTAssertEqual(stations.first?.id, "mirror")
XCTAssertEqual(mirror.callCount, 1)
XCTAssertEqual(LiveChainProvider.lastSource, "github")
}
// MARK: Mirror day-cache decision
func testDumpCacheReuseDecision() {
XCTAssertTrue(MirrorFuelProvider.canReuseCache(cachedDay: "2026-08-15", latestDay: "2026-08-15"),
"same day → reuse the cached dump, no ~2.8 MB re-download")
XCTAssertFalse(MirrorFuelProvider.canReuseCache(cachedDay: "2026-08-14", latestDay: "2026-08-15"))
XCTAssertFalse(MirrorFuelProvider.canReuseCache(cachedDay: nil, latestDay: "2026-08-15"))
XCTAssertFalse(MirrorFuelProvider.canReuseCache(cachedDay: "2026-08-15", latestDay: nil))
}
// MARK: Beacon URL
func testBeaconURLCarriesAppAttribution() {
guard let url = FuelBeacon.beaconURL(source: "github", n: 8022) else {
return XCTFail("beacon URL must build")
}
XCTAssertTrue(url.absoluteString.contains("api/v1/widget-diag"))
let items = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems ?? []
XCTAssertTrue(items.contains(URLQueryItem(name: "intent", value: "app-live")))
XCTAssertTrue(items.contains(URLQueryItem(name: "source", value: "github")))
XCTAssertTrue(items.contains(URLQueryItem(name: "n", value: "8022")))
}
}
+6 -3
View File
@@ -19,9 +19,9 @@ protocol FuelPriceProviding {
} }
enum FuelPriceProvider { enum FuelPriceProvider {
/// Default: the keyless relay (full-UK Fuel Finder data). Falls back to /// Default: the live chain GitHub mirror (primary, off-LAN) LAN
/// the England-wide sample set when the relay is unreachable. /// relay (fallback) bundled dump (last resort in the app target).
static let active: FuelPriceProviding = RelayFuelProvider() static let active: FuelPriceProviding = LiveChainProvider()
/// Decodes a relay payload into stations, applying the defensive price /// Decodes a relay payload into stations, applying the defensive price
/// band. Internal so the unit-test target can exercise the guard. /// band. Internal so the unit-test target can exercise the guard.
@@ -296,12 +296,15 @@ struct FuelFinderProvider: FuelPriceProviding {
enum FuelProviderError: LocalizedError { enum FuelProviderError: LocalizedError {
case notImplemented case notImplemented
case relayUnavailable case relayUnavailable
case mirrorUnavailable
var errorDescription: String? { var errorDescription: String? {
switch self { switch self {
case .notImplemented: case .notImplemented:
return "Live Fuel Finder API not wired yet — activate with GOV.UK One Login credentials." return "Live Fuel Finder API not wired yet — activate with GOV.UK One Login credentials."
case .relayUnavailable: case .relayUnavailable:
return "FuelBoard Relay unreachable — showing cached/sample data." return "FuelBoard Relay unreachable — showing cached/sample data."
case .mirrorUnavailable:
return "GitHub price mirror unreachable."
} }
} }
} }
+180
View File
@@ -0,0 +1,180 @@
// 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 the LAN relay and the bundled dump stays as the
// no-network last resort.
//
// Chain (LiveChainProvider):
// full path (app refresh): GitHub mirror LAN relay bundled dump
// focused path (alert fetch): LAN relay (small radius fetch) GitHub
// mirror (full decode, device filters) dump
// The focused path stays relay-first so a background alert check never pulls
// the ~2.8 MB full dump over mobile data when the relay is reachable.
//
// Telemetry (FuelBeacon): raw GitHub fetches are invisible to repo analytics
// (page views/clones only), so every chain fetch fires an opportunistic
// fire-and-forget X-Client "app" ping at the relay's existing widget-diag
// route (zero relay changes). On-LAN = attribution + cadence; off-LAN the
// ping fails in ~2 s and the skip itself is the reachability datum. The
// relay's /stats app-hit spike doubles as the fallback-outage signal.
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
}
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 {
let (fetched, response) = try await URLSession.shared.data(
from: FuelHistoryStore.historyFileURL(day: day, base: baseURL)
)
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 stations
}
}
// MARK: - Live chain provider
/// The live price chain. Full path prefers the GitHub mirror; the focused
/// path (alert checks) stays relay-first so a background alert never pulls
/// the full dump over mobile data when the relay is up. Whichever leg serves
/// is recorded for telemetry + the About section.
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] {
if radiusKM != nil {
// Focused alert fetch: light relay radius call first, mirror fallback.
do {
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
} catch {
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
}
}
// Full dump: GitHub first (off-LAN + App Review), relay fallback.
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 {
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 (off-LAN) is silent and IS the
/// reachability datum. Disabled in unit tests via `isEnabled`.
enum FuelBeacon {
static var isEnabled = true
static func fire(source: String, n: Int, timeout: TimeInterval = 2) {
guard isEnabled,
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
}
}
+20
View File
@@ -0,0 +1,20 @@
#!/bin/bash
# Refresh the bundled fallback dump (FuelBoardDump dataset) from the GitHub
# mirror — run before a Release build so the IPA carries the freshest REAL
# snapshot (the app's no-network last resort). Idempotent: skips when the
# bundled dump already carries the mirror's data_updated stamp.
set -euo pipefail
cd "$(dirname "$0")/.."
TARGET="FuelBoard/Assets.xcassets/FuelBoardDump.dataset/fuelboard_dump.json"
RAW="https://raw.githubusercontent.com/aptonline/fuelboard-data/main"
LATEST="$(curl -fsSL "$RAW/latest.json")"
DAY="$(printf '%s' "$LATEST" | python3 -c 'import json,sys; d=json.load(sys.stdin); print(d.get("available_to") or d.get("date") or "")')"
UPD="$(printf '%s' "$LATEST" | python3 -c 'import json,sys; d=json.load(sys.stdin); print(d.get("data_updated") or "")')"
if [ -z "$DAY" ]; then echo "no snapshot day in latest.json" >&2; exit 1; fi
if [ -f "$TARGET" ] && grep -q "\"data_updated\": \"$UPD\"" "$TARGET"; then
echo "bundled dump already current ($DAY, $UPD)"
exit 0
fi
echo "fetching history/$DAY.json ($UPD)…"
curl -fsSL "$RAW/history/$DAY.json" -o "$TARGET"
echo "updated $TARGET$DAY"