road distance: pin-fingerprint guard so embedded/live/cached never cross-pollute
The road-distance cache was keyed purely by station ID with no coordinate, so
a value routed to one pin could be served for a same-ID station whose stored
coordinate came from a different source (live fetch vs bundled offline dump
vs a corrected pin), reproducing Maps mismatches.
- RoadDistanceCache now stores each routed pin (CachedRoadDistance{meters,
lat, lng}) and roadDistanceMeters() only serves a value when the displayed
station's coordinate matches the pinned one (within ~11 m).
- Kept the existing origin-distance staleness guard.
- Verified embedded sample data (ids y1/se1/...) never collides with real
relay IDs, and the widget STRICT radius filter drops far-offline samples, so
no actual leak existed in practice — this closes the theoretical stale-pin
channel and future-proofs against coordinate fixes.
This commit is contained in:
@@ -51,11 +51,16 @@ enum RoadDistanceService {
|
|||||||
.filter { $0.distanceKM(to: lat, lng2: lng) <= maxRadiusKM }
|
.filter { $0.distanceKM(to: lat, lng2: lng) <= maxRadiusKM }
|
||||||
|
|
||||||
let origin = CLLocationCoordinate2D(latitude: lat, longitude: lng)
|
let origin = CLLocationCoordinate2D(latitude: lat, longitude: lng)
|
||||||
var entries: [String: Double] = [:]
|
var entries: [String: CachedRoadDistance] = [:]
|
||||||
for station in nearest {
|
for station in nearest {
|
||||||
let dest = CLLocationCoordinate2D(latitude: station.lat, longitude: station.lng)
|
let dest = CLLocationCoordinate2D(latitude: station.lat, longitude: station.lng)
|
||||||
if let meters = await roadMeters(from: origin, to: dest) {
|
if let meters = await roadMeters(from: origin, to: dest) {
|
||||||
entries[station.id] = meters
|
// 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).
|
||||||
|
entries[station.id] = CachedRoadDistance(meters: meters,
|
||||||
|
lat: station.lat,
|
||||||
|
lng: station.lng)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
guard !entries.isEmpty else { return }
|
guard !entries.isEmpty else { return }
|
||||||
|
|||||||
@@ -647,7 +647,8 @@ final class RoadDistanceCacheTests: XCTestCase {
|
|||||||
func testRoadDistanceUsedWhenCachedNear() {
|
func testRoadDistanceUsedWhenCachedNear() {
|
||||||
let s = station("a", 51.5074, -0.1278)
|
let s = station("a", 51.5074, -0.1278)
|
||||||
// Cache a road distance of 3.2 km for this station from the user's fix.
|
// Cache a road distance of 3.2 km for this station from the user's fix.
|
||||||
FuelStore.saveRoadDistances(sourceLat: 51.6, sourceLng: -0.1, entries: ["a": 3200])
|
FuelStore.saveRoadDistances(sourceLat: 51.6, sourceLng: -0.1,
|
||||||
|
entries: ["a": .init(meters: 3200, lat: 51.5074, lng: -0.1278)])
|
||||||
let km = FuelStore.displayDistanceKM(station: s, userLat: 51.6, userLng: -0.1)
|
let km = FuelStore.displayDistanceKM(station: s, userLat: 51.6, userLng: -0.1)
|
||||||
XCTAssertEqual(km, 3.2, accuracy: 0.0001)
|
XCTAssertEqual(km, 3.2, accuracy: 0.0001)
|
||||||
}
|
}
|
||||||
@@ -655,8 +656,9 @@ final class RoadDistanceCacheTests: XCTestCase {
|
|||||||
func testRoadDistanceNilWhenOriginFar() {
|
func testRoadDistanceNilWhenOriginFar() {
|
||||||
let s = station("a", 51.5074, -0.1278)
|
let s = station("a", 51.5074, -0.1278)
|
||||||
// Cache built in London, but the user is now ~200 km away -> stale.
|
// Cache built in London, but the user is now ~200 km away -> stale.
|
||||||
FuelStore.saveRoadDistances(sourceLat: 51.5074, sourceLng: -0.1278, entries: ["a": 3200])
|
FuelStore.saveRoadDistances(sourceLat: 51.5074, sourceLng: -0.1278,
|
||||||
let meters = FuelStore.roadDistanceMeters(for: "a", userLat: 53.4808, userLng: -2.2426)
|
entries: ["a": .init(meters: 3200, lat: 51.5074, lng: -0.1278)])
|
||||||
|
let meters = FuelStore.roadDistanceMeters(for: s, userLat: 53.4808, userLng: -2.2426)
|
||||||
XCTAssertNil(meters)
|
XCTAssertNil(meters)
|
||||||
// And display falls back to straight-line.
|
// And display falls back to straight-line.
|
||||||
let km = FuelStore.displayDistanceKM(station: s, userLat: 53.4808, userLng: -2.2426)
|
let km = FuelStore.displayDistanceKM(station: s, userLat: 53.4808, userLng: -2.2426)
|
||||||
@@ -664,12 +666,26 @@ final class RoadDistanceCacheTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func testRoadDistanceUsedForOtherStationNotFound() {
|
func testRoadDistanceUsedForOtherStationNotFound() {
|
||||||
FuelStore.saveRoadDistances(sourceLat: 51.6, sourceLng: -0.1, entries: ["a": 3200])
|
FuelStore.saveRoadDistances(sourceLat: 51.6, sourceLng: -0.1,
|
||||||
|
entries: ["a": .init(meters: 3200, lat: 51.5074, lng: -0.1278)])
|
||||||
// A station that isn't in the cache falls back to straight-line.
|
// A station that isn't in the cache falls back to straight-line.
|
||||||
let s = station("z", 51.51, -0.13)
|
let s = station("z", 51.51, -0.13)
|
||||||
let km = FuelStore.displayDistanceKM(station: s, userLat: 51.6, userLng: -0.1)
|
let km = FuelStore.displayDistanceKM(station: s, userLat: 51.6, userLng: -0.1)
|
||||||
XCTAssertEqual(km, s.distanceKM(to: 51.6, lng2: -0.1), accuracy: 0.0001)
|
XCTAssertEqual(km, s.distanceKM(to: 51.6, lng2: -0.1), accuracy: 0.0001)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testRoadDistanceNotServedWhenStationPinDiffers() {
|
||||||
|
// Route a road distance to station "a" at pin P1.
|
||||||
|
FuelStore.saveRoadDistances(sourceLat: 51.6, sourceLng: -0.1,
|
||||||
|
entries: ["a": .init(meters: 3200, lat: 51.5074, lng: -0.1278)])
|
||||||
|
// The SAME station id appears with a moved pin (corrected coordinate /
|
||||||
|
// different embedded vs live source): the cached route to P1 must NOT
|
||||||
|
// be served — it belongs to a different location.
|
||||||
|
let moved = station("a", 51.5400, -0.1600)
|
||||||
|
let km = FuelStore.displayDistanceKM(station: moved, userLat: 51.6, userLng: -0.1)
|
||||||
|
XCTAssertEqual(km, moved.distanceKM(to: 51.6, lng2: -0.1), accuracy: 0.0001,
|
||||||
|
"road value routed to the old pin leaked onto a different coordinate")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Install identity
|
// MARK: - Install identity
|
||||||
|
|||||||
+34
-8
@@ -999,7 +999,16 @@ struct FuelStore {
|
|||||||
/// user position before a cached road distance is treated as stale.
|
/// user position before a cached road distance is treated as stale.
|
||||||
static let roadDistanceOriginToleranceMeters: Double = 600
|
static let roadDistanceOriginToleranceMeters: Double = 600
|
||||||
|
|
||||||
static func saveRoadDistances(sourceLat: Double, sourceLng: Double, entries: [String: Double]) {
|
/// How far (degrees) a station's stored coordinate may drift from the pin
|
||||||
|
/// a road distance was actually routed to before that cached value is
|
||||||
|
/// treated as belonging to a different station. Guards against one data
|
||||||
|
/// source (live fetch, bundled offline dump, or a corrected pin) serving a
|
||||||
|
/// road distance that was computed for a different coordinate under the
|
||||||
|
/// same station ID. ~1e-4 deg ≈ 11 m — tolerates float/rounding jitter but
|
||||||
|
/// catches any real pin change.
|
||||||
|
static let roadDistancePinToleranceDegrees: Double = 1e-4
|
||||||
|
|
||||||
|
static func saveRoadDistances(sourceLat: Double, sourceLng: Double, entries: [String: CachedRoadDistance]) {
|
||||||
let cache = RoadDistanceCache(sourceLat: sourceLat, sourceLng: sourceLng,
|
let cache = RoadDistanceCache(sourceLat: sourceLat, sourceLng: sourceLng,
|
||||||
updatedAt: Date().timeIntervalSince1970, entries: entries)
|
updatedAt: Date().timeIntervalSince1970, entries: entries)
|
||||||
if let data = try? JSONEncoder().encode(cache) {
|
if let data = try? JSONEncoder().encode(cache) {
|
||||||
@@ -1017,10 +1026,11 @@ struct FuelStore {
|
|||||||
|
|
||||||
/// Cached road distance (metres) to a station from the user's location, or
|
/// Cached road distance (metres) to a station from the user's location, or
|
||||||
/// nil when not cached / the cache was built too far from where the user
|
/// nil when not cached / the cache was built too far from where the user
|
||||||
/// is now.
|
/// is now / the station's own pin doesn't match the coordinate that was
|
||||||
static func roadDistanceMeters(for stationID: String, userLat: Double, userLng: Double) -> Double? {
|
/// routed.
|
||||||
|
static func roadDistanceMeters(for station: FuelStation, userLat: Double, userLng: Double) -> Double? {
|
||||||
guard let cache = loadRoadDistances(),
|
guard let cache = loadRoadDistances(),
|
||||||
let meters = cache.entries[stationID] else { return nil }
|
let entry = cache.entries[station.id] else { return nil }
|
||||||
// The cache is only valid near the location it was built from.
|
// The cache is only valid near the location it was built from.
|
||||||
let dLat = (userLat - cache.sourceLat) * .pi / 180
|
let dLat = (userLat - cache.sourceLat) * .pi / 180
|
||||||
let dLng = (userLng - cache.sourceLng) * .pi / 180
|
let dLng = (userLng - cache.sourceLng) * .pi / 180
|
||||||
@@ -1030,13 +1040,18 @@ struct FuelStore {
|
|||||||
sin(dLng / 2) * sin(dLng / 2)
|
sin(dLng / 2) * sin(dLng / 2)
|
||||||
let originDistanceMeters = r * 2 * atan2(sqrt(a), sqrt(1 - a))
|
let originDistanceMeters = r * 2 * atan2(sqrt(a), sqrt(1 - a))
|
||||||
guard originDistanceMeters <= roadDistanceOriginToleranceMeters else { return nil }
|
guard originDistanceMeters <= roadDistanceOriginToleranceMeters else { return nil }
|
||||||
return meters
|
// Pin fingerprint: never serve a routed value for a coordinate we
|
||||||
|
// didn't actually route to. This is the guard that keeps embedded /
|
||||||
|
// live / cached station sets from injecting each other's road metres.
|
||||||
|
guard abs(entry.lat - station.lat) <= roadDistancePinToleranceDegrees,
|
||||||
|
abs(entry.lng - station.lng) <= roadDistancePinToleranceDegrees else { return nil }
|
||||||
|
return entry.meters
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Distance (km) to display for a station: cached ROAD distance when
|
/// Distance (km) to display for a station: cached ROAD distance when
|
||||||
/// available (matches Apple Maps), else straight-line haversine.
|
/// available (matches Apple Maps), else straight-line haversine.
|
||||||
static func displayDistanceKM(station: FuelStation, userLat: Double, userLng: Double) -> Double {
|
static func displayDistanceKM(station: FuelStation, userLat: Double, userLng: Double) -> Double {
|
||||||
if let meters = roadDistanceMeters(for: station.id, userLat: userLat, userLng: userLng) {
|
if let meters = roadDistanceMeters(for: station, userLat: userLat, userLng: userLng) {
|
||||||
return meters / 1000.0
|
return meters / 1000.0
|
||||||
}
|
}
|
||||||
return station.distanceKM(to: userLat, lng2: userLng)
|
return station.distanceKM(to: userLat, lng2: userLng)
|
||||||
@@ -1103,10 +1118,21 @@ struct FuelStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Cached Apple-Maps road distances for nearby stations (see
|
/// Cached Apple-Maps road distances for nearby stations (see
|
||||||
/// `FuelStore.roadDistancesKey`). `entries` maps stationID → road metres.
|
/// A single cached road distance plus the station pin it was routed to. Keeping
|
||||||
|
/// the pin lets `roadDistanceMeters` refuse to serve a route computed for a
|
||||||
|
/// *different* coordinate under the same ID — the guard that stops embedded /
|
||||||
|
/// live / cached station sets cross-contaminating the distance display.
|
||||||
|
struct CachedRoadDistance: Codable {
|
||||||
|
let meters: Double
|
||||||
|
let lat: Double
|
||||||
|
let lng: Double
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `FuelStore.roadDistancesKey`). `entries` maps stationID → road metres +
|
||||||
|
/// the routed pin.
|
||||||
struct RoadDistanceCache: Codable {
|
struct RoadDistanceCache: Codable {
|
||||||
let sourceLat: Double
|
let sourceLat: Double
|
||||||
let sourceLng: Double
|
let sourceLng: Double
|
||||||
let updatedAt: TimeInterval
|
let updatedAt: TimeInterval
|
||||||
let entries: [String: Double]
|
let entries: [String: CachedRoadDistance]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user