Skip to content

04 — External service integrations (Soaring Spot, WeGlide, Charron.line, SkyLines)

Moved from vulture-android/docs/analysis/04-integrations.md

Scope: everything the iOS app does to pull flights/tasks/competition data from, or push flights to, external soaring services, plus the shared networking layer. Source: /Users/fousa/Projects/vulture-ios (read-only), and — because the Soaring Spot integration is a scraping proxy — the backend in /Users/fousa/Projects/vulture-web (Sinatra/Ruby, also read-only for this analysis).

Secrets policy: this document names where credentials live (file + symbol / env var), never their values. Several secrets are hard-coded in iOS source; see §8 “Secrets inventory”.


ServiceTransportAuthUser can…Android complexity
Soaring SpotJSON from our own backend https://vulture.aero/api/soaringspot/*, which scrapes soaringspot.com with Nokogiri. IGC files fetched directly from soaringspot.com.Static shared token in Authorization header (no scheme) + mandatory Accept: application/json.Browse competitions (featured / by country+year, paged), recents, competition → classes → days, day scoreboard (search, multi-select, open, Replay Day with race ranking), day task (open on map, Glide & Seek), pilot list. Deep links.Medium-high (most UI; no HTML parsing on device).
WeGlideREST JSON https://api.weglide.org/v1/User-pasted API key (X-API-Key), plus user id + date of birth stored for uploads. No OAuth in use (legacy OAuth constants are dead).List own flights (paged), download IGC, upload IGC (+ auto comment “Uploaded with Vulture”), refresh a WeGlide-sourced task.Low-medium.
Charron.line (Belgian league)Season-scoped PHP site https://www.charronline.be/charronline/<season>/… — JSON for days/flights, cookie session + form POST for login, multipart upload completed inside a WebView.Username/password form login (gebruikersnaam/paswoord), session cookie.Browse seasons → months → days → ranked flights, open/replay, upload own flight (form + WebView).Medium (cookie hand-off to WebView).
SkyLinesREST JSON https://skylines.aero/api/HTTP Basic auth with stored username/password; user id resolved via GET settings.List own/latest/daily flights (paged), download IGC, upload IGC (+ optional publish).Low.

Out of scope for phase 1 (noted §7): FLARM (Wi-Fi http://10.10.10.10/api) & Bluetooth loggers, SkySight, AVWX/sunrise-sunset weather, elevation API, remote file catalogue.


1. Shared networking layer — Packages/Networking

Section titled “1. Shared networking layer — Packages/Networking”

Files: Sources/Networking/{Configuration,Service,NetworkService,Request,Request+URLRequest,Serializer,ResponseError,FailureReason,Interceptor,Logger,PublicKeyPinningService}.swift.

ConceptiOS typeBehaviour to replicate
Per-API configprotocol ConfigurationbaseURL?, publicKeys? (SPKI pins per host), loggers?, headers(for:) (shared headers, evaluated per request — WeGlide/SkyLines read the Keychain each call), alter(URLSessionConfiguration) (Charron enables cookies).
Clientclass ServiceOne URLSession per Service instance (URLSessionConfiguration.default → 60 s request timeout, default URLCache, system User-Agent Vulture/<build> CFNetwork/… Darwin/…). Completion is delivered on the caller’s queue. Returns the URLSessionDataTask so callers can cancel (Soaring Spot list cancels the previous page fetch).
Requestprotocol Requesturl (relative → baseURL.appendingPathComponent(path), absolute used as-is; any query already in the URL is preserved), method, query (appended), headers (merged over config headers), body (Data raw, otherwise JSONSerialization of a dictionary), cachePolicy (default useProtocolCachePolicy), isInterceptable.
SerializerCodableSerializer<T>Transport error → failure; status ≥ 400 → ResponseError (with body message); empty body → success(nil) (callers map to []); else JSON decode (decode error → failure).
ErrorsResponseError400 badRequest, 401 unauthorized, 403 forbidden, 404 notFound, 405–499 clientError(code), 500 internalServerError, 503 serviceUnavailable, other ≥500 serverError(code), plus .service(code, message) when the body explains itself. Body message extraction: top-level JSON keys error, message, detail (string), or nested object under those keys with error_description/error/message. Description format "[<code>] <text>" — call sites match "[406]" (WeGlide upload).
UI error classificationFailureReasonoffline / unreachable / serviceTrouble (5xx) / refused (401,403) / notFound / unreadable (decode, bad response) / explained(String) / unknown. Screens localise the reason themselves.
RetryInterceptorHook for token refresh; only implementation (WeGlideInterceptor) is a no-op. No automatic retry anywhere in the API layer.
LoggingConsoleLoggeros_log method+URL before, status+bytes after, and the first 400 chars of a failing body.
PinningPublicKeyPinningServiceSPKI SHA-256 base64 pins per host; any cert in chain matching passes; host without pins → default trust. Effectively unused: Vulture backend pin removed (Let’s Encrypt rotation outages, see comment in Packages/Backend/Sources/Backend/VultureServiceConfiguration.swift); WeGlide pins host weglide.org but requests go to api.weglide.org, so the pin never matches a challenge host and is never applied. SkyLines/Charron: none.

1.2 Raw IGC download path (not via Service)

Section titled “1.2 Raw IGC download path (not via Service)”

Packages/RemoteContent/Sources/RemoteContent/FlightDownload/FlightDownloadItem.swift — URL.download():

  • URLSession.shared.dataTask (blocking with a semaphore on a background operation), no auth headers.
  • Soaring Spot rate-limit detection: Soaring Spot answers throttling with HTTP 200 and a short HTML page. If body < 4096 bytes and its first line contains “too many requests” (case-insensitive) → wait 2^attempt s (2, 4, 8) and retry, max 3 retries, then hand the page to the decoder (which fails as invalid IGC).
  • Logs status, MIME type, final URL after redirects, and the first line of the body.
  • Decoded with FlightDecoder (IGC parser, see analysis doc on flight parsing).
  • Batch downloads (Modules/App/Sources/Map/Flight/FlightMapViewModel.swift add(items:)): OperationQueue with max 3 concurrent downloads; results kept in input order; per-item failures reported, others continue; without Pro only the first item is fetched.
  • FlightDownloadSource = .url(URL, rank: Int?) or .weGlide(id:) — used for hand-off/state restoration.
  • Item types: URL, RankedFlightDownloadItem(url, rank) (Soaring Spot), ImportFlightDownloadType.weGlide(id) (resolve file URL via WeGlide API first, filename weglide<id>.igc).
  • Recommendation: Retrofit 2 + OkHttp 4 + kotlinx.serialization converter (or Ktor client with OkHttp engine — either works; Retrofit gives the cleanest declarative endpoints for 4 small APIs and OkHttp gives Interceptors, CookieJar, CertificatePinner, caching and MockWebServer). Use coroutines (suspend functions) instead of callbacks.
  • One OkHttpClient base (shared connection pool) with newBuilder() per service:
    • VultureAuthInterceptor (adds Accept: application/json + Authorization: <token>).
    • WeGlideApiKeyInterceptor (reads key from encrypted store per request, adds X-API-Key only if present).
    • SkyLines: OkHttp Authenticator is wrong here (iOS sends Basic pre-emptively) → use an interceptor adding Authorization: Basic base64(user:pass) when creds exist.
    • Charron: JavaNetCookieJar(CookieManager) or a custom CookieJar backed by android.webkit.CookieManager so the WebView sees the session (see §4).
    • HttpLoggingInterceptor at BASIC in debug, plus an error-body logger mirroring ConsoleLogger (first 400 chars).
    • Timeouts: connect/read 60 s to match URLSessionConfiguration.default.
    • User-Agent: set explicitly Vulture-Android/<versionName> (<build>) — iOS relies on the CFNetwork default; the backend/services do not check it, but Soaring Spot rate limiting might be friendlier to an identifiable UA.
    • Cache: OkHttp Cache (10–20 MB) to approximate useProtocolCachePolicy; the backend sends no cache headers today, so effectively no caching — same as iOS.
  • sealed interface ApiError mirroring ResponseError with the same body-message extraction (port message(in:) exactly, incl. nested FastAPI detail), and a FailureReason.from(Throwable) mapping IOException subclasses: UnknownHostException/ConnectException/SocketTimeoutException/SSLException → unreachable; no network (ConnectivityManager) → offline; SerializationException → unreadable.
  • Empty-body-is-success: Retrofit Response<T?> / a converter that returns null on contentLength == 0. Callers map null → empty list.
  • Pinning: do not pin (mirror iOS reality). If wanted later, OkHttp CertificatePinner with intermediate/root pins only.
  • IGC downloader: plain OkHttpClient call, same rate-limit page heuristic & backoff, Semaphore(3)/Dispatchers.IO.limitedParallelism(3) for batches.

LayeriOS path
Models + requestsPackages/SoaringSpot/Sources/SoaringSpot/{Models,Requests,Extensions}/*.swift
Backend config (base URL, token)Packages/Backend/Sources/Backend/VultureServiceConfiguration.swift
Providers (bridge to service + Core Data recents)Packages/CompetitionScreens/Sources/CompetitionScreens/Hosting/SoaringSpot/*Provider.swift
View models / SwiftUI viewsPackages/CompetitionScreens/Sources/CompetitionScreens/SoaringSpot/{List,Competition,Day,Task,Filter,Pilots}/
UIKit hosting / navigation…/Hosting/SoaringSpot/*ViewController.swift
App wiringModules/App/Sources/Shell/Split/SplitViewController.swift (serviceController(for:), openServiceFlights, openServiceTask, showSoaringSpot)
Deep linksModules/App/Sources/Application/SceneDelegate+Routing.swift, SoaringSpotDayLink.swift, URL+SoaringSpot.swift
Task refreshModules/App/Sources/Map/Task/TaskMapViewModel.swift (updateTask, updateSoaringSpotTask)
App Clip (read-only scoreboard)Modules/Clip/Sources/Features/Competition/Providers/Clip*Provider.swift
Scraper (server)vulture-web/app/api/soaring_spot.rb, soaring_spot_flights.rb, soaring_spot_tasks.rb, open.rb, helpers in vulture-web/app/helpers/

2.2 User flow (phone; iPad/Mac add split view)

Section titled “2.2 User flow (phone; iPad/Mac add split view)”
  1. Sidebar → Discover → Soaring Spot (DiscoverService.soaringSpot, raw value "soaringspot" persisted as detail destination).
  2. Catalogue screen (SoaringSpotListView). Title “Soaring Spot”, subtitle = current selection (Competitions / Recent) and, when filtered, "<flag> <year>". Filter bar button (menu) offers:
    • Selection: Competitions (server) / Recent (local DB, most-recently-opened first, swipe to delete, local text search over name, location, country code & localized country name, date string). Selection persisted (Defaults.soaringSpotSelection, Int 0/1).
    • “Filter…” (competitions only) → filter sheet: countries (from GET countries, sorted by name; name is an ISO-2 code rendered as flag) and years (2003…current year, descending). Each tap applies immediately; “Clear” when anything set. Default label when unfiltered: “latest” (soaringspot.competitions.latest).
    • Competitions list: groups (unfiltered home page has named groups “Competitions in progress”, “Recent…”, “Upcoming competitions”; search results have one unnamed group). Infinite scroll: next_page of the first group; subsequent pages append into group 0. Row: name, location + flag, start – end (medium date). Full-screen error state with retry; empty placeholder.
  3. Competition screen (SoaringSpotCompetitionView): fetch GET competitions/{id}; title follows server name; saved to Recents on success (only if name present). Shows each class as a section with a grid of day tiles (weekday/short day + day name; cancelled → strikethrough; day without id → dimmed, not tappable). Placeholder when no class has days. “Pilots” button → sheet.
  4. Day scoreboard (SoaringSpotDayView): GET …/flights/{class}/{day}; prefetches the day task in parallel. Rows: rank, competition id, pilot + country flag, glider, start/finish time, speed (parsed leading number “114.48 km/h” → user speed unit, whole number), distance (“138.90 km” → user distance unit, 1 decimal), points. Search over all those fields. Tap = open that one flight; Select mode = multi-select (only rows with link), Select All, Open (n). Replay Day (≥2 downloadable flights) = open all flights with race ranking shown. Toolbar “Show task” → task sheet.
  5. Task sheet (SoaringSpotTaskView): turnpoint list + distance text; Open task (→ task map with ExternalTask), Glide and Seek (opens https://glideandseek.com/?taskOneUrl=http://www.soaringspot.com/en/{competitionId}/tasks/{dayId} in an in-app browser).
  6. Opening flights: items are RankedFlightDownloadItem(url: link, rank: Int(digits of rank)) (“1.” → 1, “HC” → nil). Map downloads them (§1.2), sorts by rank and re-assigns palette colours by rank when every flight has a rank, applies the prefetched day task automatically, and if showsRanking opens the race-ranking panel once the ranking can be computed. Opening >1 flight requires Pro (ProFeature.severalFlights).
  7. Pilots sheet: GET competitions/{id}/pilots — CN, name, club, glider, class, handicap; empty body → empty list.

Analytics event openSoaringSpotCompetition on competition tap.

2.3 Endpoints (all on the Vulture backend)

Section titled “2.3 Endpoints (all on the Vulture backend)”

Base URL: https://vulture.aero/api/. Required headers on every call:

HeaderValueServer check (vulture-web/app/helpers/api_auth.rb)
Acceptapplication/json — must be the first media typeelse 406
Authorizationraw token, no Bearer prefix. iOS: hard-coded literal in VultureServiceConfiguration.headers(for:) (also duplicated in Packages/Flights/Sources/Flights/Service/ElevationServiceConfiguration.swift). Server: env var API_TOKEN.else 403

No pagination headers, no rate limiting on our side; every call triggers a live scrape of soaringspot.com (String#load_html = Nokogiri::HTML(URI.open(url)), no server cache, no custom UA). Latency is therefore Soaring Spot’s.

#Method & pathQueryResponse (JSON)iOS request / decoder
1GET soaringspot/countries–[{"name":"FR"}, …] (ISO-2; “XD” dropped)SoaringSpotCountriesRequest, default decoder
2GET soaringspot/competitionscountry (ISO-2), year (Int), page (Int) — all optional. If neither country nor year → server scrapes the home page and ignores page.[{"name"?: String, "next_page"?: Int, "competitions":[Competition]}]SoaringSpotCompetitionsRequest; dates yyyy-MM-dd UTC (Date.Formatter.Vulture.date)
3GET soaringspot/competitions/{id}–Competition incl. classes:[{name, days:[Day]}]SoaringSpotCompetitionRequest; dates yyyy-MM-dd
4GET soaringspot/competitions/{id}/pilots–[{"competition_number"?, "name", "club"?, "glider"?, "class_name"?, "handicap"?}] (blank cells omitted)SoaringSpotCompetitionPilotsRequest
5GET soaringspot/competitions/{id}/flights/{class}/{day}–[Flight]SoaringSpotCompetitionDayRequest(type: .flights); {dayId} = "{class}/{day}" with a literal slash — do not percent-encode it (use @Path(value="dayId", encoded=true) or two path params).
6GET soaringspot/competitions/{id}/task/{class}/{day}–TaskSoaringSpotCompetitionDayRequest(type: .task); default decoder (no dates)
7GET open?url=<soaringspot url>url{"competition": Competition}; 406 for non-soaringspot host, login page, or missing idSoaringSpotOpenRequest — defined but unused in the app. Skip on Android.
// Competition (list rows omit "classes")
{ "id": "jwgc2026", "name": "…", "location": "Leeton", "country": "AU",
"start_date": "2021-01-09", "end_date": "2021-01-15",
"classes": [ { "name": "Club", "days": [Day] } ] }
// Note: iOS decoder requires "name" (decode, not decodeIfPresent) although the property is optional.
// Day
{ "id": "club/task-4-on-2026-08-06", // optional: absent → day has no results/task
"name": "Task 4", "status": "valid|practice|cancelled|notask", "date": "2026-08-06" }
// Flight (results row) — all strings as scraped
{ "rank": "1.", "competition_id": "UC", "name": "Keith Essex", "country": "GB",
"glider": "ASG 32", "start_time": "14:36:58", "end_time": "18:06:41",
"speed": "129.47 km/h", "distance": "452.52 km", "points": "1,000",
"link": "https://soaringspot.com/en_gb/download-contest-flight/3355-6933184580?dl=1",
"warning": "Exceeded buffer by 80 feet (500 penalty points)" } // "warning" is NOT decoded by iOS
// Server drops keys whose value is "", "0" or "0.00 km" (String#compact).
// Task
{ "name": "Task 9, Open, 10 January 2021", "is_aat": false, "aat_minimum_duration": 7200,
"distance": "452.52 km", "version": "v1", "notes": "…", "inactive_airspace": "…",
"waypoints": [ { "index": 0, "name": "301-Buscot", "latitude": -44.4, "longitude": 170.07,
"type": "next|previous|symmetric|fixed|start", "is_line": false,
"maximum_altitude": 2133, "r1": 5000, "r2": 0, "a1": 180, "a2": 0, "a12": 0 } ] }

Server-side scraping rules (for reference / for a future on-device fallback)

Section titled “Server-side scraping rules (for reference / for a future on-device fallback)”

All on https://www.soaringspot.com/en_gb/… (Nokogiri CSS → Jsoup selectors are near-identical):

EndpointSource pageSelectors / rules
countries/en_gb/searchul.list-group:first li>a → href query param country; skip XD.
competitions/en_gb/ or /en_gb/search?country=&year=&page=next page: .pagination li:nth-child(3) a[href] → page param. Groups: .contest-list; group name h2:first (home page only). Items li .contest: id = h3 a:first[href] regex /en_gb/(.*)/; name = its text; .info span:first text split by , → last = dates "start – end" (split ' –', parsed with Chronic natural-language → yyyy-MM-dd), next-to-last = country name → ISO-2 via iso_country_codes (overrides: “Czech Republic”→CZ, “Bosnia & Herzegovina”→BA), rest = location.
competition/en_gb/{id}/results.contest-title h1:first name; .contest-title .location split , → location, country name; .contest-title .date split ' –'. Classes .result-overview: class name thead th:first; rows tbody tr (skip rows with a single td): id = td:nth-child(2) a:first[href] regex .*/tasks/(.*); date = td:nth-child(1) (Date.parse); status = td:nth-child(2)[class] (cancelled→cancelled, no-task→notask, else valid), if nil → tr[class]=="practice"→practice else valid; name = td:nth-child(2) a text, "No task" when notask.
pilots/en_gb/{id}/pilotstable.pilot first; header texts lower-cased mapped cn, contestant, club, glider, class, handicap; rows whose cell count ≠ header count skipped; blank cells omitted; rows without name dropped.
flights/en_gb/{id}/results/{class}/{day}/dailyrows .result-daily tbody tr; header text detects optional Team, Club (+1 column offset each) and Handicap (+1 more for time/score columns). rank td1, CN td3, link = td3 a:first[data-content] parsed as HTML → a:last[href] (made absolute with https://soaringspot.com), name td4, country td.contestant .flag:first[title] upper-cased, glider td(5+off), start td(6+off2), finish td(7+off2), speed td(9+off2), distance td(10+off2), points td(11+off2), warning td(11+off2) i:first[data-content].
task/en_gb/{id}/tasks/{class}/{day}name .title-action h2; distance .task tfoot td (strip whitespace and “Total:”); version .task-version span strong; notes = first h3’s next sibling if it is a <p>; inactive airspace .task-excluded-airspaces span strong; waypoints from inline <script>: regex Map\.SoaringSpot\.taskNormalize\((.*), \[ → JSON with task_type (assigned_area ⇒ AAT), task_duration (s, 0→null), task_points[] {name, latitude/longitude (radians → degrees), oz_type, oz_line, oz_radius1, oz_radius2, oz_angle1/oz_angle2 (radians→degrees, rounded), oz_angle12 (only for fixed, else 0), oz_max_altitude (0→null)}.

Recommendation: Android keeps using the backend exactly like iOS (identical behaviour, one parser to maintain, no Jsoup on device). Jsoup is only needed if we ever add an offline/“direct” fallback; keep the table above as its spec.

  • SoaringSpotTask.raw → Waypoints.Task(name, minimumAATDuration, waypoints, notes, inactiveAirspace). Each SoaringSpotWaypoint.raw → Waypoint(name, coordinate, order=index, zone) where a zone exists only if type ∈ {fixed, symmetric, next, previous, start} (Packages/Waypoints/Sources/Waypoints/Models/ZoneType+Text.swift); zone.isLine = is_line ?? false, R1/R2/A1/A2/A12 = value ?? 0, maximumAltitude ?? 0, and zone.isAAT = task.is_aat for every waypoint.
  • ExternalTask(task, externalVersion = task.version, externalName = competition.name, externalURL = <relative request URL "soaringspot/competitions/{id}/task/{class}/{day}">). The URL string prefix soaringspot marks it as a Soaring Spot task (ExternalTaskType, logo in lists, edit disabled for external tasks — TaskMenuBuilder).
  • Persisting: Waypoints.Task.createTask(externalURL:externalName:externalVersion:) upserts by externalURLString (Packages/Persistence/Sources/Persistence/Extensions/CoreData/Task+CoreData.swift).
  • Update task (task map): re-GET the stored relative URL against the backend, replace waypoints/name/notes/inactive airspace, store new version. Keep the relative-URL convention on Android so imported/synced tasks stay compatible.
  • Flight → map: RankedFlightDownloadItem(url = link, rank = Int(rank.filter(isDigit))); race ranking ordering is by that rank (see §2.2 step 6). Race ranking math itself belongs to the flight-analysis doc.
  • Recents: Core Data RecentCompetition {id, name, location, country, startDate, endDate, lastOpenedDate}; upsert by id, bump lastOpenedDate. → Room entity recent_competition.
Section titled “2.5 Deep links (universal links on https://vulture.aero)”
PathAction
/open/soaringspot/{competitionId}Open Soaring Spot → competition.
/open/soaringspot/results/{competitionId}[/{class}/{day}]Open competition; if class+day present, auto-push that day once classes load (openingDayId, matched against Day.id).
/open/soaringspot/flight/{flightId}Download https://www.soaringspot.com/en_gb/download-contest-flight/{flightId}?dl=1 and open it.
/open/charronline/igc/{year}/{file}Download https://www.charronline.be/charronline/igc/{year}/{file} and open it.
/open/flight?id=…local logbook (not an integration).

These links are produced by the Safari/Chrome extensions (Modules/Safari/Sources/Web/soaringspot_daily.js, charron.js). Android: declare App Links with autoVerify for vulture.aero path prefixes /open/; requires adding the Android package + signing cert SHA-256 to https://vulture.aero/.well-known/assetlinks.json (served by vulture-web).

  • All Soaring Spot fetch failures → full-screen error state with retry (list, competition, day); task & filter & pilots use an alert with Retry/Cancel.
  • A page fetch arriving after the user switched to Recents is ignored for the error state.
  • Starting a new catalogue fetch cancels the in-flight one.
  • Day fetch failure clears flights, selection and select mode.
  • Day without id → not tappable. Flight without link → not selectable/openable.
  • IGC download rate-limit retries (§1.2), max 3 concurrent.
  • Clip variant: read-only scoreboard (no selection, no task prefetch). Android analogue would be an Instant App — not needed for phase 1.

Packages/WeGlide/Sources/WeGlide/**, Packages/ImportScreens/Sources/ImportScreens/Hosting/ImportFlights/* (list/download), Modules/App/Sources/Support/Upload/WeGlide/WeGlideUploadViewModel.swift + UIViewController+Weglide.swift (upload), Packages/SettingsScreens/Sources/SettingsScreens/{Services/WeGlide,Hosting/AppWeGlideAuth.swift} (credentials), Packages/TaskScreens/Sources/TaskScreens/Hosting/ImportTasks/* (task import — not reachable from the app today), Modules/App/Sources/Map/Task/TaskMapViewModel.swift (task refresh), Modules/App/Sources/Services/ConnectedServices.swift (gating).

  • No OAuth. The user pastes an API key (weglide.org → Profile → Settings → Advanced), their user ID (number in their profile URL) and optionally date of birth (yyyy-MM-dd, default picker value 1990-01-01). Stored in Keychain keys VultureWeGlideAPIKey, VultureWeGlideUserId, VultureWeGlideDateOfBirth (Keys+WeGlide.swift). Blank/whitespace = absent. Saving does not validate against the API.
  • Legacy: Vulture.WeGlide.clientId/clientSecret/redirectURL (Packages/Preferences/Sources/Preferences/Vulture.swift) and Keychain weGlideAccessToken/RefreshToken/Name are remnants of an old OAuth flow — only cleared on sign-out. Do not port. WeGlide grants OAuth only to approved partners; the photo/story endpoint requires it, which is why photo upload is disabled (canUploadPhotos == false).
  • WeGlideCapabilities (WeGlideCapability.swift) — port verbatim:
CapabilityRequires
downloadFlight, downloadTaskapiKey + userId
uploadFlightapiKey + userId + dateOfBirth

Settings shows each capability as available/locked with “missing X, Y and Z” note. Upload menu item shown only if uploadFlight available. The WeGlide browser shows a “connect” prompt (→ settings) when no API key.

Base https://api.weglide.org/v1/. Header X-API-Key: <key> on every request when a key is stored. JSON dates decoded with ISO-8601 (JSONDecoder.dateDecodingStrategy = .iso8601, which requires a timezone designator — on Android use Instant/OffsetDateTime parsing tolerant of both Z and offsets, and consider accepting a missing offset as UTC to be safe).

PurposeMethod & pathParamsResponse fields usedUsed?
Pilot’s flights (“Your”)GET flightuser_id_in=<userId>&skip&limit=100&order_by=-scoring_date[{id, user.name, aircraft.name?, takeoff_airport?{name?, country?}, takeoff_time, contest?{distance (km), speed (km/h)}}]Yes (only scope offered for WeGlide)
Flights of a dateGET flightscoring_date_in=<YYYY-MM-dd>&skip&limit&order_by=-scoring_datesamecode only
LatestGET flightskip&limit&order_by=-createdsamecode only
Feed (friends)GET flight/feedskip&limit&order_by=-createdsamecode only
Flight detail → IGCGET flightdetail/{id}–igc_file.file (path) → IGC URL https://weglidefiles.b-cdn.net/<path>Yes (on open)
Upload IGCPOST igcfilemultipart (below)[{id}] → first idYes
Set commentPATCH flightdetail/{id}JSON {"comment":"Uploaded with Vulture"}ignoredYes (after upload)
Upload photoPOST storymultipart image (jpeg, EXIF preserved) + flight_idignoredDisabled (needs OAuth)
Task by idGET task/{id}–WeGlideTaskYes (task refresh)
Pilot’s tasks / starred / exploreGET taskuser_id_in or starred_by + skip&limit=50; explore sends no query (bug: no paging)[WeGlideTask]Not wired in UI

Multipart for POST igcfile (boundary __Vulture__, CRLF line breaks):

--__Vulture__
Content-Disposition: form-data; name="file"; filename=flight.igc
Content-Type: application/octet-stream; charset=utf-8
<IGC text>
--__Vulture__
Content-Disposition: form-data; name="user_id"
<userId> (omitted if empty)
--__Vulture__
Content-Disposition: form-data; name="date_of_birth"
<yyyy-MM-dd> (omitted if empty)
--__Vulture__--

Request Content-Type: multipart/form-data; charset=utf-8; boundary=__Vulture__. On Android use OkHttp MultipartBody (boundary may differ — server does not care).

Pagination: skip/limit (100 for flights, 50 for tasks); “has more” = page returned exactly limit items.

Error handling: upload failures show the server’s explanation (e.g. 400 {"error":"Date of birth not correct"} → “[400] Date of birth not correct”); a [406] description is replaced by weglide.labels.upload.flight.error (duplicate/unsupported flight). Upload is a modal progress alert: “uploading flight” → “updating comment” → done/failed; retry only offered for local UploadErrors.

  • WeGlideFlight → ImportFlightItem row: id, takeoffTime, pilotName, aircraftType, takeoffLocation, takeoffCountry, distance (km, default 0), speed (km/h, default 0); downloadType = .weGlide(id).
  • WeGlideTask (point_features = GeoJSON features): coordinates [lon, lat]; name null/""/“Unnamed TP” → Waypoint.defaultName(order:); every zone type = "symmetric"; kind: CYLINDER → R1 = radius·1000 m, A1 = 180°; LINE → R1 = radius·1000, A1 = radial; else (sector) → R1 = 3000 m, A1 = (angle ?? 10)/2. isLine always false (note: lines are modelled as a symmetric sector with A1=radial — preserve for parity). ExternalTask(externalURL = "weglide/<id>", version/name nil).

4. Charron.line (Belgian national cross-country league)

Section titled “4. Charron.line (Belgian national cross-country league)”

Packages/Charron/Sources/Charron/**; list/day screens Packages/CompetitionScreens/Sources/CompetitionScreens/{Charron,Hosting/Charron}/*; upload form …/CharronUpload/* + Modules/App/Sources/Features/Competition/CharronUpload/AppCharronUploadProvider.swift; auth Packages/SettingsScreens/Sources/SettingsScreens/Hosting/AppCharronAuth.swift; WebView Packages/DesignSystem/Sources/DesignSystem/{Hosting/WebsiteHostingController.swift,Screens/Website/WebsiteView.swift}; bundled lists Modules/App/Resources/Data/charron_gliders.json, charron_competitions.json ([{"name","id"}]).

A Charron season is named after the year it ends: flights in October–December belong to year + 1 (date.month > 9). The season picker lists 2010…latest, where latest = year + 1 from September (CharronViewModel.latestSeasonYear: month >= 9) — an inconsistency (Sept flights are requested in the current-year season while the picker already offers next year). Mirror both rules exactly, or fix deliberately on both platforms.

Base https://www.charronline.be/charronline/. Session config: shared cookie storage, accept all cookies.

PurposeMethod & pathParams / bodyResponse
Days of a seasonGET {season}/days.phpheader Accept: application/json[{"date":"dd-MM-yy", "flights":"12"}] — date parsed dd-MM-yy UTC, unparseable → isValid=false; flights string → Int (default 0).
Flights of a dayGET {season(date)}/flights.php?day=<YYYY-MM-dd>Accept: application/json[{"pilot","club","glider","start","score","task","igc"}]; start is an ICAO code resolved via bundled airport DB → airport name + country ISO ("ZZZZ" = unknown, kept raw).
LoginPOST {season(date)}/login.phpapplication/x-www-form-urlencoded; charset=utf-8, body gebruikersnaam=<u>&paswoord=<p>HTML; success iff final URL after redirects ends with overzicht.php (WebsiteSerializer), else CharronError.invalid (“invalid credentials”). Sets session cookie.
UploadPOST {season(date)}/invoer2.phpmultipart (boundary __Vulture__): file1 (filename = IGC filename, octet-stream), immatriculatie (registration, upper-cased), motorzwever J/N, wedstrijd J/N (competition chosen?), wedstrijdafstand (only if competition), igc=1, typeid (glider list id), MAX_FILE_SIZE=3984000, action=1. (copiloot supported but always nil.)Not executed by the API client: the built URLRequest (with body) is loaded in a WKWebView after copying HTTPCookieStorage.shared cookies into WKWebsiteDataStore. The user sees Charron’s own result page and closes the sheet.
Open IGCGET absolute igc URL / https://www.charronline.be/charronline/igc/{year}/{file}–IGC text

Notes: YYYY-MM-dd uses Date.reversedString whose formatter is "YYYY-MM-dd" (week-based year — wrong for dates around New Year, e.g. 2024-12-30 → “2025-12-30”). Same bug affects WeGlide scoring_date_in and SkyLines flights/date/… (both code paths unused/visible respectively). Android should use yyyy-MM-dd (DateTimeFormatter.ISO_LOCAL_DATE); flag as a deliberate divergence/fix to back-port.

The chosen competition’s id is never sent (only wedstrijd=J + distance) — likely a latent bug; confirm with the Charron form before porting.

  • Browse: Discover → Charron.line → season (persisted Defaults.charronSelection) → months (only valid days, grouped by month name) → day cells (date + flight count) → day scoreboard: flights sorted by Double(score) desc, rank = position; search over pilot, glider, club, start, task, score, country; tap/select/open; Replay Day when >1 flight (no source rank → map ranks by its own race ranking). Analytics openCharronCompetitions.
  • Sign in (Settings → Services → Charron): store creds → POST login (season of today) → success keeps them, failure deletes them.
  • Upload (flight menu, requires Pro uploads and stored creds): form prefilled with registration (upper-cased) and fuzzy-matched glider type (Fuse, weight 0.3 on name) from bundled glider list; motor-glider toggle; optional competition + distance; mandatory acknowledgement checkbox; Save enabled when acknowledged ∧ registration ∧ glider. Save → login → build upload request → push WebView.
  • Use a CookieJar backed by android.webkit.CookieManager (or copy cookies into it before loading) so the login session from OkHttp is visible to the WebView.
  • WebView cannot loadUrl with an arbitrary multipart body; WebView.postUrl(url, bytes) sends application/x-www-form-urlencoded only. Options: (a) perform the multipart POST with OkHttp and loadDataWithBaseURL(finalUrl, html, "text/html", "utf-8", finalUrl) to show the returned page (relative links keep working via the base URL, cookies shared); (b) intercept with shouldInterceptRequest. Recommend (a).
  • Ship the two JSON lists as assets; use a small fuzzy matcher (e.g. port Fuse’s bitap scoring or use me.xdrop:fuzzywuzzy) — verify the same top match for common glider names with a parity test.

Base https://skylines.aero/api/ (iOS base https://skylines.aero/api + path component). Header Authorization: Basic base64("<username>:<password>") added to every request when both are stored (Keychain VultureSkyLinesUsername, VultureSkyLinesPassword; user id in VultureSkyLinesUserId).

PurposeMethod & pathParamsResponse
Ping / resolve userGET settingsContent-Type: application/json{"id": Int, …} → store user id
Pilot flightsGET flights/pilot/{userId}page = skip/limit + 1 (limit 100){"flights":[SkyLinesFlight]}
Flights of dateGET flights/date/{YYYY-MM-dd}pagesame
LatestGET flights/latestpagesame
UploadPOST flights/uploadmultipart: files (filename = flight filename, octet-stream, IGC text) + pilotId (if stored){"results":[{"status": Int, "flight":{"id": Int}}]}
PublishPOST flights/{id}JSON {"privacyLevel": 0}ignored

SkyLinesFlight decoding: id; pilot name from pilot.name or top-level pilotName; aircraft model.name?; IGC URL https://skylines.aero/files/ + igcFile.filename; takeoff takeoffAirport{name, countryCode}?; takeoffTime ISO-8601, fallback: date part before T parsed yyyyy-MM-dd, fallback now; distance m → km (/1000); speed m/s → km/h (×3.6).

Upload status enum (Int): 0 success, 1 duplicate (both treated as success), 2 missingDate, 3 parserError, 4 noFlightFound, 5 flightInFuture → localized UploadErrors (Modules/App/Sources/Support/Upload/UploadError.swift). After success, if Defaults.skyLinesShouldPublish → publish call. Retry button offered only for transport/API errors (not for UploadError) — opposite of WeGlide’s rule; mirror per service.

  • Settings → SkyLines: username/password + “publish after upload” toggle; Sign in = store creds → ping → store id; failure clears all three.
  • Discover → SkyLines browser: scopes Your (only if connected: user+pass+id), Latest, Daily (date picker). Rows like WeGlide. Tap/select → download IGC URL directly (no auth) → map.
  • Flight menu → Upload to SkyLines (Pro uploads, connected): decode stored IGC first (fails → skylines.labels.upload.error), upload, publish.

6. Shared “import flights” & upload plumbing

Section titled “6. Shared “import flights” & upload plumbing”
ConcerniOSAndroid
Browser for WeGlide/SkyLinesImportFlightsViewController<Flight> + ImportFlightsViewModel (Packages/ImportScreens) — scope menu, infinite scroll (limit 100, has-more = page full), connect prompt, select/open, (photo upload hidden).One ServiceFlightsScreen Compose screen parameterised by a RemoteFlightSource interface (scopes, suspend fun page(scope, date, skip, limit)). Paging 3 optional; a hand-rolled skip/limit loader keeps exact parity.
Opening many flightsPro gate severalFlights; max 3 concurrent downloads; ranks sort + recolour.Same; use a FlightDownloadQueue in the map feature.
Upload gatingPro uploads + ConnectedServices (credentials ladder) cached until servicesDidChange.ConnectedServicesRepository exposing a StateFlow recomputed on credential changes.
Credential storageiOS Keychain (generic password, service = bundle id, not iCloud-synced).Jetpack DataStore + Tink Aead (Android Keystore master key) — EncryptedSharedPreferences is deprecated. Exclude from Auto Backup (dataExtractionRules) to match non-synced Keychain.
Remembered UI stateDefaults.soaringSpotSelection, charronSelection, skyLinesShouldPublish, detailDestinationPreferences DataStore.
RecentsCore Data RecentCompetitionRoom.

7. Out of scope for phase 1 (found during the sweep)

Section titled “7. Out of scope for phase 1 (found during the sweep)”
IntegrationWhereNote
FLARM device API (Wi-Fi)Packages/Flarm/Sources/Flarm/Core/FlarmServiceConfiguration.swift → http://10.10.10.10/apiDownload flights from / send data to a FLARM device on its own hotspot; needs cleartext-traffic exception on Android.
Bluetooth loggersPackages/Bluetooth, Packages/ImportScreens/.../Bluetooth/*BLE flight download/send.
SkySightPackages/SkySight → https://skysight.io/api, tiles https://satellite.skysight.io/tilesForecast overlays; Pro.
Aviation weatherPackages/AviationWeather → https://avwx.rest/api/{metar,taf,station/near}, https://api.sunrise-sunset.org/jsonWidgets/weather screens.
ElevationPackages/Flights/Sources/Flights/Service/ElevationServiceConfiguration.swift → https://vulture.aero/api/elevations (same backend token)Ground elevation for analysis — probably needed by the flight-analysis port; same auth as §2.3.
Remote file cataloguePackages/RemoteContent, ImportScreens/.../RemoteFilesAirspace/waypoint downloads.
Backend extras not used by iOS herevulture-web/app/api/{sgp,sgp_flights,xcsoar,open_aip}.rbSGP (Sailplane Grand Prix) endpoints exist server-side but no iOS client was found.

SecretiOS locationRecommended Android handling
Vulture backend API tokenLiteral in Packages/Backend/Sources/Backend/VultureServiceConfiguration.swift headers(for:) and in ElevationServiceConfiguration.swift; server env API_TOKEN (vulture-web/app/helpers/api_auth.rb)Inject via Gradle property (local.properties/CI secret, e.g. VULTURE_API_TOKEN) → BuildConfig field; never commit. It ships in the APK regardless (as on iOS) — treat as an app identifier, not a secret. Consider a separate Android token so it can be rotated independently.
Legacy WeGlide OAuth client id/secretPackages/Preferences/Sources/Preferences/Vulture.swift Vulture.WeGlideDo not port (unused).
RevenueCat keysame file purchasesAPIKeyOut of scope here.
User credentials (WeGlide key/id/DOB, SkyLines user/pass/id, Charron user/pass)Keychain keys in Keys+WeGlide.swift, Keys+SkyLines.swift, Keys+Charron.swiftEncrypted DataStore (Tink/Keystore).
.env / .env.examplerepo rootFastlane/App Store Connect only (FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD, ASC_API_KEY_*); no service secrets.

9. Test fixtures available for Kotlin tests

Section titled “9. Test fixtures available for Kotlin tests”
FixturePathUse
Soaring Spot HTML pages (home, featured, search, 2016/fr pages, competition, empty competition, results (normal / virtual / archive / empty), pilots (+ blanks), tasks AST/AAT/Bailleau/British)vulture-web/spec/mocks/soaringspot_*.htmlOnly if implementing on-device parsing (Jsoup).
Expected JSON values for the abovevulture-web/spec/soaring_spot_spec.rb, soaring_spot_flights_spec.rb, soaring_spot_tasks_spec.rb, open_spec.rbHand-derive JSON response fixtures for MockWebServer tests of the Android client (e.g. results row values in §2.3, task AST values: 6 waypoints, version "v1", distance "452.52 km"). Recommended: generate them once by running the backend specs’ stubs and capturing last_response.body.
Inline JSONPackages/SoaringSpot/Tests/SoaringSpotTests/SoaringSpotPilotTests.swift (pilot rows), Packages/CompetitionScreens/Sources/CompetitionScreens/Mocks/MockSoaringSpotData.swift (builders)Port as kotlinx.serialization tests.
Deep-link casesSoaringSpotDayLinkTests.swiftPort 1:1.
Request-shape testsPackages/WeGlide/Tests/WeGlideTests/WeGlideRequestTests.swift, Packages/SkyLines/Tests/SkyLinesTests/SkyLinesTests.swift, Packages/Charron/Tests/CharronTests/CharronTests.swift, WeGlideCapabilityTests.swiftPort as MockWebServer takeRequest() assertions.
Error-body parsingPackages/Networking/Tests/NetworkingTests/ResponseErrorTests.swift (e.g. {"error":"Date of birth not correct"}, FastAPI nested detail)Port verbatim.
View-model behaviourPackages/CompetitionScreens/Tests/CompetitionScreensTests/{SoaringSpot,Charron,CharronUpload}/*, Packages/ImportScreens/Tests/…/ImportFlights/*, Packages/SettingsScreens/Tests/…/Services/*Port to JUnit5/Turbine against fakes.
IGC filesPackages/Flights/Tests/Flights/*.igcDownload-pipeline tests (serve via MockWebServer).
Bundled dataModules/App/Resources/Data/charron_gliders.json, charron_competitions.jsonShip as assets.

There are no recorded WeGlide/SkyLines/Charron JSON responses in either repo — capture a few real responses (scrubbed) before implementation.


:core:network OkHttp client factory, interceptors, ApiError/FailureReason, Json config, IGC downloader (rate-limit retry)
:core:security Encrypted credential store (DataStore + Tink)
:integration:vulture-api Retrofit interface for vulture.aero/api (soaringspot/*, elevations) + DTOs
:integration:soaringspot Repository + domain models (Competition, Day, ResultRow, Pilot, CompetitionTask→Task mapper), recents (Room DAO)
:integration:weglide Retrofit API, DTOs, capability model, upload use case, task mapper
:integration:skylines Retrofit API, DTOs, auth/ping, upload+publish use case
:integration:charron Retrofit API (+ cookie jar), season rule, login, upload request builder, bundled lists
:feature:competitions Compose screens: Soaring Spot catalogue/filter/competition/day/task/pilots, Charron seasons/day/upload form + WebView
:feature:service-flights Compose: WeGlide/SkyLines browser
:feature:settings-services Compose: credentials & capability screens

DI: Hilt (@Named OkHttp clients per service). JSON: kotlinx.serialization with ignoreUnknownKeys = true, explicitNulls = false, coerceInputValues = true; custom serializers for yyyy-MM-dd (LocalDate), Charron dd-MM-yy, WeGlide nested shapes (use DTOs that mirror JSON nesting, then map — simpler than custom decoders). In-app browser for Glide & Seek / weglide.org: Custom Tabs. No AppAuth needed (no OAuth flows in phase 1).


  1. Soaring Spot scraping fragility lives on the backend — Android inherits it for free but also any outage; no versioning of the API. Keep request/response identical to iOS so one backend fix serves both.
  2. Static backend token shipped in clients (same on Android); rotation means coordinated releases. Consider per-platform tokens.
  3. Soaring Spot rate limiting on IGC downloads (200 + HTML page). Keep the 3-concurrent cap and backoff; consider an identifiable UA.
  4. Charron WebView upload — Android WebView cannot replay a multipart POST; needs the OkHttp-then-render approach and a cookie bridge. Also the competition id is never sent (verify).
  5. Date bugs to decide on: YYYY week-year in reversedString (Charron/SkyLines/WeGlide date queries), Charron season month inconsistency (>9 vs >=9), SkyLines yyyyy fallback format.
  6. WeGlide: API key auth only; uploads require DOB/user id; ISO-8601 parsing strictness; pinning config is inert; task import/latest/daily/feed code exists but is unreachable in UI — confirm phase-1 scope (recommend: own flights + upload + task refresh only).
  7. App Links need assetlinks.json on vulture.aero.
  8. No recorded fixtures for WeGlide/SkyLines/Charron.

Rough size (Kotlin, excluding the map/race-ranking work)

Section titled “Rough size (Kotlin, excluding the map/race-ranking work)”
PieceEst.
core:network + security + IGC downloader3–4 dev-days
Soaring Spot API + repo + Room recents + 6 Compose screens + deep links8–10 dev-days
WeGlide (list, download, upload, settings/capabilities, task refresh)3–4 dev-days
SkyLines (list, download, auth, upload/publish)2–3 dev-days
Charron (browse, login, upload form, WebView bridge)4–5 dev-days
Tests (MockWebServer + VM tests ported)4–5 dev-days
Total≈ 24–31 dev-days (~5–6 weeks for one developer)