- RoadDistanceService builds route endpoints with MKMapItem(location:address:) instead of the MKPlacemark path deprecated in iOS 26. - LiveActivityManager updates through ActivityContent, replacing the ActivityKit update(using:) call deprecated in iOS 16.2. - The location-update hook no longer captures an unused state object; the closure body already runs on self, so behaviour is unchanged.
454 lines
22 KiB
Swift
454 lines
22 KiB
Swift
// RoadDistanceService.swift — computes Apple-Maps-matched ROAD distances for
|
|
// nearby stations and caches them (keychain) so widgets + Live Activity can
|
|
// show real driving distance instead of straight-line haversine.
|
|
//
|
|
// Runs only in the APP: MKDirections is network-bound and the widget extension
|
|
// has a tiny execution budget + a ~40-70/day refresh budget, so routing belongs
|
|
// here, not in the widget. The widget/Live Activity just read the cache.
|
|
//
|
|
// Throttling: recompute at most every `throttleMinutes`, or when the user has
|
|
// moved `moveThresholdMeters` from where the cache was built. In
|
|
// "Accurate distances" mode a pass routes the WHOLE search area (up to
|
|
// `fillCapacity` stations) so the browse layer can serve routed distances for
|
|
// every row at once; the mode must also be ON for any pass to run at all.
|
|
|
|
import Foundation
|
|
import CoreLocation
|
|
import MapKit
|
|
import UIKit
|
|
import WidgetKit
|
|
|
|
struct RoadDistanceFillDebugStatus: Codable, Equatable {
|
|
var state: State = .idle
|
|
var reason: String = "Waiting"
|
|
var updatedAt: Date = Date()
|
|
var originLat: Double?
|
|
var originLng: Double?
|
|
var stationPoolCount: Int = 0
|
|
var priorityCount: Int = 0
|
|
var candidateCount: Int = 0
|
|
var routedCount: Int = 0
|
|
var cachedCount: Int = 0
|
|
var failedCount: Int = 0
|
|
var complete: Bool = false
|
|
var capped: Bool = false
|
|
var elapsedSeconds: Double?
|
|
var movedMeters: Double?
|
|
var throttleSecondsRemaining: Double?
|
|
|
|
enum State: String, Codable {
|
|
case idle
|
|
case gated
|
|
case running
|
|
case completed
|
|
}
|
|
|
|
var summary: String {
|
|
switch state {
|
|
case .idle: return "Idle — \(reason)"
|
|
case .gated: return "Gated — \(reason)"
|
|
case .running: return "Running — \(reason)"
|
|
case .completed: return complete ? "Complete — routed set ready" : "Incomplete — still Approx."
|
|
}
|
|
}
|
|
|
|
static let idle = RoadDistanceFillDebugStatus()
|
|
}
|
|
|
|
enum RoadDistanceService {
|
|
/// Upper bound on stations routed per pass. Accurate-distances mode routes the
|
|
/// whole search area in ONE pass, because a 12-per-pass budget could never
|
|
/// make a list fully routed — that is exactly why two devices (and two
|
|
/// radii) disagreed about the same station. Capped so a pathological dataset
|
|
/// can't turn one pass into thousands of route requests; a capped pass stays
|
|
/// incomplete, and the browse layer keeps showing straight-line + "Approx."
|
|
/// until a later pass finishes the job. Measured on device: ~3.2 route calls
|
|
/// per second, so 600 stations (1,200 calls) is ~6 min at the very worst.
|
|
static let fillCapacity = 600
|
|
/// How many Worth-the-Drive candidates may claim slots in a pass. Those
|
|
/// rows are ranked by NET SAVING and are typically FURTHER out than the
|
|
/// nearest stations — routing only the nearest stations (the old behaviour)
|
|
/// meant they never got a measured route and stayed on straight-line
|
|
/// estimates forever.
|
|
static let priorityCandidates = 6
|
|
/// Only route stations within this straight-line radius (km). Covers the
|
|
/// largest search radius the UI exposes (15 mi ≈ 24.1 km) plus margin, so
|
|
/// every station a widget/Live Activity/list can actually show gets routed.
|
|
static let maxRadiusKM: Double = 25
|
|
/// Don't route again more often than this (minutes) once the set is whole.
|
|
static let throttleMinutes: Double = 10
|
|
/// Retry window for a set that is still INCOMPLETE — a pass cut short by
|
|
/// backgrounding the app must be continued promptly, not in ten minutes.
|
|
static let incompleteRetrySeconds: Double = 30
|
|
/// Recompute when the user moves more than this (metres) from the last
|
|
/// source location.
|
|
static let moveThresholdMeters: Double = 400
|
|
static private(set) var debugStatus = RoadDistanceFillDebugStatus.idle
|
|
|
|
@MainActor
|
|
static func currentDebugStatus() -> RoadDistanceFillDebugStatus {
|
|
debugStatus
|
|
}
|
|
|
|
@MainActor
|
|
private static func publishDebug(_ status: RoadDistanceFillDebugStatus) {
|
|
debugStatus = status
|
|
NotificationCenter.default.post(name: .fuelBoardRoadDistanceDebugUpdated, object: nil)
|
|
}
|
|
|
|
private static func updateDebug(_ status: RoadDistanceFillDebugStatus) async {
|
|
await MainActor.run { publishDebug(status) }
|
|
}
|
|
|
|
private static func baseDebugStatus(state: RoadDistanceFillDebugStatus.State,
|
|
reason: String,
|
|
stations: [FuelStation],
|
|
lat: Double,
|
|
lng: Double,
|
|
priorityCount: Int) -> RoadDistanceFillDebugStatus {
|
|
RoadDistanceFillDebugStatus(
|
|
state: state,
|
|
reason: reason,
|
|
updatedAt: Date(),
|
|
originLat: lat,
|
|
originLng: lng,
|
|
stationPoolCount: stations.count,
|
|
priorityCount: priorityCount
|
|
)
|
|
}
|
|
|
|
/// Refreshes the cached road distances for the in-radius stations around
|
|
/// `lat`/`lng`. Throttled by time + distance; safe to call on every fix.
|
|
///
|
|
/// - Parameter priorityStations: stations to route BEFORE the nearest ones
|
|
/// (the Worth-the-Drive candidates and the comparison baseline), so the
|
|
/// numbers the user actually reads are road-routed rather than estimated.
|
|
static func refreshIfNeeded(stations: [FuelStation],
|
|
lat: Double,
|
|
lng: Double,
|
|
prioritising priorityStations: [FuelStation] = []) async {
|
|
// "Accurate distances" OFF (the default) means the browse layer never
|
|
// serves a routed value, so any routing would be pure cost with nothing
|
|
// visible to show for it: no pass, no requests, no widget reloads.
|
|
guard FuelStore.accurateDistancesEnabled else {
|
|
await updateDebug(baseDebugStatus(state: .gated,
|
|
reason: "Accurate distances off",
|
|
stations: stations,
|
|
lat: lat,
|
|
lng: lng,
|
|
priorityCount: priorityStations.count))
|
|
return
|
|
}
|
|
// Only fill while the app is on screen. The browse layer is the only
|
|
// consumer, so a background fill would spend radio and battery routing a
|
|
// position nothing is displaying — worst case, a long drive.
|
|
guard await appIsActive() else {
|
|
await updateDebug(baseDebugStatus(state: .gated,
|
|
reason: "App not active",
|
|
stations: stations,
|
|
lat: lat,
|
|
lng: lng,
|
|
priorityCount: priorityStations.count))
|
|
return
|
|
}
|
|
guard await refreshGate.begin() else {
|
|
await updateDebug(baseDebugStatus(state: .gated,
|
|
reason: "Fill already running",
|
|
stations: stations,
|
|
lat: lat,
|
|
lng: lng,
|
|
priorityCount: priorityStations.count))
|
|
return
|
|
}
|
|
defer { Task { await refreshGate.end() } }
|
|
|
|
guard !stations.isEmpty else {
|
|
await updateDebug(baseDebugStatus(state: .gated,
|
|
reason: "No stations loaded",
|
|
stations: stations,
|
|
lat: lat,
|
|
lng: lng,
|
|
priorityCount: priorityStations.count))
|
|
return
|
|
}
|
|
|
|
let existing = FuelStore.loadRoadDistances()
|
|
// Entries can only be merged when they were routed from (near enough)
|
|
// this same origin — the read path rejects anything else anyway.
|
|
let originCompatible = existing.map {
|
|
haversineMeters($0.sourceLat, $0.sourceLng, lat, lng) < moveThresholdMeters
|
|
} ?? false
|
|
|
|
// Throttle: keep cached values when fresh and the user hasn't moved far.
|
|
// `FB_ROUTE_REFRESH=1` forces a pass (DEBUG verification hook).
|
|
#if DEBUG
|
|
let forcePass = ProcessInfo.processInfo.environment["FB_ROUTE_REFRESH"] == "1"
|
|
#else
|
|
let forcePass = false
|
|
#endif
|
|
if !forcePass, let cache = existing {
|
|
let elapsed = Date().timeIntervalSince1970 - cache.updatedAt
|
|
let movedMeters = haversineMeters(cache.sourceLat, cache.sourceLng, lat, lng)
|
|
// An INCOMPLETE set still has work to do, so it is not throttled for
|
|
// the full ten minutes. The browse layer refuses to serve a partial
|
|
// set (every row reads straight-line + "Approx." until the set is
|
|
// whole), and a pass is routinely cut short by backgrounding the app
|
|
// — so a ten-minute throttle on an incomplete set is what made
|
|
// "Approx." look permanent: reopen the app inside the window and the
|
|
// pass that would finish the job never even started, and refreshing
|
|
// the prices could not help. Retry soon instead.
|
|
let incomplete = cache.complete != true
|
|
let throttleWindow = incomplete ? incompleteRetrySeconds : throttleMinutes * 60
|
|
if elapsed < throttleWindow && movedMeters < moveThresholdMeters {
|
|
var status = baseDebugStatus(state: .gated,
|
|
reason: incomplete ? "Throttled — incomplete set, retrying"
|
|
: "Throttled",
|
|
stations: stations,
|
|
lat: lat,
|
|
lng: lng,
|
|
priorityCount: priorityStations.count)
|
|
status.cachedCount = cache.entries.count
|
|
status.complete = !incomplete
|
|
status.movedMeters = movedMeters
|
|
status.throttleSecondsRemaining = throttleWindow - elapsed
|
|
if incomplete,
|
|
await repairCompletenessIfNothingLeftToRoute(cache, stations: stations,
|
|
lat: lat, lng: lng) {
|
|
status.complete = true
|
|
status.reason = "Throttled — stale incomplete flag repaired"
|
|
}
|
|
await updateDebug(status)
|
|
if status.complete {
|
|
await notifyDistanceSurfaces()
|
|
}
|
|
return
|
|
}
|
|
}
|
|
|
|
// Candidates: Worth-the-Drive priorities first, then the nearest
|
|
// stations. Stations that already have a route from this origin are
|
|
// skipped so the per-pass budget goes on the ones still missing one.
|
|
var candidates: [FuelStation] = []
|
|
var seen = Set<String>()
|
|
func consider(_ station: FuelStation) {
|
|
guard candidates.count < fillCapacity else { return }
|
|
guard station.distanceKM(to: lat, lng2: lng) <= maxRadiusKM else { return }
|
|
guard seen.insert(station.id).inserted else { return }
|
|
guard !FuelStore.hasRoadDistance(for: station, userLat: lat, userLng: lng) else { return }
|
|
candidates.append(station)
|
|
}
|
|
for station in priorityStations.prefix(priorityCandidates) { consider(station) }
|
|
let nearest = stations.sorted { $0.distanceKM(to: lat, lng2: lng) < $1.distanceKM(to: lat, lng2: lng) }
|
|
for station in nearest {
|
|
if candidates.count >= fillCapacity { break }
|
|
consider(station)
|
|
}
|
|
|
|
guard !candidates.isEmpty else {
|
|
var status = baseDebugStatus(state: .gated,
|
|
reason: "No unrouted candidates",
|
|
stations: stations,
|
|
lat: lat,
|
|
lng: lng,
|
|
priorityCount: priorityStations.count)
|
|
status.cachedCount = existing?.entries.count ?? 0
|
|
var complete = existing?.complete == true && FuelStore.accurateDistancesReady(userLat: lat, userLng: lng)
|
|
if !complete, let existing,
|
|
await repairCompletenessIfNothingLeftToRoute(existing, stations: stations, lat: lat, lng: lng) {
|
|
complete = true
|
|
status.reason = "No unrouted candidates — stale incomplete flag repaired"
|
|
}
|
|
status.complete = complete
|
|
await updateDebug(status)
|
|
if status.complete {
|
|
await notifyDistanceSurfaces()
|
|
}
|
|
return
|
|
}
|
|
|
|
let origin = CLLocationCoordinate2D(latitude: lat, longitude: lng)
|
|
var entries: [String: CachedRoadDistance] = originCompatible ? (existing?.entries ?? [:]) : [:]
|
|
|
|
var running = baseDebugStatus(state: .running,
|
|
reason: "Routing \(candidates.count) stations",
|
|
stations: stations,
|
|
lat: lat,
|
|
lng: lng,
|
|
priorityCount: priorityStations.count)
|
|
running.candidateCount = candidates.count
|
|
running.cachedCount = entries.count
|
|
running.capped = candidates.count >= fillCapacity
|
|
await updateDebug(running)
|
|
|
|
let passStart = Date()
|
|
|
|
var routedCount = 0
|
|
for station in candidates {
|
|
let dest = CLLocationCoordinate2D(latitude: station.lat, longitude: station.lng)
|
|
async let outward = roadMeters(from: origin, to: dest)
|
|
async let back = roadMeters(from: dest, to: origin)
|
|
if let meters = await outward {
|
|
routedCount += 1
|
|
// Store the exact pin that was routed so the display layer can
|
|
// refuse to serve this value if the station later appears with
|
|
// a different coordinate (corrected pin / other data source).
|
|
let returnMeters = await back
|
|
entries[station.id] = CachedRoadDistance(meters: meters,
|
|
lat: station.lat,
|
|
lng: station.lng,
|
|
returnMeters: returnMeters)
|
|
}
|
|
}
|
|
guard entries.count > (originCompatible ? (existing?.entries.count ?? 0) : 0) || !originCompatible else {
|
|
var status = baseDebugStatus(state: .gated,
|
|
reason: "Route pass added no distances",
|
|
stations: stations,
|
|
lat: lat,
|
|
lng: lng,
|
|
priorityCount: priorityStations.count)
|
|
status.candidateCount = candidates.count
|
|
status.routedCount = routedCount
|
|
status.failedCount = candidates.count - routedCount
|
|
status.cachedCount = entries.count
|
|
status.elapsedSeconds = Date().timeIntervalSince(passStart)
|
|
await updateDebug(status)
|
|
return
|
|
}
|
|
|
|
// A pass is complete when nothing in the search area is left UNRESOLVED:
|
|
// every station either has a route from this anchor, or was attempted in
|
|
// THIS pass and Apple Maps returned none. A few pins genuinely have no
|
|
// route (offshore, deleted forecourt, bad snapshot) and one of those must
|
|
// never leave the area permanently incomplete — that would pin the whole
|
|
// list on straight-line estimates forever. Those stations show their
|
|
// straight line, marked Approx., and the radius filter uses that same
|
|
// distance, so nothing is ever out of step with what a row displays.
|
|
let attempted = Set(candidates.map(\.id))
|
|
let capped = candidates.count >= fillCapacity
|
|
let unresolved = capped || stations.contains { station in
|
|
station.distanceKM(to: lat, lng2: lng) <= maxRadiusKM
|
|
&& !FuelStore.hasRoadDistance(for: station, userLat: lat, userLng: lng)
|
|
&& !attempted.contains(station.id)
|
|
}
|
|
FuelStore.saveRoadDistances(sourceLat: lat, sourceLng: lng, entries: entries,
|
|
complete: !unresolved)
|
|
var completed = baseDebugStatus(state: .completed,
|
|
reason: !unresolved ? "Fill complete" : (capped ? "Capacity cap hit" : "Unresolved stations remain"),
|
|
stations: stations,
|
|
lat: lat,
|
|
lng: lng,
|
|
priorityCount: priorityStations.count)
|
|
completed.candidateCount = candidates.count
|
|
completed.routedCount = routedCount
|
|
completed.failedCount = candidates.count - routedCount
|
|
completed.cachedCount = entries.count
|
|
completed.complete = !unresolved
|
|
completed.capped = capped
|
|
completed.elapsedSeconds = Date().timeIntervalSince(passStart)
|
|
await updateDebug(completed)
|
|
#if DEBUG
|
|
// `FB-ROUTE` prints what a pass actually cost (stations, route calls,
|
|
// wall-clock, unroutable pins, whether the set is now complete) — the
|
|
// evidence for any "how taxing is routing?" answer, and how a bigger pass
|
|
// gets scaled from measurement instead of guesswork.
|
|
print("FB-ROUTE pass stations=\(candidates.count) calls=\(candidates.count * 2) "
|
|
+ "elapsed=\(String(format: "%.2f", Date().timeIntervalSince(passStart)))s "
|
|
+ "cached=\(entries.count) failed=\(candidates.count - routedCount) "
|
|
+ "complete=\(!unresolved)")
|
|
#endif
|
|
await notifyDistanceSurfaces()
|
|
}
|
|
|
|
/// A cache can hold a route for EVERY station in the search area and still be
|
|
/// flagged incomplete — a previous pass hit the capacity cap, or was
|
|
/// interrupted between routing and writing. Nothing would ever clear that
|
|
/// flag on its own (the next pass finds nothing left to route, and the
|
|
/// throttle path returns without writing), and every row would stay on
|
|
/// straight-line + "Approx." while its routed value sat in the cache. When
|
|
/// nothing in the area is left unrouted, the set IS complete: correct the
|
|
/// flag so the routed values can be served.
|
|
private static func repairCompletenessIfNothingLeftToRoute(_ cache: RoadDistanceCache,
|
|
stations: [FuelStation],
|
|
lat: Double,
|
|
lng: Double) async -> Bool {
|
|
guard cache.complete != true, !stations.isEmpty else { return false }
|
|
let nothingLeftToRoute = !stations.contains { station in
|
|
station.distanceKM(to: lat, lng2: lng) <= maxRadiusKM
|
|
&& !FuelStore.hasRoadDistance(for: station, userLat: lat, userLng: lng)
|
|
}
|
|
guard nothingLeftToRoute else { return false }
|
|
FuelStore.saveRoadDistances(sourceLat: cache.sourceLat, sourceLng: cache.sourceLng,
|
|
entries: cache.entries, complete: true)
|
|
#if DEBUG
|
|
print("FB-COMPLETE stale incomplete flag repaired cached=\(cache.entries.count) "
|
|
+ "(every in-radius station already routed — no re-routing needed)")
|
|
#endif
|
|
return true
|
|
}
|
|
|
|
@MainActor
|
|
private static func notifyDistanceSurfacesOnMain() {
|
|
WidgetCenter.shared.reloadAllTimelines()
|
|
NotificationCenter.default.post(name: .fuelBoardRoadDistancesUpdated, object: nil)
|
|
}
|
|
|
|
private static func notifyDistanceSurfaces() async {
|
|
await MainActor.run { notifyDistanceSurfacesOnMain() }
|
|
}
|
|
|
|
private static let refreshGate = RoadDistanceRefreshGate()
|
|
|
|
/// Accurate mode fills only while the app is on screen: the browse layer is
|
|
/// the only consumer of routed distances, so a background fill would spend
|
|
/// radio and battery routing a position nothing is looking at.
|
|
private static func appIsActive() async -> Bool {
|
|
await MainActor.run { UIApplication.shared.applicationState == .active }
|
|
}
|
|
|
|
/// Driving distance (metres) between two coordinates via Apple Maps routing.
|
|
private static func roadMeters(from: CLLocationCoordinate2D, to: CLLocationCoordinate2D) async -> Double? {
|
|
let request = MKDirections.Request()
|
|
request.source = MKMapItem(
|
|
location: CLLocation(latitude: from.latitude, longitude: from.longitude),
|
|
address: nil
|
|
)
|
|
request.destination = MKMapItem(
|
|
location: CLLocation(latitude: to.latitude, longitude: to.longitude),
|
|
address: nil
|
|
)
|
|
request.transportType = .automobile
|
|
request.requestsAlternateRoutes = false
|
|
do {
|
|
let response = try await MKDirections(request: request).calculate()
|
|
return response.routes.first?.distance
|
|
} catch {
|
|
return nil
|
|
}
|
|
}
|
|
|
|
/// Straight-line haversine distance between two coordinates, in metres.
|
|
private static func haversineMeters(_ lat1: Double, _ lng1: Double, _ lat2: Double, _ lng2: Double) -> Double {
|
|
let r = 6371000.0
|
|
let dLat = (lat2 - lat1) * .pi / 180
|
|
let dLng = (lng2 - lng1) * .pi / 180
|
|
let a = sin(dLat / 2) * sin(dLat / 2) +
|
|
cos(lat1 * .pi / 180) * cos(lat2 * .pi / 180) *
|
|
sin(dLng / 2) * sin(dLng / 2)
|
|
return r * 2 * atan2(sqrt(a), sqrt(1 - a))
|
|
}
|
|
}
|
|
|
|
private actor RoadDistanceRefreshGate {
|
|
private var isRefreshing = false
|
|
|
|
func begin() -> Bool {
|
|
guard !isRefreshing else { return false }
|
|
isRefreshing = true
|
|
return true
|
|
}
|
|
|
|
func end() {
|
|
isRefreshing = false
|
|
}
|
|
}
|