Changes to make in vulture-ios (and vulture-web) for the Android port
Moved from vulture-android/docs/ios-changes.md
Paths are relative to vulture-ios/ unless stated. Line numbers are as of 9c266f5c3.
Ship the iOS fixes before porting the affected code, so Android ports the fixed behaviour
and both platforms give the same numbers.
Copy-pasteable prompts for Claude Code in vulture-ios, one per batch: vulture-android/docs/ios-fix-prompt.md
(B1–B9, Q1–Q21, A1), vulture-android/docs/ios-fix-prompt-2.md (Q23, B10–B25),
vulture-android/docs/ios-fix-prompt-3.md (B26–B34), vulture-android/docs/ios-fix-prompt-4.md (B35–B39, A3) and
vulture-android/docs/ios-fix-prompt-5.md (B40–B42, B44–B51, accepted on 2026-10-12; B43 rejected) and
vulture-android/docs/ios-fix-prompt-6.md (catches up on batches 3–5, which iOS has not done as of build 564, and adds
B53, A5’s vulture:// routing and the open decisions P5-4 and B52).
A. Needed for the port
Section titled “A. Needed for the port”A1. Golden-data exporter (test-only, highest priority)
Section titled “A1. Golden-data exporter (test-only, highest priority)”Add a test in Packages/Flights/Tests/ that, for every fixture IGC, runs the full
FlightEnricher pipeline (with the cached elevation fixtures, so it is deterministic) and writes
one JSON per fixture:
- header fields, date, time zone, sport, warnings
- per fix:
rawTime, lat/lon, pressure + GPS altitude (raw and corrected), elevation, smoothed vario, ground speed,distanceTraveled, thermal/leg membership - takeoff / release / landing / engine-run indexes and times
- thermals (all candidates incl. rejected, with every computed field), wind, legs, phases
- every optimisation (free, triangle, out-and-return …) with its turnpoints and distance
- task attempt: credited fixes per turnpoint, distances, speeds, finished flag
- the flight statistics shown in the detail summary
- formatted strings for each unit setting (to pin rounding)
Write doubles with full precision (String(describing:)/%.17g) so Kotlin can compare with a
tight tolerance. Commit the JSON into this Android repo under domain/flight/src/test/resources/golden/.
Re-run it after each iOS fix below so the golden data always matches shipping iOS.
A3. Airspace golden-data exporter (test-only; Phase 6 decision P6-D20)
Section titled “A3. Airspace golden-data exporter (test-only; Phase 6 decision P6-D20)”iOS has no exporter for airspace, so Android’s golden data was written by a scratch copy of
Packages/Airspace with an extra test (domain/airspace/scripts/GoldenExporter.swift in this repo
holds it). Add that test to Packages/Airspace/Tests/AirspaceTests/ (it uses the internal API through
@testable import Airspace) so the data can be exported again after any change to the decoders:
- for every
.txt/.aip/.cubfile in a folder (AIRSPACE_GOLDEN_IN), decode it withAirspaceDecoder(type:filename:data:)and write<file>.jsontoAIRSPACE_GOLDEN_OUT: per airspace the id (decimalUInt64as a string), name, class raw value, floor and ceiling (case, value, unit or level), radio frequency, transponder code, point count and the points ([lat, lon], full precision; left out for files over 20 000 points unlessAIRSPACE_GOLDEN_FULLis set); - write
vectors.json: each parser and mapping over a fixed list of inputs (Altitude(value:),(element:),(tileValue:unit:datum:),(alt:altStyle:), everyClassinitialiser incl. all CUB class/style pairs,extractFrequency,appendFrequencyDot,uniqueHash,NSString.pathExtension→AirspaceFileType(extension:),AirspaceFileType(content:),pow(60, -k),.whitespacestrimming).
Android commits the output under domain/airspace/src/test/resources/golden/ (after
domain/airspace/scripts/format_golden.py). The infringement scan’s golden data (WP-D2,
domain/airspace/src/test/resources/golden/scan/) needs the scan moved out of the app module
(FlightViewModel.swift:555-660) into a package first; until then it comes from a Swift copy too.
Note for comparing: OpenAir arc points go through Apple’s libm, so Android matches them to the last
bits only, and the ids of airspace drawn with arcs can differ (see domain/airspace/README.md,
“Ids and the last bit”).
A4. iOS joins the Firebase sync (later; Phase 8b decisions §14)
Section titled “A4. iOS joins the Firebase sync (later; Phase 8b decisions §14)”Android syncs through the Firebase project vulture-app-store (Google sign-in, Firestore + Cloud Storage); iOS keeps
CloudKit. For one logbook, one subscription and one set of settings across both platforms, iOS adopts the same cloud
format. Nothing here blocks Android; do it when cross-platform sync is wanted. The format, the id rules and the merge
rules are in sync-schema.md (§7 is the iOS mapping); the console side in
vulture-android/docs/firebase-setup.md.
- Firebase Auth with Sign in with Apple and Google (decision P8B-D17); add an iOS app to
vulture-app-store(GoogleService-Info.plist, kept out of git like Android’sgoogle-services.json). - A Firestore sync engine beside
NSPersistentCloudKitContainer(both run during a transition, 03 §8.6): push changed rows as field-merges withupdatedAt = FieldValue.serverTimestamp(), pull byupdatedAtwith a 2-minute overlap, merge per field (sync-schema §5), tombstones kept 90 days. - Ids: flights = SHA-256 of the IGC bytes; a new optional
syncIDString attribute onTaskandFile(an additive, CloudKit-safe model change) holding the deterministic ids of sync-schema §4; home bases = SHA-256 of the trimmed, lower-cased name. - Non-UTF-8 IGC files (plan risk R4): iOS only keeps
rawContentas aString, so for a Latin-1 header the re-encoded UTF-8 bytes hash to another id than Android’s. Keep the original bytes’ SHA-256 for new imports, and run the one-time duplicate check of sync-schema §4 after the first full pull. - Settings: map the iCloud KVS keys to
settings/syncedwith the key and unit tables of sync-schema §2 (units as ids, not indexes;VultureShowTrackwritten to both halves). - RevenueCat (decision P8-D14): after sign-in call
Purchases.shared.logIn(firebaseUid)instead of logging in with the CloudKit user record name (Modules/App/Sources/Services/Subscriptions/AppStoreSubscription.swift:130), andlogOut()on sign-out. One subscription then unlocks both platforms, and RevenueCat’s Firebase extension puts therevenueCatEntitlementsclaim (Pro =all) on the ID token, which lifts the 100 MB cloud-storage cap of free users (decision P8B-D14). Only then may the paywall promise “one subscription on iPhone and Android” (P8-D8).
A5. The /open/* page and the Chrome extension on Android (vulture-web, vulture-ios/Chrome; low priority)
Section titled “A5. The /open/* page and the Chrome extension on Android (vulture-web, vulture-ios/Chrome; low priority)”Android (2026-10-17) opens Soaring Spot and Charron.line pages from the browser’s share sheet and reads the Chrome
extension’s vulture://open/… links (vulture-android/docs/app-links.md, “Soaring Spot and Charron.line pages”). Two
small things on the other side:
- vulture-web
views/open.hamlshows the App Store badge and a QR code for a/open/…link opened on a computer or an unverified Android. Add the Google Play badge next to the App Store one (the Play listing exists since 2026-10-15,be.fousa.vulture), and a line for Android readers of that page, since on Android 12+ an unverified link lands here until “Open supported links” is on: “On Android, open this link in Vulture with Share › Open in Vulture, or turn on Settings › Apps › Vulture › Open by default › Open supported links.” Status 2026-10-20: theredesignbranch of vulture-web adds the Google Play badge, not the Android line (Website). - vulture-web FAQ (
/faq), a new entry next to the Safari / Chrome extension one (the owner asked for this on 2026-10-17), in the 8 languages: “Can I open Soaring Spot flights from Chrome on Android? Yes. Chrome for Android has no extensions, so Vulture listens for the pages instead: on a Soaring Spot day (daily results, totals or the task), a flight download, or a Charron.line IGC link, tap Share in the browser and pick Open in Vulture. A link tapped in another app opens Vulture once Open supported links is on in Settings › Apps › Vulture › Open by default. Links with the vulture:// scheme, as the Chrome extension on a computer makes them, open Vulture on a Chromebook or in an Android browser that runs extensions.” - vulture-ios/Chrome writes
vulture://open/…(commit0948392fa, for Chrome on the Mac). Android reads that scheme too, so nothing is needed for a Chromebook or an Android browser that runs the extension. Note for the Chrome extension’s own routing:URL(string: "vulture://open/soaringspot/flight/1").pathis/soaringspot/flight/1(“open” is the host), andSceneDelegate+Routing.open(url:in:)(as ofea449d137) hands the URL toroute(url:in:)unchanged, which matches/open/…prefixes only: the extension’s buttons most likely open the Mac app without opening anything. Fix: rebuild the path from host + path for thevulturescheme before routing (batch 6,vulture-android/docs/ios-fix-prompt-6.mdPart 4).
B. Bug fixes (decision 4: yes)
Section titled “B. Bug fixes (decision 4: yes)”| # | Bug | Where | Fix |
|---|---|---|---|
| B1 | Dates near New Year come out with the wrong year (YYYY = week-based year) in Charron / WeGlide / SkyLines date queries, task filenames, the logbook’s year sections and SkySight dates | Packages/Formatting/Sources/Formatting/Date+Formatters.swift:202 (reversedFormatter), Packages/Waypoints/Sources/Waypoints/Extensions/Task+Filename.swift:52, Packages/Persistence/Sources/Persistence/Extensions/Date+Year.swift:17 (year sections), Modules/App/Sources/Map/SkySight/Date+SkySight.swift:15 ("YYYYMMdd"; now Packages/SkySight/Sources/SkySight/Extensions/Date+SkySight.swift, already yyyy on main) | "yyyy-MM-dd", plus locale = en_US_POSIX and a gregorian calendar. Add a test for 2024-12-30 → 2024-12-30. |
| B2 | SkyLines fallback date format is yyyyy-MM-dd | Date+Formatters.swift:121 | "yyyy-MM-dd" |
| B3 | Charron season rule disagrees: the picker switches in September (>= 9), the requests in October (> 9). It also mixes Calendar.current with a UTC month. | Packages/CompetitionScreens/.../Charron/CharronViewModel.swift:98-102; Packages/Charron/.../CharronFlightsRequest.swift:28, CharronAuthenticateRequest.swift:31, CharronUploadRequest.swift:55; .../CharronUpload/CharronUploadViewModel.swift:62 | Confirm the real season start with Charron. Then add one CharronSeason.year(for: Date) in Packages/Charron and use it in all five places. |
| B4 | The Charron upload lets you pick a competition but never sends it: only wedstrijd=J and the distance are posted | Picked in CharronUploadViewModel.swift:99, dropped in Modules/App/Sources/Features/Competition/CharronUpload/AppCharronUploadProvider.swift:65; CharronUploadRequest.swift has no field for it | Look up the competition field name in Charron’s invoer2.php form. Pass the selected competition’s id through the provider into CharronUploadRequest and append it to the multipart body. |
| B5 | The WeGlide certificate pin is registered for weglide.org, but requests go to api.weglide.org, so it never applies | Packages/WeGlide/Sources/WeGlide/Core/WeGlideServiceConfiguration.swift:23 | Remove the pin. That matches the backend decision after the Let’s Encrypt outages; keeping it on the right host risks the same outages. |
| B6 | Airspace infringement ignores the altitude reference: AGL limits are compared with pressure altitude (no terrain), and FL/AMSL are mixed through valueInMeters | Modules/App/Sources/Map/Flight/Flight/FlightViewModel.swift:574-640, Packages/Airspace/.../Models/Airspace+Altitude.swift:166 | Compare per reference, as the airspace sheet already does: AGL against pressureAltitude − elevation (skip AGL bounds when no elevation), AMSL against pressure altitude, FL against pressureAltitude × 3.2808399 / 100. Optional: a small tolerance (e.g. 30 m), as a separate decision. |
| B7 | OpenAir AL 1000ft GND / SFC with a number is parsed as AMSL (only agl is detected) | Airspace+Altitude.swift:26-64 | Treat a value containing gnd, sfc or agl as AGL. Exact gnd/sfc stays ground. |
| B8 | OpenAIP tile limits are always read as feet | Airspace+Altitude.swift:86-100 (init(tileValue:unit:datum:)) | Honour the tile’s unit attribute (metres vs feet). |
| B9 | Some units print 6 decimals (String(format: "%f")) for ft/min, hPa and percentage. Also check the “ft/s” label: the conversion × 196.850394 is ft/min. | Packages/Preferences/Sources/Preferences/Units/Formatter/UnitFormatter.swift:42,50,56 | Android already does this; match it: 0 decimals for ft/min, hPa and %, and a new label key settings.labels.unit.ftmin = “ft/min” in all 8 locales (Android then drops its Android-only copy of that string). |
| B10 | The statistics truncate speeds, distances and altitudes to whole SI units before converting: 25.9 m/s shows as 90 km/h (25 m/s) instead of 93 km/h, 1500.7 m as 1500 m, and in m/s every speed reads xx.00 | Packages/FlightScreens/Sources/FlightScreens/Statistics/FlightsStatisticsViewModel.swift:106,107,109 (StatisticsDataItem.formattedValue(_:)) | Format the double: case .roundedDistance: return value.roundedDistanceString, case .speed: return value.speedString, case .altitude: return value.altitudeString. Add the missing Double.roundedDistanceString to Packages/Preferences/Sources/Preferences/Units/Extensions/Double+Formatter.swift (after distanceString, line 39): a copy of the Int one in Int+Formatter.swift:34 (formatter.numberOfFractionDigits = 0). Keep .count/.hours truncating. Test: .bestCruiseSpeed.formattedValue(25.9) = "93 km/h", .totalAltiudeGained.formattedValue(1500.7) = "1501 m", .taskDistance.formattedValue(2500.9) = "3 km". |
| B11 | The flight preview’s duration wraps after 24 h: 90 000 s shows 01:00:00 | Modules/App/Sources/Shell/Sidebar/List/FlightConfiguration/CoreFlight+FlightListRowItem.swift:84 (preciseDurationText uses Int(duration).timeValue, the time-of-day formatter of Packages/Flights/Sources/Flights/Extensions/Int+Time.swift:32 with its correctOffsetTime wrap) | Format total hours without the wrap: let seconds = Int(duration); return String(format: "%.2d:%.2d:%.2d", seconds / 3600, seconds % 3600 / 60, seconds % 60). Leave timeValue alone (fix times need the wrap). Test: 90 000 → "25:00:00", 86 400 → "24:00:00", 363 599 → "100:59:59". |
| B12 | String.trimmedString turns leading/trailing white space into one space instead of removing it, and shortens an inner run to one space + its last character ("a b" → "a b"). Visible as a row comment starting/ending with a space and the Library caption "Last flight " for a flight without a takeoff time | Packages/Utilities/Sources/Utilities/String+Trimming.swift:12-15; used at CoreFlight+FlightListRowItem.swift:48 (comments?.trimmedString) and Modules/App/Sources/Shell/Sidebar/SidebarViewModel.swift:280 (heading.trimmedString) | Add to String+Trimming.swift: public var normalizedWhitespace: String { components(separatedBy: .whitespacesAndNewlines).filter { !$0.isEmpty }.joined(separator: " ") } (trims the ends, collapses every inner run, newlines included, to one space; .whitespacesAndNewlines is the same Unicode White_Space set as ICU’s \s). Use it at those two call sites only. Keep trimmedString for Task+Filename.swift:33 and Bluetooth.swift:323,345 (changing it would rename exported tasks; Android keeps that too). Test: " Great day\n\nat Terlet " → "Great day at Terlet", " \t " → "". |
| B13 | Empty (not nil) pilot and passenger names give ", " (or "Jan, ") instead of "--" | Packages/Formatting/Sources/Formatting/FlightConvertible.swift:26-32 (pilots) | Treat blank as missing: let pilot = pilotName.flatMap { $0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : $0.capitalized }, the same for passenger, then [pilot, passenger == "Nil" ? nil : passenger].compact().joined(separator: ", "). Test: ("", "") → "--", ("jan", " ") → "Jan", ("", "marie") → "Marie". |
| B14 | A statistics grid with a single year reports only the left corners (.topLeft, .bottomLeft), so the card’s right corners stay square | Packages/FlightScreens/Sources/FlightScreens/Statistics/FlightsStatisticsViewModel.swift:269-291 (position(for:), the item == 0 branch wins); the same shape in Compare/CompareFlightsViewModel+Sections.swift:39-62 (one flight) | Add case topLeftAndRight, bottomLeftAndRight to CompareFlightValuePosition (Components/CompareGridValues.swift:18). In position(for:) check item == 0 && item == numberOfYears - 1 first and return them. Draw them: Hosting/Compare/Cells/CompareFlightValueCollectionViewCell+Drawing.swift:21 roundedCorners → [.topLeft, .topRight] / [.bottomLeft, .bottomRight]; line 62 (no top separator) also for .topLeftAndRight; line 73 (no trailing separator) also for both; Hosting/Compare/CompareFlightRoundedPositionCell.swift:18 maskedCorners → [.layerMinXMinYCorner, .layerMaxXMinYCorner] / [.layerMinXMaxYCorner, .layerMaxXMaxYCorner]. Test: one flight → position(item: 0, section: 0) == .topLeftAndRight, section 1 and the last section == .bottomLeftAndRight, section 3 == .right. |
| B15 | The flight map’s highlight (a selected leg, an infringement) draws its wider, darker border on top of the line, so the highlight shows as the border colour with the line hidden under it | Modules/App/Sources/Map/Flight/DataSource/FlightMapViewDataSource.swift:162-217 (highlightFixes, highlightInfringementFixes: highlight.layer is added, then highlight.border on top) | Add highlight.border first and highlight.layer after it (or add the border with below: highlight.layer), in both closures. Check on a selected leg: a #D5A359 line with a darker edge either side. |
| B16 | On the Standard style with lightPreset = night, the style’s lights darken every custom layer that is not emissive: the flight lines, the altitude ramp, the optimisation lines, task, thermals and the selected glider’s pill turn almost black on the dark map (seen on Android with iOS’s layer definitions; iOS sets no emissive strength either) | Every layer factory in Modules/App/Sources/Map/Rendering/Layers/ (LineStyleLayer, FlightTrackLineStyleLayer, Task/*, ThermalFillStyleLayer, Events/*) and the glider view annotations | Set the emissive strength to 1 on each: layer.lineEmissiveStrength = .constant(1) (line layers), fillEmissiveStrength (fills), circleEmissiveStrength (event dots), textEmissiveStrength / iconEmissiveStrength (symbols). No effect on the classic styles (no lights). Check: Standard style, dark mode, a flight with the coloured track: the ramp shows in its own colours. |
| B17 | GraphHandleView.isActiveSubject is never sent, so isActive stays false: the map’s pan is never switched off while the barogram handle is dragged, and a scrub can pan the map | Modules/App/Sources/Map/Flight/Panel/Graph/Views/GraphHandleView.swift:23,107 (subject created, never sent); read in FlightMapViewController (≈ line 448 as of 9c266f5c3; re-check) | Send true when the handle’s drag begins and false when it ends or is cancelled (isActiveSubject.send(…) in the pan handler’s .began / .ended, .cancelled, .failed). Check: while scrubbing the barogram the map does not move. |
| B18 | The map-source coalescer keeps a pending trailing push when an immediate push happens, so at 60 updates a second it pushes in pairs 1 ms apart (≈ 40 pushes a second instead of at most 30) | Modules/App/Sources/Map/Rendering/Compat/MapCompatSources.swift:124-141 (scheduleMapSourcePush) | When the immediate branch pushes (now - lastPush >= interval), cancel the pending trailing push: keep the scheduled work in a DispatchWorkItem, cancel() it and set pushScheduled = false before updateMapSource(). Test: requests every 16 ms push at 0, 48, 96, … ms, never two within 33 ms. |
| B19 | The wind pill keeps its previous arrow for a wind from exactly 180°: rotation = (direction − 180)° is 0 and the abs(rotation) > 0.01 guard skips the transform, so the arrow keeps the last wind’s direction | Modules/App/Sources/Map/Flight/FlightMapView.swift:879-882 | Always set the transform: drop the abs(rotation) > 0.01 guard so windImageView.transform = CGAffineTransform.identity.rotated(by: rotation) also runs for 0 (giving .identity). Check: a wind from 240° then one from 180°: the arrow turns back to north. |
| B20 | Shared-task checks compare Waypoints by object identity (NSObject without isEqual/hash; Task == compares hashValues): two flights that declare the identical task in their IGC files are not “on the same task”, so the race ranking and the compare task rows only appear after a task override | Packages/Waypoints/Sources/Waypoints/Models/Task.swift:54-66 (Hashable: distance + waypoints), Packages/Waypoints/Sources/Waypoints/Models/Waypoint.swift:16; used by Packages/FlightScreens/Sources/FlightScreens/Compare/CompareFlightsViewModel.swift:146 (containsTaskInformation) and, on branch feature/race-ranking, Modules/App/Sources/Map/Flight/FlightMapViewModel+Ranking.swift:27,33 (canShowRanking) | Android already compares by value; match it. Give Waypoint value equality: override isEqual(_:) and hash to compare every stored property (name, code, country, coordinate, elevation, style, runway direction and length, frequency, description, order, zone; the derived leg distance and bearing follow from these; CLLocationCoordinate2D needs comparing by latitude and longitude). Make Task == compare distance and the waypoint arrays element by element instead of hashValues (equal hashes are not equality). Name, notes and minimum time stay out of the task’s equality, as now. Test: the same C records parsed twice give two tasks that are ==; a moved waypoint or another zone is not. |
| B21 | RaceProgress ignores engine runs: a motor glider that starts its engine before the finish and motors into the finish ring counts as a finisher. On the OBGN 2026 task 5 days Soaring Spot scores KT, BV and XL (15 m) and LC, ARC, J, 2Y (18 m/Open) as land-outs at their engine start, while the ranking card shows them finished (all 8 engine-run flights are non-finishers in the official results; none of the 21 finishers has an engine run) | On branch feature/race-ranking: Packages/Flights/Sources/Flights/Models/RaceProgress.swift (init(flight:), lines 105–153: landingIndex at 117–118, the area range at 126; init(times:…) at ≈ 190–215) | Android already does this; match it. In init(flight:), after the start index: let engineStartIndex = flight.engineRuns?.compactMap(\.firstFixIndex).filter { $0 > startIndex }.min(). Pass it to init(times:…) as a new engineStartIndex: Int? = nil; there use credited = reachedIndexes.prefix { $0 < engineStartIndex } instead of reachedIndexes and min(landingIndex, engineStartIndex) as the landing. For an AAT, end the area range at min(landingIndex, engineStartIndex − 1). An engine run that starts before the start does not cut. Tests (Android RaceProgressEngineRunTest): synthetic track start 50° N / TP 50.5° / finish 51° along 4° E, a fix a minute, start fix 1, TP fix 3, finish fix 5 — engine at fix 4: not finished, landed out at fix 4’s time with ≈ 1.5 legs; engine at fix 2: landed out at ≈ 0.5 leg; engine at fix 6 (after the finish): unchanged. jelle600 with an engine run halfway between start and finish: not finished; one 120 fixes before the start: unchanged. Known limits: a logger without ENL/MOP stays undetectable (as for Soaring Spot); processEngineRuns drops a run still on at landing, so motoring to touchdown is missed. |
| B22 | The barogram places highlights (a selected leg or thermal, airspace infringements) by rawTime without the flight’s alignment offset, so on aligned flights (“Align start”) the highlight sits away from the stretch it marks | Modules/App/Sources/Map/Flight/Panel/Graph/GraphDataSource.swift:552-572 (highlights(in:)) | Add the highlighted flight’s offset: progress(for: first.rawTime + (timeOffsets[hashValue] ?? 0)) (and the same for last), with hashValue the selected flight’s, as taskAttemptProgressTimes does. Test: two flights, the second aligned 100 s later, the leg highlighted on the second: its stretch starts 100 s later than on the first. |
| B23 | Every non-altitude barogram axis starts one full interval below its minimum: minimum - (interval + minimum % interval), so speed axes start at −50 km/h, a vario clamped at −10 m/s at −15 m/s, 32 °F at 20 °F | GraphDataSource.swift:197 (valueRange, the default case) | Start at the grid line at or below the minimum, like the altitude axes: let remainder = ((minimumValue % interval) + interval) % interval; minimum: minimumValue - remainder (a floored modulo, because Swift’s % truncates). For a negative minimum between two grid lines the result is unchanged (−3, interval 5 → −5). Keep the maximum rule (a full interval more when on a grid line, as the altitude axes do). Test: valueRange(0, 280, 50, .groundspeed) → 0…300; (-10, 14, 5, .vario) → -10…15; (32, 91, 10, .outsideAirTemperature) → 30…100; (-3, 7, 5, .vario) → -5…10. |
| B24 | Five small/large barogram interval pairs are inverted, so the small graph (phone landscape) gets the finer grid: vario and total energy in ft/min (small 500 / large 1000), electrical current (2 / 50), roll and pitch (2 / 10), heart rate (10 / 40), SpO2 (5 / 20) | GraphDataSource.swift:624-709 (smallInterval(from:), largeInterval(from:)) | Swap each pair: small / large vario and total energy in ft/min 1000 / 500, current 50 / 2, roll and pitch 10 / 2, heart rate 40 / 10, SpO2 20 / 5. Test: .vario.smallInterval with ft/min = 1000, .heartRate.largeInterval = 10. |
| B25 | The barogram’s vario and total-energy readout keeps 2 decimals in ft/min ("%0.2f %@": “394.70 ft/min”), while ft/min shows without decimals everywhere else (B9) | GraphDataSource.swift:872-874 (unitFormat(from:isElevationAvailable:)) | For .vario, .totalEnergy: return "%0.f %@" when the vertical speed unit is ft/min, else keep "%0.2f %@". Test: 394.7 ft/min → “395 ft/min”; 1.5 m/s → “1.50 m/s”. |
| B26 | The flight detail’s “Average glide ratio” shows the bare integer (“42”) instead of a glide ratio (“42:1”): the Int goes through stringValue (String(self)), while every other glide ratio in the app (the phase rows, the compare table) reads “n:1” | Packages/FlightScreens/Sources/FlightScreens/FlightDetail/Tabs/DetailTabFlightViewModel.swift:134 (self.flight.averageGlideRatio?.stringValue) | Use self.flight.averageGlideRatio?.ldString ("%i:1", Packages/Preferences/Sources/Preferences/Units/Extensions/Int+Formatter.swift). Test: a flight whose averageGlideRatio is 42 shows “42:1”. |
| B27 | The flight detail’s “Water ballast” is hard-coded English and never plural: “80 liter”, “1 liter”, also in Czech, German, … | Packages/DesignSystem/Sources/DesignSystem/Extensions/Int16+Water.swift:14 ("\(self) liter"), used at Packages/FlightScreens/Sources/FlightScreens/FlightDetail/Tabs/DetailTabFlightViewModel.swift:96 | Format it as a measurement, like the Edit flight slider already does (EditFlightView.swift:151): Measurement(value: Double(self), unit: UnitVolume.liters).formatted(.measurement(width: .wide, usage: .asProvided)) (“80 liters”, “1 liter”, “80 litrů”, “80 Liter”). Android uses ICU MeasureFormat (MeasureUnit.LITER, WIDE). Test: 80 → “80 liters”, 1 → “1 liter” in English. |
| B28 | On the Thermals page the left- and right-turn average climbs are both labelled “Average vario”; only the column tells them apart (and VoiceOver reads the same label twice) | Packages/FlightScreens/Sources/FlightScreens/FlightDetail/Tabs/DetailTabThermalsViewModel.swift:107,110 ("flight.info.label.thermal.average" on both columns) | Add two keys to the DesignSystem catalog, in all 8 languages, and use them on those lines: flight.info.label.thermal.average.left = “Average vario (left)”, flight.info.label.thermal.average.right = “Average vario (right)”. Android’s texts (copy them so the catalogs agree): cs “Průměrné vario (doleva)” / “(doprava)”, de “Ø Vario (links)” / “(rechts)”, es “Vario medio (izquierda)” / “(derecha)”, fr “Vario moyen (gauche)” / “(droite)”, it “Vario medio (sinistra)” / “(destra)”, nl “Gemiddelde vario (links)” / “(rechts)”, pl “Średnie wariometru (w lewo)” / “(w prawo)”. Android then drops its Android-only copy (feature/flightdetail strings_flightdetail_d1.xml). |
| B29 | Share › Map Image writes every image as NSTemporaryDirectory()/<flight file name>.png, so a second share of the same flight overwrites (or fails on) a file the receiving app may still be reading; the files are never cleaned up either (02 §6.11, temporary files) | Modules/App/Sources/Map/Flight/FlightMapViewController.swift:1068-1097 (shareSnapshot(for:), the URL at 1079-1081; as of vulture-ios 9892f067f on main) | Write each image in a folder of its own, keeping the flight’s name for the receiver: let folder = FileManager.default.temporaryDirectory.appendingPathComponent("snapshots").appendingPathComponent(UUID().uuidString), try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true), let url = folder.appendingPathComponent(filename).appendingPathExtension("png"). Before writing, remove the folders under snapshots/ older than a day. Android does this (FIXED(B29) in :core:map FlightSnapshotFile). Test: two shares of one flight give two different URLs with the same last path component, and both files exist. |
| B30 | Two English typos in the DesignSystem catalog, shown in the flight detail: “Competion class” and “Minumum AAT duration” | Packages/DesignSystem/Sources/DesignSystem/Resources/Localizable.xcstrings: keys flight.info.label.competition.class and flight.turnpoints.info.label.minimumtime (the English values) | Change the English values to “Competition class” and “Minimum AAT duration”. The Dutch value of the second key has the same typo (“Minumum duur AAT” → “Minimum duur AAT”; checked on vulture-ios 9892f067f); the other locales are correct and keep their texts and states (the keys are identifiers, not English source strings). Android then regenerates strings_ios.xml (python3 scripts/convert_strings.py --ios ../vulture-ios); it never edits that file by hand, so until then Android shows the typos too. Decision D9 of the Phase 5 plan. |
| B31 | The Debrief’s “The task, leg by leg” puts “Final glide” on the second-to-last leg: OptimizedLeg.index is the index of the optimisation fix the leg ends at, so 1-based (makeLegs skips index 0), and leg.index == item.legs.count - 1 matches the leg before the last. Flight Chat’s task tool has the same test (” (final glide)”) | Packages/FlightScreens/Sources/FlightScreens/FlightDebrief/FlightQuestionAnswering.swift:156-161 (task(of:), the test at 159); Packages/FlightScreens/Sources/FlightScreens/FlightChat/Tools/FlightChatTools.swift:194-197; the index comes from Packages/FlightScreens/Sources/FlightScreens/Models/DetailOptimizationItem+Legs.swift:39-64 (for (index, fix) in fixes.enumerated(), guard index > 0, index: index) | Test the position, not the index: for (position, leg) in item.legs.enumerated() (or item.legs.enumerated().map { position, leg in … }) and if position == item.legs.count - 1. Same in FlightChatTools.swift. Test (FlightQuestionAnsweringTests, obgn2026 task-2 85, the IGC in Packages/Flights/Tests/Flights/, a completed 5-leg task): only the fifth row ends in ” · Final glide” (“Leg 5 to 074St. Hubert Aero · Final glide” with B32; today “Leg 5 to 065Bertrix Jehonville · Final glide”, the fourth row). |
| B32 | Leg numbers disagree between the Debrief’s answers: “The task, leg by leg” and the detour row print leg.index + 1 (the 1-based fix index plus one: “Leg 2” for the first leg, “Leg 6” for the fifth), while “My slowest leg” and “My legs against the fastest” print their position + 1 (“Leg 1”). Flight Chat’s task tool prints leg.index + 1 too | Packages/FlightScreens/Sources/FlightScreens/FlightDebrief/FlightQuestionAnswering.swift:157-158 (task(of:)); Packages/FlightScreens/Sources/FlightScreens/FlightDebrief/FlightQuestionAnswering+Flight.swift:214-216 (detour(of:), worst.index + 1); Packages/FlightScreens/Sources/FlightScreens/FlightChat/Tools/FlightChatTools.swift:197; for comparison, by position: FlightQuestionAnswering+Flight.swift:178 (slowestLeg(of:)) and FlightQuestionAnswering+Flights.swift:203 (legsAgainstFastest(for:)) | Number every leg 1…n by its position in item.legs: position + 1 in task(of:) (with B31’s enumerated()) and in FlightChatTools.swift; in detour(of:) pick the worst with its position, e.g. let worst = item.legs.enumerated().max(by: { ($0.element.detourDistance ?? 0) < ($1.element.detourDistance ?? 0) }) and print worst.offset + 1. Do not change OptimizedLeg.index itself (B33: legFixes, status and the waypoint are read by it). Test (obgn2026 task-2, each of 85, lc, le): the task answer’s first row is “Leg 1 to 100Adenau” (le: “100ADENAU”); the slowest-leg row and the detour row’s “Leg n to …” each appear among the task answer’s titles. |
| B33 | A dropped leg breaks the leg list. makeLegs skips a leg of zero duration or distance (two turnpoints credited at one fix) with continue, but the unflown legs are appended from legs.count + 1 and numbered on from lastIndex (the last fix index). So a reached turnpoint comes back as an unflown, failed leg, and the screens that print leg.index skip a number. Seen on obgn2026 task-2 le (a completed task; Bertrix Jehonville and the finish both credited at fix 7727, attempt indexes [2216, 4251, 5336, 6228, 7727, 7727]): the Debrief reads “Leg 2” … “Leg 5” and then “Leg 7 to 074ST. HUBERT AERO” with no “Leg 6”, and the flight detail / optimisation detail list the finish with a red ✗ although the task is completed. The same hole shows in the optimisation detail’s number badge ("\(leg.index).circle.fill", free optimisations) | Packages/FlightScreens/Sources/FlightScreens/Models/DetailOptimizationItem+Legs.swift:38-50 (totalIndex = index, then guard duration > 0, distance > 0 else { continue }); Packages/FlightScreens/Sources/FlightScreens/Models/DetailOptimizationItem.swift:96-112 (for offset in (legs.count + 1)..<task.waypoints.count, totalIndex += 1); Modules/App/Sources/Map/OptimizationDetail/OptimizationDetailView.swift:130 (ForEach(viewModel.legs, id: \.index)) and :292 (the badge); the Debrief and Chat lines of B31/B32 | Two parts. (1) Start the unflown legs after the last turnpoint the attempt reached, which is flown.lastIndex (the last fix index): let firstUnflown = flown.lastIndex + 1; if let task, firstUnflown < task.waypoints.count { for offset in firstUnflown..<task.waypoints.count { … } } (guard the range: a Swift range with lower > upper traps; Q21). Without a dropped leg flown.lastIndex + 1 == legs.count + 1, so nothing else changes. (2) Number legs by position everywhere they are shown (B31, B32, and the badge: ForEach(Array(viewModel.legs.enumerated()), id: \.element.index) { position, leg in … } with "\(position + 1).circle.fill"). Tests (DetailOptimizationItemTests, synthetic: a flight with an OptimizationItem whose indexes repeat a fix, task Start/TP1/TP2/TP3/Finish): credited [0, a, a, b, c] → legs TP1, TP3, Finish, all flown (today a fourth, unflown “Finish”); credited [0, a, a] → TP1 flown, then TP3 and Finish unflown with index 3 and 4 (today TP2, TP3, Finish unflown); [0, a, b] → unchanged (TP1, TP2 flown; TP3, Finish unflown). obgn2026 task-2 le: four legs, “Leg 1 to 100ADENAU” … “Leg 4 to 065BERTRIX JEHONVILLE · Final glide”, every row with a place. Why the scoring credits the finish at the same fix as the last turnpoint is a separate question, not looked into. Android: FIXED(B33) in :domain:flightdetail DetailOptimizationItem / OptimizationDetail and :domain:debrief. |
| B34 | Edit flight’s water ballast slider is continuous, so iOS stores any litre value (e.g. 125 L, 37 L); decided (owner, 2026-10-11) that it moves in 10 L steps on both platforms, as Android’s does | Packages/FlightScreens/Sources/FlightScreens/EditFlight/EditFlightView.swift:160 (Slider(value: $viewModel.waterBallast, in: 0...20)); the mapping in Packages/FlightScreens/Sources/FlightScreens/EditFlight/EditFlightViewModel.swift:35-46,61 (waterBallast = litres / 10, waterBallastLiters = Int(waterBallast * 10)) | Slider(value: $viewModel.waterBallast, in: 0...20, step: 1). Leave the mapping as it is: a value stored off the grid (125 L → 12.5) still shows “125 liters” until the pilot moves the slider, which then snaps to a stop (120 or 130 L); nothing is rewritten on open, so an unchanged flight is not saved. Test (EditFlightViewModelTests): a store holding 80 L shows 80; one holding 125 L shows 125 and saves 125 when nothing moved; setting waterBallast = 13 saves 130. Android: FIXED(B34) in :feature:editflight (Slider(steps = 19)). |
| B35 | The measure tool leaves two white rings on the map after every measure: the end dots fade with circle-opacity only, and a circle’s white stroke has its own circle-stroke-opacity (still 1) | Modules/App/Sources/Map/Rendering/Layers/MeasureStyleLayer.swift (MeasureEndStyleLayer), Modules/App/Sources/Map/MapViewDataSource.swift (measurePolylineShape didSet: setOpacity(opacity, of: .circle, …)) | Create the layer with circleStrokeOpacity = 0 and the same 0.35 s transition, and set circle-stroke-opacity next to circle-opacity in the didSet. Proposed by Phase 6 spike S2, confirmed by the owner on 2026-10-11. Android applies it (FIXED(B35) in :core:map measure/) |
| B36 | The airspace sheet never says “inside” for an airspace with an unlimited ceiling (UNL): ceiling > altitude falls through to default: return false for .unlimited, so a glider above the floor of, say, an FL 30 – UNL restricted area reads “0 m above the ceiling” (the distance to UNL is 0 too), on the row’s badge and in the profile (05 §9 #5). The infringement scan already treats UNL as above any altitude (B6, isAtOrAbove). Decision P6-D5 of the Phase 6 plan | Modules/App/Sources/Support/Extensions/Airspace+Formatter.swift:17-30 (static func > (lhs: Airspace.Altitude, rhs: LocationAltitude), the default: at 27); used by Modules/App/Sources/Map/Airspace/AirspaceViewModel.swift:86-101 (position(of:at:)) | Add case .unlimited: return true to > (an unlimited limit is above any altitude). Leave < (.unlimited is never below) and differenceInMeters (0 for UNL) as they are: with the fix an UNL ceiling never reaches the aboveCeiling branch while the glider is above the floor. Test (AirspaceViewModel, a tile airspace FL 30 – UNL over the glider’s coordinate): LocationAltitude(agl: 2500, fl: 98, msl: 3000) → .inside (today .aboveCeiling("0 m")); fl: 20, msl: 600 → .belowFloor with 305 m. Not part of the fix (kept on both platforms, PARITY): a glider exactly on the floor, or whose altitude lacks the floor’s frame (an AGL floor without terrain), still falls through to aboveCeiling with the distance to the ceiling. Android: FIXED(B36) in :domain:airspace AirspaceDetailModel.position. |
| B37 | The measure tool’s distance is unreadable in dark mode and on Satellite: the pill is tinted UIColor.Flight.glider (UIColor.label, white in dark mode, always white on Satellite) and the text is constant white, so it is white on white | Modules/App/Sources/Map/Rendering/Layers/MeasureStyleLayer.swift (MeasureDistanceStyleLayer: textColor = .white, iconColor = UIColor.Flight.glider) | Pick the text colour against the pill: black (UIColor.label resolved light) when the pill is white, white otherwise. Proposed by Phase 6 WP-M2, confirmed by the owner on 2026-10-11. Android applies it (FIXED(B37) in :core:map measure/MeasureLayers) |
| B38 | openAIP airspace checked on every tile fragment and every MultiPolygon part. A rendered-feature query returns an openAIP airspace as one feature per tile, clipped to it, and a feature can be a MultiPolygon (Phase 6 spike S1: in an Alps z9 view 18 of 31 airspaces came in 2–5 fragments; MultiPolygons in about 1–6 % of the features). iOS converts each feature on its own, keeps only a MultiPolygon’s first polygon, and deduplicates the tile airspaces by id, so only the first fragment’s first polygon reaches the infringement scan: crossings in the other tiles of a large CTA/TMA are missed. Decision P6-D3 of the Phase 6 plan | Modules/App/Sources/Map/Rendering/Compat/MapCompatShapes.swift:341-375 (toMGLFeature(), the .multiPolygon case keeps multi.polygons.first); Modules/App/Sources/Map/MapViewDataSource+Airspace.swift:330-335 (Airspace(tileFeatureAttributes:coordinates:) per queried feature); Packages/MapData/Sources/MapData/MapDataManager.swift:213-230 (publishVisibleAirspaces, the dedupe by id); the scan’s point-in-polygon in Modules/App/Sources/Map/Flight/Flight/FlightViewModel.swift:555-660 | Give Airspace the parts: polygons: [[CLLocationCoordinate2D]] ([coordinates] for a file airspace), and init?(tileFeatureAttributes:polygons:). In queryOpenAIPAirspaces, group the queried features by source_id ?? name and collect every polygon’s outer ring of every feature (each ring once); keep coordinates as the first ring for the highlight and framing. In the scan, compute the bounds over all parts and count a fix as inside when it is inside any part with more than one point. Test: a TMA as two overlapping fragments finds the same fixes as the whole outline; the second fragment’s fixes are found (today they are not); a file airspace gives the same result as before. Android: FIXED(B38) in :domain:airspace (Airspace.polygons, Airspace.fromTile(…, polygons), AirspaceInfringementScanner) and :core:map (OpenAipFeatureReader.merge, AirspaceMapContent). |
| B39 | The infringement scan reads both airspace sources whatever the map shows: publishVisibleAirspaces merges the visible custom files and the openAIP tiles on screen, and every FlightViewModel scans that list. So while the map shows openAIP (the setting, and always without Pro, who cannot show files at all) the visible files are scanned too, and an airspace that is in a file and in openAIP is listed twice under two ids (seen on the OBGN 2026 day: “TSA29B-TSA ARDENNES 07” from a Belgium file and from openAIP). Was candidate P6-3; decided by the owner on 2026-10-11 | Packages/MapData/Sources/MapData/MapDataManager.swift:213-229 (publishVisibleAirspaces: sortedParsedAirspaces + tileAirspaces, a file winning); the scan input in Modules/App/Sources/Map/Flight/Flight/FlightViewModel.swift:151-152 (setupBindings: mapDataManager.visibleAirspaces.sink(receiveValue: processAirspaceInfringements)), the scan at :560-572 (processAirspaceInfringements); which source is shown: Modules/App/Sources/Services/Subscriptions/ProAccess.swift:106-120 (showsOpenAIPAirspace, showsOpenAIPAirspacePublisher); a tile airspace is the one with openAIPSourceId != nil (Packages/Airspace/Sources/Airspace/Models/Airspace.swift:32, set only by init?(tileFeatureAttributes:…)) | Scan only the source the map shows: the openAIP tiles when showsOpenAIPAirspace, else the visible files (hidden sectors included: P6-2 stays). Leave publishVisibleAirspaces and visibleAirspaces as they are (airspace(for:) and the Hidden Sectors screen’s loadedAirspaces read them) and filter in FlightViewModel.setupBindings: Publishers.CombineLatest(mapDataManager.visibleAirspaces, ProAccess.shared.showsOpenAIPAirspacePublisher).map { airspaces, showsOpenAIP in airspaces.filter { ($0.openAIPSourceId != nil) == showsOpenAIP } }.removeDuplicates(by: { $0.map(\.id) == $1.map(\.id) }).sink(receiveValue: processAirspaceInfringements) (the scan already hops to a global queue, so the publisher’s queue does not matter). The switch then rescans. Tests (a FlightViewModel over a mock MapDataManager holding one file airspace and one tile airspace over the same outline): openAIP shown → only the tile’s crossing; custom airspace shown → only the file’s; switching rescans; a hidden file sector is still reported with custom airspace shown. Android: FIXED(B39) in :feature:flightmap (scanInput, FlightMapViewModel.scanInfringements). |
B10–B14 (decided 2026-10-10, decisions.md §7; line numbers as of vulture-ios f4607dc41) are already fixed on Android, tagged FIXED(B10) … FIXED(B14) in :domain:logbook and :feature:library.
B15 (decision D7 of the Phase 4 plan, 2026-10-10) is fixed on Android in the flight map’s layer order, tagged
FIXED(B15) in :core:map FlightMapLayerIds.
B16 (proposed by WP-M2 of Phase 4) is applied on Android, tagged FIXED(B16) in :core:map FlightMapLayers
(screenshots before/after: core/map/README.md, “WP-M2”).
B15 and B16 were confirmed by the owner on 2026-10-11.
B17–B25 (decided by the owner on 2026-10-11, from the Phase 4 candidates below; iOS line numbers as of vulture-ios
0283014e3 on main, the race-ranking files on branch feature/race-ranking) are fixed on Android, tagged
FIXED(B17) … FIXED(B25): B17 in :core:barogram BarogramState/BarogramGestures, B18 in :core:map
CoalescingPusher, B19 in :feature:flightmap WindPill, B20 in :domain:flightmap ReplayRanking.isSameTask and
:domain:compare CompareData, B21 in :domain:flight RaceProgress, B22–B25 in :domain:barogram.
B26–B28 (decision D9 of the Phase 5 plan, taken by the lead on 2026-10-11, owner to confirm at the end of Phase 5;
iOS line numbers as of vulture-ios 85c4cef2c on main) are fixed on Android, tagged FIXED(B26) … FIXED(B28) in
:domain:flightdetail (FlightTab, DetailTexts.waterBallast, ThermalsTab). D9 copies two other detail quirks
(PARITY): the thermal diameter in the altitude unit and the histogram’s m/s bucket edges under knots titles.
B30 (decision D9 of the Phase 5 plan) is an iOS catalog fix only; Android picks it up when strings_ios.xml is
regenerated.
B26–B30 were confirmed by the owner on 2026-10-11. B31–B33 (the Debrief leg numbering, Phase 5 candidates P5-1…P5-3)
and B34 (the water ballast slider’s 10 L steps) were decided by the owner on 2026-10-11; iOS line numbers as of
vulture-ios 9892f067f on main. Android applies them now, tagged FIXED(B31) … FIXED(B34) (B31–B33 in
:domain:debrief and :domain:flightdetail, B34 in :feature:editflight). Copy-pasteable prompt for iOS:
vulture-android/docs/ios-fix-prompt-3.md (B26–B34).
B36 (the airspace sheet under an UNL ceiling, decision P6-D5; iOS line numbers as of vulture-ios 532cac007 on
main) is fixed on Android, tagged FIXED(B36) in :domain:airspace.
B38 (openAIP fragments and MultiPolygon parts, decision P6-D3 after spike S1) is fixed on Android, tagged
FIXED(B38) in :domain:airspace and :core:map.
B35–B38 were confirmed by the owner on 2026-10-11 (with the Phase 6 decisions P6-D1…D20). B39 (the scan reads only the
airspace source the map shows, was candidate P6-3) was decided by the owner on 2026-10-11; iOS line numbers as of
vulture-ios 2eb6c562c on main. Android applies it now, tagged FIXED(B39) in :feature:flightmap.
Copy-pasteable prompt for iOS: vulture-android/docs/ios-fix-prompt-4.md (B35–B39 and the A3 airspace golden
exporter).
B-candidates found while aligning the thermal and airspace sheets (2026-10-17)
Section titled “B-candidates found while aligning the thermal and airspace sheets (2026-10-17)”Found by comparing the two sheets item by item against iOS build 564 (vulture-ios ea449d137); no other data
difference was found (feature/thermal/README.md and feature/airspace/README.md, “Alignment with iOS,
2026-10-17”). Not decided: Android copies iOS (PARITY). Paths relative to vulture-ios/.
| # | Status | Bug | Where | Proposed fix |
|---|---|---|---|---|
| B52 | candidate, not decided (copied, PARITY in :domain:thermal ThermalDetailModel.grid, test PARITY the turn rate is the bank angle…) | The thermal sheet’s “Turn rate” (thermal.label.turnrate, “Drehrate”, “Rychlost otáčení” …) shows the average bank angle in degrees: Thermal.turnRate is atan(v̄² / (9.81 · radius)) in degrees (its own doc comment says “The average bank angle flown in the thermal”), truncated to whole degrees and printed as “42°”. A rate of turn would be degrees per second | Packages/Flights/Sources/Flights/Models/Thermal.swift:85-95 (turnRate); shown by Packages/FlightScreens/Sources/FlightScreens/ThermalDetail/ThermalViewModel.swift:57 (turnRateString) under the title set in ThermalDetailRepresentables.swift:118 | Either rename the label (“Bank angle”, a new catalog key thermal.label.bankangle in 8 languages, and turnRate → bankAngle) or show a real turn rate (360° / the average circle time, in °/s). Android then regenerates strings_ios.xml and renames its Grid.turnRate to match; the value itself is unchanged either way |
B-candidates found reading the subscription code (2026-10-19)
Section titled “B-candidates found reading the subscription code (2026-10-19)”Paths relative to vulture-ios/, main at ea449d137 (build 564). To confirm on an iPhone first.
| # | Status | Bug | Where | Proposed fix |
|---|---|---|---|---|
| B53 | candidate, to confirm on a device (Android already behaves as the fix: :feature:paywall PaywallViewModel.isAskingToRetry raises “There are no subscriptions found.” only for a paywall that was asked for) | At launch a pilot without a subscription has the products fetched (fetchState → fetchProducts → fetchKnownProducts); when none come back (no network, App Store sandbox hiccup, a TestFlight build without products) errorSubject sends .noProducts, so an “Error / There are no subscriptions found.” alert can pop up on launch with no paywall in sight. Noted in the alpha plan as “the possible B52” before B52 was given to the turn rate | Modules/App/Sources/Services/Subscriptions/AppStoreSubscription.swift (fetchState around :155-167, fetchKnownProducts around :262-275, withProducts :238) | Send .noProducts only while a paywall is waiting for the products (withProducts has a pending request: productRequests not empty, or a flag set by withProducts); a background fetch at launch that finds nothing stays silent and the next paywall request fetches again. Test: fetchState with an inactive customer and no products sends no error; withProducts with no products sends .noProducts |
B-candidates found in Phase 8a
Section titled “B-candidates found in Phase 8a”Found while porting the subscription and the paywall; both accepted by the owner on 2026-10-12. Paths relative
to vulture-ios/, main at eaf4271b0. Prompt for iOS: vulture-android/docs/ios-fix-prompt-5.md.
| # | Status | Bug | Where | Proposed fix |
|---|---|---|---|---|
| B50 | accepted (owner, 2026-10-12): the iOS fix is to be made; Android applies it, FIXED(B50); Android says nothing on a cancel (:data:billing SubscriptionRepository.purchase; was ANDROID; decision P8-D11) | Closing the App Store’s purchase sheet shows “Something went wrong” with RevenueCat’s “Purchase was cancelled.” alert: RevenueCat completes a cancelled purchase with userCancelled == true and a purchaseCancelledError, and handleErrorIfNeeded alerts on any error (the analytics already tell a cancel apart). To confirm on a device | Modules/App/Sources/Services/Subscriptions/AppStoreSubscription.swift (subscribe(to:trigger:) → handleErrorIfNeeded(info, error); the promoted-purchase path too) | Pass userCancelled into handleErrorIfNeeded and return early when it is true. Test: a cancelled purchase sends no SubscriptionError |
| B51 | accepted (owner, 2026-10-12): the iOS fix is to be made; Android applies it, FIXED(B51) in :feature:paywall (PaywallViewModel.restore / PaywallAlert.NothingToRestore, string paywall_restore_nothing_found in 8 languages until iOS adds a catalog key; was PARITY) | A Restore that finds no subscription says “The subscription failed to activate, please try again.” (SubscriptionError.inactive), which reads like a failed purchase; there is nothing to try again | AppStoreSubscription.swift (restore(trigger:) → handleErrorIfNeeded: !info.isActive → .inactive) | A restore-specific message (“No subscription found for this account.”) as a new SubscriptionError case with its own catalog string, in 8 languages; Android picks the string up from the catalog |
B-candidates found in Phase 7
Section titled “B-candidates found in Phase 7”Found while porting the services (B40–B41 in WP-DC, B42–B49 in WP-DW / WP-UF / WP-UT / WP-UA / WP-US, logged by
WP-Q). Decided by the owner on 2026-10-12: all accepted except B43 (rejected: Android copies iOS, PARITY).
Android applies each accepted fix now, tagged FIXED(<id>) (the row names the Android place). Paths relative to
vulture-ios/, main at eaf4271b0. Prompt for iOS: vulture-android/docs/ios-fix-prompt-5.md.
| # | Status | Bug | Where | Proposed fix |
|---|---|---|---|---|
| B40 | accepted (owner, 2026-10-12): the iOS fix is to be made; Android applies it, FIXED(B40); Android uses CharronSeason (decision P7-D19) | The Charron account screen’s website link still uses the old season rule: Calendar.current (the device’s time zone) and month > 9, while B3 made every other Charron place use CharronSeason.year(for:) (October, Gregorian calendar in UTC). Same result today except around midnight UTC on 30 September / 1 October in a zone away from UTC | Packages/SettingsScreens/Sources/SettingsScreens/Services/Charron/CharronAuthViewModel.swift:88-93 (static func seasonYear(for:), used at :56 for the website link) | CharronSeason.year(for: date) (import Charron), and delete seasonYear(for:). Test: utc(2026, 9, 30, 23, 30) in a Europe/Brussels calendar gives 2026 (today 2027) |
| B41 | accepted (owner, 2026-10-12): the iOS fix is to be made; Android applies it, FIXED(B41) in :data:charron HttpCharronApi.signInBody: everything outside A–Z a–z 0–9 - . _ ~ is encoded (was PARITY) | The Charron sign-in form body is encoded with .urlQueryAllowed, which leaves &, =, + and ; unencoded: a password (or user name) with & or = is cut by the site’s form parser and + arrives as a space, so such an account can never sign in (the account screen and every upload say “The given credentials are incorrect.”) | Packages/Charron/Sources/Charron/Core/Dictionary+QueryString.swift (queryParameters), used by Packages/Charron/Sources/Charron/Requests/CharronAuthenticateRequest.swift (body) | Encode form values as application/x-www-form-urlencoded: allow only A–Z a–z 0–9 - . _ ~ (or *) and percent-encode the rest, e.g. a CharacterSet.alphanumerics ∩ ASCII plus -._~. Test: password: "p&ss=w+rd" → body contains paswoord=p%26ss%3Dw%2Brd. Android then drops its PARITY |
| B42 | accepted (owner, 2026-10-12): the iOS fix is to be made; Android applies it, FIXED(B42) (Android decodes it: :data:skylines) | A SkyLines upload the server refuses (a duplicate, a file it cannot read) answers each result with "flight": null and a status; iOS’s decoder requires flight, so the whole answer fails to decode and the card shows a decoding error with Retry instead of the status’s message (skylines.labels.error.*) | Packages/SkyLines/Sources/SkyLines/Models/SkyLinesFlightResult.swift (the flight key decoded as required), read by Modules/App/Sources/Support/Upload/SkyLines/SkyLinesUploadViewModel.swift | Decode flight with decodeIfPresent (optional), and map the result’s status to its message before looking at the flight. Test: {"results":[{"status":2,"flight":null}]} → the status-2 message, no decoding error |
| B43 | rejected (owner, 2026-10-12): iOS keeps its behaviour and Android copies it, PARITY in :feature:serviceflights WeGlideFlightList: with a key but no user id it lists the latest uploads without user_id_in (it used to prompt until both were stored; order_by=-created, as iOS’s “latest”) | WeGlide “Your flights” with an API key but no user id sends GET flight without user_id_in, so the list shows everybody’s latest uploads under “Your flights” | Packages/WeGlide/Sources/WeGlide/Requests/WeGlideFlightRequest.swift:24-44 (userId: Int? = nil, the query only when set), built from Packages/ImportScreens/Sources/ImportScreens/Hosting/ImportFlights/ImportFlightType.swift; the prompt stops at the key alone | Keep the connect prompt until the key and the user id are stored (the downloadFlight capability), and never send the request without user_id_in. Test: key only → the prompt, no request |
| B44 | accepted (owner, 2026-10-12): the iOS fix is to be made; Android applies it, FIXED(B44) (Android shows and saves the fetched values: :feature:taskmap) | After Update task on a Soaring Spot task map iOS keeps showing the old minimum AAT duration (only the saved copy gets the new one), and it writes the task’s name where the competition’s name was, so a second update in the same visit saves the task name as the competition (externalName) | Modules/App/Sources/Map/Task/TaskMapViewModel.swift:830-860 (reload(updatedTask:) for Soaring Spot: externalName: task.name at :841, the shown task’s AAT duration not replaced) | Rebuild the shown task from the fetched one including minimumDuration, and keep externalTask.externalName (the competition) on every update. Test: two updates in a row keep the competition name; the banner and page show the new AAT duration |
| B45 | accepted (owner, 2026-10-12): the iOS fix is to be made; Android applies it, FIXED(B45) (Android shows no Update button for it: :feature:taskmap) | A task map whose external URL belongs to no known service shows Update; tapping it sets the “Updating task” HUD and updateTask switches on .none: break, so the HUD never clears and later updates are ignored (loadingTextSubject.value == nil guard) | Modules/App/Sources/Map/Task/TaskMapViewModel.swift:803-815 (updateTask(), case .none: break) | Offer Update only when externalTask.externalType != .none, and in updateTask return before setting the HUD for .none. Test: an unknown external URL → no Update action; calling updateTask() leaves loadingText nil |
| B46 | accepted (owner, 2026-10-12): the iOS fix is to be made; Android applies it, FIXED(B46) (Android shows the first step’s spinner on Retry: :feature:upload) | Retry on a failed upload card calls load() again but leaves the failed card (with its Retry button) up until the new attempt ends, so a second tap starts a second upload | Modules/App/Sources/Shell/Alerts/AlertViewController.swift (retry → viewModel.load()), Modules/App/Sources/Support/Upload/WeGlide/WeGlideUploadViewModel.swift, …/SkyLines/SkyLinesUploadViewModel.swift (load() sends the loading state only later) | Send the first step’s .loading(message) at the start of load() before any work. Test: after Retry the state is .loading at once |
| B47 | accepted (owner, 2026-10-12): the iOS fix is to be made; Android applies it, FIXED(B47) (Android reads skylines.labels.error.noflight: :feature:upload) | A SkyLines upload answered with an empty results list (or a result without a flight) crashes the app: result!.flights.first!.flightId | Modules/App/Sources/Support/Upload/SkyLines/SkyLinesUploadViewModel.swift:94 | guard let flightId = result?.flights.first?.flightId else { fail with UploadError(skylines.labels.error.noflight) }. Test: {"results":[]} → the no-flight message, no crash |
| B48 | accepted (owner, 2026-10-12): the iOS fix is to be made; Android applies it, FIXED(B48) in :feature:soaringspot: countries sorted by their localized name with the locale’s Collator (was PARITY) | The Soaring Spot filter’s countries are sorted by their code (name holds the code), while the tiles show the localized country name, so the list reads out of order (“Belgium” after “Austria” but “Germany” (DE) before “Denmark” (DK) …) | Packages/CompetitionScreens/Sources/CompetitionScreens/SoaringSpot/Filter/SoaringSpotFilterViewModel.swift:116 (countries.sorted { $0.name < $1.name }) | Sort by the localized name the tile shows: countries.sorted { $0.localizedName.localizedStandardCompare($1.localizedName) == .orderedAscending }. Test: in English, DE “Germany” sorts after DK “Denmark” |
| B49 | accepted (owner, 2026-10-12): the iOS fix is to be made; Android applies it, FIXED(B49) (Android shows the calendar day: :feature:soaringspot) | A Soaring Spot day (yyyy-MM-dd from the backend) is read at midnight UTC and formatted in the device’s time zone, so west of UTC every day shows as the day before (“Jun 24” for the 25 June task) | Packages/CompetitionScreens/Sources/CompetitionScreens/SoaringSpot/Day/SoaringSpotDaySection.swift:21 (day.date.mediumDayString), the competition day rows likewise | Format the day in UTC (a formatter with timeZone = UTC), or keep it as a calendar date (DateComponents). Test: 2026-06-25 in America/New_York reads “Jun 25, 2026” |
B-candidates found in Phase 6
Section titled “B-candidates found in Phase 6”Decision P6-D4 of the Phase 6 plan copied both (PARITY) until the owner decided; the owner decided on 2026-10-11:
P6-2 stays as iOS, P6-3 became B39. (P6-1, the openAIP fragments, became B38.) Paths relative to vulture-ios/, main at 532cac007.
| # | Status | Bug | Where | Proposed fix |
|---|---|---|---|---|
| P6-2 | decided: keep (owner, 2026-10-11; PARITY on both platforms: hiding a sector on the map does not make it safe to fly through) | Hidden sectors are still scanned for infringements: hiding a sector takes it off the map but its crossings stay in the detail’s Infringements list | Packages/MapData/Sources/MapData/MapDataManager.swift:199-230 (airspaceFilesDidChange → parsedAirspaces keeps every file airspace; publishVisibleAirspaces); the scan in Modules/App/Sources/Map/Flight/Flight/FlightViewModel.swift:555-660 | None: kept. Android: ParsedAirspaces.all keeps them (:data:airspace) |
| P6-3 | became B39 (owner, 2026-10-11; fixed on Android) | The visible custom files are scanned while the map shows openAIP (also for a pilot without Pro, who cannot show them), so an airspace in both is listed twice under two ids (seen on the OBGN 2026 day: “TSA29B-TSA ARDENNES 07” from a Belgium file and from openAIP) | MapDataManager.swift:213-230 (publishVisibleAirspaces merges sortedParsedAirspaces and the tiles whatever ProAccess.showsOpenAIPAirspace says) | Scan only the source the map shows: the files when custom airspace is on, else the openAIP tiles. See B39 |
B-candidates found in Phase 5
Section titled “B-candidates found in Phase 5”P5-1, P5-2 and P5-3 became B31, B32 and B33 (owner, 2026-10-11). P5-4 was found porting iOS build 564 (the Debrief’s
sections, 2026-10-17) and is not decided: Android keeps iOS’s behaviour (PARITY). Paths relative to vulture-ios/.
| # | Status | Bug | Where | Proposed fix |
|---|---|---|---|---|
| P5-4 | decided 2026-10-20: fix on both (Calculation fixes §1); fixed on both 2026-10-20: iOS and Android read Thermal.heightLost (−altitudeLost); Android’s PARITY tags became FIXED(P5-4)) | Thermal.altitudeLost is the sum of the drops between consecutive fixes, so it is at most 0; every reader asks for altitudeLost > 0: the Debrief’s “Where I lost height in thermals” (heightLost, build 564) never applies and never lists a thermal, and the rating’s decisions never deduct the 10 points for “more than a tenth of the height gained lost again” nor show the ”… lost again in thermals” fact | Packages/Flights/Sources/Flights/Models/Thermal.swift:143-156 (altitudeLost, loss += delta with delta < 0); read by Packages/FlightScreens/Sources/FlightScreens/FlightQuestion.swift (applies(to:), .heightLost), FlightQuestionAnswering+Breakdown.swift (heightLost(in:)) and FlightRating+Compute.swift (thermallingDeductions(in:)) | Read the loss as a positive height: -altitudeLost (or make altitudeLost return -loss and check its other readers, the thermal detail among them). Test: a thermal whose fixes go 1000, 1050, 1030, 1080 m lost 20 m, and a flight with such a thermal offers the question and lists it. Changes the decisions score of a flight that lost more than a tenth of its gains |
B-candidates found in Phase 4 (candidate, not decided)
Section titled “B-candidates found in Phase 4 (candidate, not decided)”Found while porting the flight map (Phase 4). On 2026-10-11 the owner decided P4-1 → B17, P4-2 → B18, P4-3 → B19,
P4-6 → B20, P4-7 → B21, and four of P4-4’s barogram quirks → B22–B25 (rows above). What is left is not decided:
Android keeps iOS’s behaviour (PARITY). Paths are relative to vulture-ios/.
| # | Status | Bug | Where | Proposed fix |
|---|---|---|---|---|
| P4-4 | candidate, not decided (copied, PARITY) | The remaining barogram quirks (decision D15): valueOffset from the metre minimum on a feet axis; event marker Y in the altitude unit; wind before the first estimate shows the last one (and ignores the recording range); wind minimum ceiling and ≈ 50 grid lines for vertical air movement in ft/min; optimisation badges raised next to hidden task badges; the haptic tick every 5 progress units | Modules/App/Sources/Map/Flight/Panel/Graph/GraphDataSource.swift, GraphFlightDataView.swift; full list with references in domain/barogram/README.md “Parity quirks copied” and 05 §9 #9–11 | Per item. Android would drop the PARITY tag and follow |
| P4-5 | candidate, not decided (copied, PARITY) | The flight map’s copy of the zone code truncates the outer bisector’s two leg bearings to whole degrees (Int(bearing)), so a symmetric zone on the map can be rotated by up to 1° compared with the zone the scoring uses | Modules/App/Sources/Map/Extensions/Waypoint/Waypoint+Features.swift (vs Packages/Waypoints) | Use the scoring’s zone outline (Double bearings) on the map. Android: zoneCoordinates(…, wholeDegreeBisector = true) (domain/flightmap/README.md) |
C. Domain quirks you choose to fix (decision 1)
Section titled “C. Domain quirks you choose to fix (decision 1)”Do these only for the quirks marked Fix in docs/decisions.md. Each changes analysed
numbers, so re-run A1 afterwards.
| Quirk | Where |
|---|---|
Q1 Landing detection reads fixes[count − m] | Packages/Flights/Sources/Flights/Enricher/FlightTimesEnricher.swift:121. Android needed two changes (match them): read the intended late fix, and flip the duration subtraction so the backwards landing scan mirrors the takeoff rule; fixing only the index makes the duration check impossible. Effect on the fixtures: only para_freeflight lands earlier (09:29:57 → 09:29:19). |
| Q2 Release index one too low | FlightTimesEnricher.swift:154-155 (correctedIndex = index + takeoffIndex → + 1). Effect: release one fix later on glider fixtures; wim40 (winch) 12:18:41 → 12:23:01; iv_egc_task6 no longer finds a release (a 9 s sink is shorter than the 10 s rule); wim1000 thermalling time 12588 → 12587 s. |
| Q3 Midnight wrap compares with the first fix only | Packages/Flights/Sources/Flights/Models/Fix.swift:136. Android’s rule (match it): a fix whose time is more than 12 h earlier than the previous fix’s has crossed midnight UTC and gets +86 400 s; a smaller step back stays as it is and gets the out-of-order warning. |
Q8 maximumVario starts at leastNormalMagnitude (Android: flight-level vario and total-energy maxima start at -infinity; a thermal’s relative strength is 0 when no thermal climbed) | Packages/Flights/Sources/Flights/Enricher/FlightPhasesEnricher.swift:104, Models/Flight.swift:182,184 (start at -.infinity, guard all-negative) |
| Q12 C declaration with a trailing task name is ignored | Models/Flight.swift:262 (allow trailing text after the 2-digit count) |
Q13 HFDTEDATE:ddmmyy without a comma gives no date | HeaderPrefix.swift / String+Parsing.swift:21-57 |
| Q14 Generated filename and recording dates use the device time zone | Extensions/Date+Format.swift:20, Models/Flight.swift:518-534 (use UTC) |
Q21 ClosedRange with lower > upper crashes | the optimiser’s optimise, refinePositions and closestIndex (FlightDistanceEnricher.swift, DistanceEnricher.swift, FlightTaskEnricher.swift): guard before building the range |
| Q23 Elevation timeout: a tile still loading after 10 s stays empty (0 m terrain) while the flight counts as having elevations | Packages/Flights/Sources/Flights/Elevation/Elevation.swift:75 (group.wait(timeout:)): when the wait times out, return nil like a failure, so the flight has no elevations. |
D. vulture-web
Section titled “D. vulture-web”| What | Why |
|---|---|
/.well-known/assetlinks.json with the Android package name and signing-key SHA-256. Done in vulture-web commit adc7fff (app/asset_links.rb, debug fingerprint only); the owner deploys it and adds the release fingerprints to RELEASE_FINGERPRINTS once Play App Signing exists (vulture-android/docs/app-links.md) | Android App Links for vulture.aero/open/* (Soaring Spot links) |
API_TOKEN for AndroidVULTURE_API_TOKEN | The token ships inside the app; separate tokens would let you rotate one platform |
