Files
Flights/Flights/Services/OpenSkyClient.swift
T
Trey T 6b33a104c8 Live tab: bundled airline DB, OpenSky login, in-flight trails
Three follow-ups to the live tab landed together:

1) Bundled airline registry
   - airlines.json (208KB, 2,695 entries sourced from FR24's
     /mobile/airlines feed and slimmed to {icao,iata,name,logo}).
   - AircraftRegistry rewritten as an instance singleton that loads
     the bundle at startup, indexes by both ICAO and IATA, and falls
     back to a small hardcoded subset if the bundle is unavailable.
   - Detail sheet now shows the airline's logo (loaded from FR24's
     CDN via AsyncImage) alongside the callsign. Filter chips use
     the real names everywhere.

2) OpenSky account login
   - OpenSkyCredentials: Keychain wrapper that stores username +
     password using SecItem APIs. Posts a notification on change so
     the OpenSkyClient can refresh its in-memory copy.
   - OpenSkyClient now sends HTTP Basic auth when credentials are
     present. Anonymous fallback unchanged.
   - OpenSkySettingsView: tap the gear in the footer to sign in.
     Credentials are verified against /states/all before being
     stored; sign-out clears Keychain. Raises the quota from ~100
     to ~4000 requests/day.

3) Flight trails
   - AircraftTrack model decodes OpenSky's /tracks/all heterogeneous
     path array into typed TrackPoint entries.
   - OpenSkyClient.track(icao24:) fetches the current/most-recent
     track for an aircraft.
   - LiveFlightsView renders a MapPolyline trail along the path of
     whichever aircraft is currently selected. Cleared on
     deselection; race-guarded against rapid selection changes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 06:16:49 -05:00

243 lines
9.9 KiB
Swift

import Foundation
/// Thin client for the OpenSky Network REST API. Two endpoints:
/// - `/states/all` live aircraft state vectors (positions, velocity, etc.)
/// - `/flights/aircraft` recent flight history per aircraft (used for the
/// tap-to-detail "where did this come from / going to" panel)
///
/// Anonymous access is rate-limited (~10s minimum between requests, 100/day);
/// authenticated access gets higher quotas but requires the user to register.
/// We default to anonymous + heavy debouncing in the UI layer.
actor OpenSkyClient {
enum ClientError: Error, LocalizedError {
case requestFailed(status: Int)
case decodingFailed(Error)
case throttled
var errorDescription: String? {
switch self {
case .requestFailed(let s): return "OpenSky HTTP \(s)"
case .decodingFailed(let e): return "Could not parse OpenSky response: \(e.localizedDescription)"
case .throttled: return "OpenSky rate limit reached. Wait a moment and retry."
}
}
}
private let session: URLSession
private var basicAuthHeader: String?
init() {
let config = URLSessionConfiguration.default
config.timeoutIntervalForRequest = 20
config.requestCachePolicy = .reloadIgnoringLocalCacheData
session = URLSession(configuration: config)
basicAuthHeader = Self.makeBasicAuth(OpenSkyCredentials.shared.load())
// Re-read credentials whenever the Settings screen saves new ones.
NotificationCenter.default.addObserver(
forName: .openSkyCredentialsChanged, object: nil, queue: nil
) { [weak self] _ in
Task { await self?.reloadCredentials() }
}
}
private func reloadCredentials() {
basicAuthHeader = Self.makeBasicAuth(OpenSkyCredentials.shared.load())
}
nonisolated private static func makeBasicAuth(_ creds: OpenSkyCredentials.Credentials?) -> String? {
guard let creds else { return nil }
let raw = "\(creds.username):\(creds.password)"
guard let data = raw.data(using: .utf8) else { return nil }
return "Basic \(data.base64EncodedString())"
}
/// Apply auth header to a URLRequest if credentials are stored.
private func applyAuth(_ request: inout URLRequest) {
if let auth = basicAuthHeader {
request.setValue(auth, forHTTPHeaderField: "Authorization")
}
}
/// Aircraft inside the given lat/lon bounding box.
///
/// Use the smallest bounding box that covers the user's visible map too
/// wide and you'll pull thousands of state vectors per call (and burn
/// quota faster).
func states(latMin: Double, lonMin: Double, latMax: Double, lonMax: Double) async throws -> [LiveAircraft] {
var comps = URLComponents(string: "https://opensky-network.org/api/states/all")!
comps.queryItems = [
URLQueryItem(name: "lamin", value: String(latMin)),
URLQueryItem(name: "lomin", value: String(lonMin)),
URLQueryItem(name: "lamax", value: String(latMax)),
URLQueryItem(name: "lomax", value: String(lonMax))
]
guard let url = comps.url else { throw ClientError.requestFailed(status: -1) }
return try await decodeStates(from: url)
}
private func decodeStates(from url: URL) async throws -> [LiveAircraft] {
var req = URLRequest(url: url)
applyAuth(&req)
req.setValue("application/json", forHTTPHeaderField: "Accept")
let (data, response) = try await session.data(for: req)
let status = (response as? HTTPURLResponse)?.statusCode ?? -1
if status == 429 { throw ClientError.throttled }
guard status == 200 else { throw ClientError.requestFailed(status: status) }
do {
let resp = try JSONDecoder().decode(StatesResponse.self, from: data)
return resp.states ?? []
} catch {
throw ClientError.decodingFailed(error)
}
}
/// In-flight track for the given aircraft sequence of (time, lat, lon,
/// alt, heading, onGround) points covering the most recent flight (or
/// current flight if it's still airborne). Used to draw the trail on
/// the map when an aircraft is selected.
func track(icao24: String) async -> AircraftTrack? {
var comps = URLComponents(string: "https://opensky-network.org/api/tracks/all")!
comps.queryItems = [
URLQueryItem(name: "icao24", value: icao24.lowercased()),
URLQueryItem(name: "time", value: "0") // 0 = current/most-recent
]
guard let url = comps.url else { return nil }
var req = URLRequest(url: url)
applyAuth(&req)
req.setValue("application/json", forHTTPHeaderField: "Accept")
do {
let (data, response) = try await session.data(for: req)
let status = (response as? HTTPURLResponse)?.statusCode ?? -1
guard status == 200 else { return nil }
return try? JSONDecoder().decode(AircraftTrack.self, from: data)
} catch {
return nil
}
}
/// Flights an aircraft has flown in the past N days.
/// OpenSky requires a `begin` and `end` window, max 30 days each.
func recentFlights(icao24: String, daysBack: Int = 7) async -> [OpenSkyFlight] {
let now = Int(Date().timeIntervalSince1970)
let begin = now - (daysBack * 86400)
var comps = URLComponents(string: "https://opensky-network.org/api/flights/aircraft")!
comps.queryItems = [
URLQueryItem(name: "icao24", value: icao24.lowercased()),
URLQueryItem(name: "begin", value: String(begin)),
URLQueryItem(name: "end", value: String(now))
]
guard let url = comps.url else { return [] }
var req = URLRequest(url: url)
applyAuth(&req)
req.setValue("application/json", forHTTPHeaderField: "Accept")
do {
let (data, response) = try await session.data(for: req)
let status = (response as? HTTPURLResponse)?.statusCode ?? -1
// 404 from OpenSky means "no flights in this window" not an error.
guard status == 200 else { return [] }
return (try? JSONDecoder().decode([OpenSkyFlight].self, from: data)) ?? []
} catch {
return []
}
}
}
// MARK: - Decoding
/// OpenSky returns each state vector as a heterogeneous array index 0 is
/// the ICAO24 hex, 1 is callsign, 2 is country, 3 is unix time of last
/// position, 4 is unix time of last contact, 5 is longitude, 6 is latitude,
/// 7 is barometric altitude in meters, 8 is on_ground bool, 9 is velocity
/// m/s, 10 is true_track degrees, 11 is vertical_rate m/s, 12 is sensors
/// (array of receiver IDs, skipped), 13 is geometric altitude meters, 14
/// is squawk, 15 is spi bool (skipped), 16 is position_source (skipped),
/// 17 is category. Most entries can be `null`.
private struct StatesResponse: Decodable {
let time: Int
let states: [LiveAircraft]?
enum CodingKeys: String, CodingKey { case time, states }
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
time = try c.decode(Int.self, forKey: .time)
let raw = try c.decodeIfPresent([RawStateVector].self, forKey: .states)
states = raw?.compactMap(LiveAircraft.from)
}
}
/// Intermediate decoder that consumes the heterogeneous array into named
/// optional fields without exploding on null values or shape drift.
private struct RawStateVector: Decodable {
let icao24: String
let callsign: String?
let originCountry: String
let timePosition: Int?
let lastContact: Int
let longitude: Double?
let latitude: Double?
let baroAltitude: Double?
let onGround: Bool
let velocity: Double?
let trueTrack: Double?
let verticalRate: Double?
let geoAltitude: Double?
let squawk: String?
let category: Int?
init(from decoder: Decoder) throws {
var c = try decoder.unkeyedContainer()
icao24 = (try? c.decode(String.self)) ?? ""
callsign = try? c.decodeIfPresent(String.self)
originCountry = (try? c.decode(String.self)) ?? ""
timePosition = try? c.decodeIfPresent(Int.self)
lastContact = (try? c.decode(Int.self)) ?? 0
longitude = try? c.decodeIfPresent(Double.self)
latitude = try? c.decodeIfPresent(Double.self)
baroAltitude = try? c.decodeIfPresent(Double.self)
onGround = (try? c.decode(Bool.self)) ?? false
velocity = try? c.decodeIfPresent(Double.self)
trueTrack = try? c.decodeIfPresent(Double.self)
verticalRate = try? c.decodeIfPresent(Double.self)
// [12] sensors array skip.
_ = try? c.decodeIfPresent([Int].self)
geoAltitude = try? c.decodeIfPresent(Double.self)
squawk = try? c.decodeIfPresent(String.self)
// [15] spi, [16] position_source skip.
_ = try? c.decode(Bool.self)
_ = try? c.decode(Int.self)
category = try? c.decodeIfPresent(Int.self)
}
}
private extension LiveAircraft {
static func from(_ raw: RawStateVector) -> LiveAircraft? {
guard let lat = raw.latitude, let lon = raw.longitude, !raw.icao24.isEmpty else {
return nil
}
return LiveAircraft(
icao24: raw.icao24,
callsign: raw.callsign,
originCountry: raw.originCountry,
latitude: lat,
longitude: lon,
baroAltitude: raw.baroAltitude,
geoAltitude: raw.geoAltitude,
velocity: raw.velocity,
trueTrack: raw.trueTrack,
verticalRate: raw.verticalRate,
onGround: raw.onGround,
squawk: raw.squawk,
category: raw.category,
lastContact: Date(timeIntervalSince1970: TimeInterval(raw.lastContact))
)
}
}