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”.
0. TL;DR
Section titled “0. TL;DR”| Service | Transport | Auth | User can… | Android complexity |
|---|---|---|---|---|
| Soaring Spot | JSON 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). |
| WeGlide | REST 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). |
| SkyLines | REST 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.
1.1 Architecture
Section titled “1.1 Architecture”| Concept | iOS type | Behaviour to replicate |
|---|---|---|
| Per-API config | protocol Configuration | baseURL?, 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). |
| Client | class Service | One 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). |
| Request | protocol Request | url (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. |
| Serializer | CodableSerializer<T> | Transport error → failure; status ≥ 400 → ResponseError (with body message); empty body → success(nil) (callers map to []); else JSON decode (decode error → failure). |
| Errors | ResponseError | 400 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 classification | FailureReason | offline / unreachable / serviceTrouble (5xx) / refused (401,403) / notFound / unreadable (decode, bad response) / explained(String) / unknown. Screens localise the reason themselves. |
| Retry | Interceptor | Hook for token refresh; only implementation (WeGlideInterceptor) is a no-op. No automatic retry anywhere in the API layer. |
| Logging | ConsoleLogger | os_log method+URL before, status+bytes after, and the first 400 chars of a failing body. |
| Pinning | PublicKeyPinningService | SPKI 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
< 4096bytes and its first line contains “too many requests” (case-insensitive) → wait2^attempts (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.swiftadd(items:)):OperationQueuewith 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, filenameweglide<id>.igc).
1.3 Android mapping
Section titled “1.3 Android mapping”- 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 (suspendfunctions) instead of callbacks. - One
OkHttpClientbase (shared connection pool) withnewBuilder()per service:VultureAuthInterceptor(addsAccept: application/json+Authorization: <token>).WeGlideApiKeyInterceptor(reads key from encrypted store per request, addsX-API-Keyonly if present).- SkyLines: OkHttp
Authenticatoris wrong here (iOS sends Basic pre-emptively) → use an interceptor addingAuthorization: Basic base64(user:pass)when creds exist. - Charron:
JavaNetCookieJar(CookieManager)or a customCookieJarbacked byandroid.webkit.CookieManagerso the WebView sees the session (see §4). HttpLoggingInterceptoratBASICin debug, plus an error-body logger mirroringConsoleLogger(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 approximateuseProtocolCachePolicy; the backend sends no cache headers today, so effectively no caching — same as iOS.
sealed interface ApiErrormirroringResponseErrorwith the same body-message extraction (portmessage(in:)exactly, incl. nested FastAPIdetail), and aFailureReason.from(Throwable)mappingIOExceptionsubclasses:UnknownHostException/ConnectException/SocketTimeoutException/SSLException→ unreachable; no network (ConnectivityManager) → offline;SerializationException→ unreadable.- Empty-body-is-success: Retrofit
Response<T?>/ a converter that returnsnulloncontentLength == 0. Callers mapnull→ empty list. - Pinning: do not pin (mirror iOS reality). If wanted later, OkHttp
CertificatePinnerwith intermediate/root pins only. - IGC downloader: plain
OkHttpClientcall, same rate-limit page heuristic & backoff,Semaphore(3)/Dispatchers.IO.limitedParallelism(3)for batches.
2. Soaring Spot
Section titled “2. Soaring Spot”2.1 Where things live
Section titled “2.1 Where things live”| Layer | iOS path |
|---|---|
| Models + requests | Packages/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 views | Packages/CompetitionScreens/Sources/CompetitionScreens/SoaringSpot/{List,Competition,Day,Task,Filter,Pilots}/ |
| UIKit hosting / navigation | …/Hosting/SoaringSpot/*ViewController.swift |
| App wiring | Modules/App/Sources/Shell/Split/SplitViewController.swift (serviceController(for:), openServiceFlights, openServiceTask, showSoaringSpot) |
| Deep links | Modules/App/Sources/Application/SceneDelegate+Routing.swift, SoaringSpotDayLink.swift, URL+SoaringSpot.swift |
| Task refresh | Modules/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)”- Sidebar → Discover → Soaring Spot (
DiscoverService.soaringSpot, raw value"soaringspot"persisted as detail destination). - 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_pageof 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.
- 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 (
- Competition screen (
SoaringSpotCompetitionView): fetchGET 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 withoutid→ dimmed, not tappable). Placeholder when no class has days. “Pilots” button → sheet. - 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 withlink), Select All, Open (n). Replay Day (≥2 downloadable flights) = open all flights with race ranking shown. Toolbar “Show task” → task sheet. - Task sheet (
SoaringSpotTaskView): turnpoint list + distance text; Open task (→ task map withExternalTask), Glide and Seek (openshttps://glideandseek.com/?taskOneUrl=http://www.soaringspot.com/en/{competitionId}/tasks/{dayId}in an in-app browser). - 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 ifshowsRankingopens the race-ranking panel once the ranking can be computed. Opening >1 flight requires Pro (ProFeature.severalFlights). - 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:
| Header | Value | Server check (vulture-web/app/helpers/api_auth.rb) |
|---|---|---|
Accept | application/json — must be the first media type | else 406 |
Authorization | raw 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 & path | Query | Response (JSON) | iOS request / decoder |
|---|---|---|---|---|
| 1 | GET soaringspot/countries | – | [{"name":"FR"}, …] (ISO-2; “XD” dropped) | SoaringSpotCountriesRequest, default decoder |
| 2 | GET soaringspot/competitions | country (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) |
| 3 | GET soaringspot/competitions/{id} | – | Competition incl. classes:[{name, days:[Day]}] | SoaringSpotCompetitionRequest; dates yyyy-MM-dd |
| 4 | GET soaringspot/competitions/{id}/pilots | – | [{"competition_number"?, "name", "club"?, "glider"?, "class_name"?, "handicap"?}] (blank cells omitted) | SoaringSpotCompetitionPilotsRequest |
| 5 | GET 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). |
| 6 | GET soaringspot/competitions/{id}/task/{class}/{day} | – | Task | SoaringSpotCompetitionDayRequest(type: .task); default decoder (no dates) |
| 7 | GET open?url=<soaringspot url> | url | {"competition": Competition}; 406 for non-soaringspot host, login page, or missing id | SoaringSpotOpenRequest — defined but unused in the app. Skip on Android. |
JSON shapes (fields the app reads)
Section titled “JSON shapes (fields the app reads)”// 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):
| Endpoint | Source page | Selectors / rules |
|---|---|---|
| countries | /en_gb/search | ul.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}/pilots | table.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}/daily | rows .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.
2.4 Mapping into app models
Section titled “2.4 Mapping into app models”SoaringSpotTask.raw→Waypoints.Task(name, minimumAATDuration, waypoints, notes, inactiveAirspace). EachSoaringSpotWaypoint.raw→Waypoint(name, coordinate, order=index, zone)where a zone exists only iftype∈ {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, andzone.isAAT = task.is_aatfor every waypoint.ExternalTask(task, externalVersion = task.version, externalName = competition.name, externalURL = <relative request URL "soaringspot/competitions/{id}/task/{class}/{day}">). The URL string prefixsoaringspotmarks it as a Soaring Spot task (ExternalTaskType, logo in lists, edit disabled for external tasks —TaskMenuBuilder).- Persisting:
Waypoints.Task.createTask(externalURL:externalName:externalVersion:)upserts byexternalURLString(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, bumplastOpenedDate. → Room entityrecent_competition.
2.5 Deep links (universal links on https://vulture.aero)
Section titled “2.5 Deep links (universal links on https://vulture.aero)”| Path | Action |
|---|---|
/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).
2.6 Error handling / edge cases to mirror
Section titled “2.6 Error handling / edge cases to mirror”- 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 withoutlink→ 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.
3. WeGlide
Section titled “3. WeGlide”3.1 Where things live
Section titled “3.1 Where things live”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).
3.2 Auth & capability model
Section titled “3.2 Auth & capability model”- 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 keysVultureWeGlideAPIKey,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 KeychainweGlideAccessToken/RefreshToken/Nameare 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:
| Capability | Requires |
|---|---|
| downloadFlight, downloadTask | apiKey + userId |
| uploadFlight | apiKey + 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.
3.3 Endpoints
Section titled “3.3 Endpoints”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).
| Purpose | Method & path | Params | Response fields used | Used? |
|---|---|---|---|---|
| Pilot’s flights (“Your”) | GET flight | user_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 date | GET flight | scoring_date_in=<YYYY-MM-dd>&skip&limit&order_by=-scoring_date | same | code only |
| Latest | GET flight | skip&limit&order_by=-created | same | code only |
| Feed (friends) | GET flight/feed | skip&limit&order_by=-created | same | code only |
| Flight detail → IGC | GET flightdetail/{id} | – | igc_file.file (path) → IGC URL https://weglidefiles.b-cdn.net/<path> | Yes (on open) |
| Upload IGC | POST igcfile | multipart (below) | [{id}] → first id | Yes |
| Set comment | PATCH flightdetail/{id} | JSON {"comment":"Uploaded with Vulture"} | ignored | Yes (after upload) |
| Upload photo | POST story | multipart image (jpeg, EXIF preserved) + flight_id | ignored | Disabled (needs OAuth) |
| Task by id | GET task/{id} | – | WeGlideTask | Yes (task refresh) |
| Pilot’s tasks / starred / explore | GET task | user_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.igcContent-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.
3.4 Model mapping
Section titled “3.4 Model mapping”WeGlideFlight→ImportFlightItemrow: 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 zonetype = "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)”4.1 Where things live
Section titled “4.1 Where things live”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"}]).
4.2 Season rule
Section titled “4.2 Season rule”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.
4.3 Endpoints
Section titled “4.3 Endpoints”Base https://www.charronline.be/charronline/. Session config: shared cookie storage, accept all cookies.
| Purpose | Method & path | Params / body | Response |
|---|---|---|---|
| Days of a season | GET {season}/days.php | header 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 day | GET {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). |
| Login | POST {season(date)}/login.php | application/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. |
| Upload | POST {season(date)}/invoer2.php | multipart (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 IGC | GET 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.
4.4 Flows
Section titled “4.4 Flows”- 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 byDouble(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). AnalyticsopenCharronCompetitions. - Sign in (Settings → Services → Charron): store creds → POST login (season of today) → success keeps them, failure deletes them.
- Upload (flight menu, requires Pro
uploadsand 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.
4.5 Android mapping
Section titled “4.5 Android mapping”- Use a
CookieJarbacked byandroid.webkit.CookieManager(or copy cookies into it before loading) so the login session from OkHttp is visible to the WebView. - WebView cannot
loadUrlwith an arbitrary multipart body;WebView.postUrl(url, bytes)sendsapplication/x-www-form-urlencodedonly. Options: (a) perform the multipart POST with OkHttp andloadDataWithBaseURL(finalUrl, html, "text/html", "utf-8", finalUrl)to show the returned page (relative links keep working via the base URL, cookies shared); (b) intercept withshouldInterceptRequest. 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.
5. SkyLines
Section titled “5. SkyLines”5.1 Endpoints
Section titled “5.1 Endpoints”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).
| Purpose | Method & path | Params | Response |
|---|---|---|---|
| Ping / resolve user | GET settings | Content-Type: application/json | {"id": Int, …} → store user id |
| Pilot flights | GET flights/pilot/{userId} | page = skip/limit + 1 (limit 100) | {"flights":[SkyLinesFlight]} |
| Flights of date | GET flights/date/{YYYY-MM-dd} | page | same |
| Latest | GET flights/latest | page | same |
| Upload | POST flights/upload | multipart: files (filename = flight filename, octet-stream, IGC text) + pilotId (if stored) | {"results":[{"status": Int, "flight":{"id": Int}}]} |
| Publish | POST 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.
5.2 Flows
Section titled “5.2 Flows”- 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”| Concern | iOS | Android |
|---|---|---|
| Browser for WeGlide/SkyLines | ImportFlightsViewController<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 flights | Pro gate severalFlights; max 3 concurrent downloads; ranks sort + recolour. | Same; use a FlightDownloadQueue in the map feature. |
| Upload gating | Pro uploads + ConnectedServices (credentials ladder) cached until servicesDidChange. | ConnectedServicesRepository exposing a StateFlow recomputed on credential changes. |
| Credential storage | iOS 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 state | Defaults.soaringSpotSelection, charronSelection, skyLinesShouldPublish, detailDestination | Preferences DataStore. |
| Recents | Core Data RecentCompetition | Room. |
7. Out of scope for phase 1 (found during the sweep)
Section titled “7. Out of scope for phase 1 (found during the sweep)”| Integration | Where | Note |
|---|---|---|
| FLARM device API (Wi-Fi) | Packages/Flarm/Sources/Flarm/Core/FlarmServiceConfiguration.swift → http://10.10.10.10/api | Download flights from / send data to a FLARM device on its own hotspot; needs cleartext-traffic exception on Android. |
| Bluetooth loggers | Packages/Bluetooth, Packages/ImportScreens/.../Bluetooth/* | BLE flight download/send. |
| SkySight | Packages/SkySight → https://skysight.io/api, tiles https://satellite.skysight.io/tiles | Forecast overlays; Pro. |
| Aviation weather | Packages/AviationWeather → https://avwx.rest/api/{metar,taf,station/near}, https://api.sunrise-sunset.org/json | Widgets/weather screens. |
| Elevation | Packages/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 catalogue | Packages/RemoteContent, ImportScreens/.../RemoteFiles | Airspace/waypoint downloads. |
| Backend extras not used by iOS here | vulture-web/app/api/{sgp,sgp_flights,xcsoar,open_aip}.rb | SGP (Sailplane Grand Prix) endpoints exist server-side but no iOS client was found. |
8. Secrets inventory (names only)
Section titled “8. Secrets inventory (names only)”| Secret | iOS location | Recommended Android handling |
|---|---|---|
| Vulture backend API token | Literal 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/secret | Packages/Preferences/Sources/Preferences/Vulture.swift Vulture.WeGlide | Do not port (unused). |
| RevenueCat key | same file purchasesAPIKey | Out 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.swift | Encrypted DataStore (Tink/Keystore). |
.env / .env.example | repo root | Fastlane/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”| Fixture | Path | Use |
|---|---|---|
| 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_*.html | Only if implementing on-device parsing (Jsoup). |
| Expected JSON values for the above | vulture-web/spec/soaring_spot_spec.rb, soaring_spot_flights_spec.rb, soaring_spot_tasks_spec.rb, open_spec.rb | Hand-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 JSON | Packages/SoaringSpot/Tests/SoaringSpotTests/SoaringSpotPilotTests.swift (pilot rows), Packages/CompetitionScreens/Sources/CompetitionScreens/Mocks/MockSoaringSpotData.swift (builders) | Port as kotlinx.serialization tests. |
| Deep-link cases | SoaringSpotDayLinkTests.swift | Port 1:1. |
| Request-shape tests | Packages/WeGlide/Tests/WeGlideTests/WeGlideRequestTests.swift, Packages/SkyLines/Tests/SkyLinesTests/SkyLinesTests.swift, Packages/Charron/Tests/CharronTests/CharronTests.swift, WeGlideCapabilityTests.swift | Port as MockWebServer takeRequest() assertions. |
| Error-body parsing | Packages/Networking/Tests/NetworkingTests/ResponseErrorTests.swift (e.g. {"error":"Date of birth not correct"}, FastAPI nested detail) | Port verbatim. |
| View-model behaviour | Packages/CompetitionScreens/Tests/CompetitionScreensTests/{SoaringSpot,Charron,CharronUpload}/*, Packages/ImportScreens/Tests/…/ImportFlights/*, Packages/SettingsScreens/Tests/…/Services/* | Port to JUnit5/Turbine against fakes. |
| IGC files | Packages/Flights/Tests/Flights/*.igc | Download-pipeline tests (serve via MockWebServer). |
| Bundled data | Modules/App/Resources/Data/charron_gliders.json, charron_competitions.json | Ship as assets. |
There are no recorded WeGlide/SkyLines/Charron JSON responses in either repo — capture a few real responses (scrubbed) before implementation.
10. Android module structure (proposal)
Section titled “10. Android module structure (proposal)”: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 screensDI: 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).
11. Risks & open questions
Section titled “11. Risks & open questions”- 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.
- Static backend token shipped in clients (same on Android); rotation means coordinated releases. Consider per-platform tokens.
- Soaring Spot rate limiting on IGC downloads (200 + HTML page). Keep the 3-concurrent cap and backoff; consider an identifiable UA.
- 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).
- Date bugs to decide on:
YYYYweek-year inreversedString(Charron/SkyLines/WeGlide date queries), Charron season month inconsistency (>9vs>=9), SkyLinesyyyyyfallback format. - 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).
- App Links need
assetlinks.jsonon vulture.aero. - 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)”| Piece | Est. |
|---|---|
| core:network + security + IGC downloader | 3–4 dev-days |
| Soaring Spot API + repo + Room recents + 6 Compose screens + deep links | 8–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) |
