Skip to content

03 — Persistence, settings, sync, accounts & monetisation

Moved from vulture-android/docs/analysis/03-persistence-settings-sync.md

Analysis of the iOS app (/Users/fousa/Projects/vulture-ios, read-only) and its backend (/Users/fousa/Projects/vulture-web) for the native Android port (Kotlin, Compose, Material 3 Expressive). Scope: Packages/Persistence, Packages/Preferences, Packages/SettingsScreens, Packages/Backend, Packages/Analytics, Packages/RemoteContent, Modules/App/Sources/Services, keychain/credentials. Android syncs via Google, not iCloud; iOS sync is documented only so its semantics can be mirrored.

Secrets (API keys, tokens, client secrets) are deliberately not copied into this document; their file locations are listed in §7 / §5.

  1. Store: Core Data (not SwiftData), one model version, 9 entities, of which 2 are dead legacy (Flight, FlightData). IGC files live inside SQLite as a String (CoreFlightData.rawContent); waypoint/airspace files likewise (File.content). Everything is mirrored to CloudKit by NSPersistentCloudKitContainer (container iCloud.be.fousa.vulture.cloud), plus a second “shared” store for task sharing.
  2. No stable identities: CloudKit has no unique constraints, so iOS de-duplicates after import (flights by filename + IGC text, homebases by name, tasks by name + importedAt ±1 s). Android should make identity structural: flight id = SHA-256 of the IGC.
  3. Settings sync is a separate channel: ~60 keys mirrored to iCloud Key-Value Store with per-key last-writer-wins; UI state, telemetry and service options stay local. Credentials are in the keychain, not synced.
  4. No sync status UI exists on iOS at all.
  5. Monetisation: RevenueCat, single entitlement all, monthly + yearly with free trials, 10 ProFeatures gated by one rule; free tier always on; the only remote switch is offering metadata show_paywall_on_first_launch. RevenueCat user id = CloudKit record name — Android needs a different identity.
  6. Backend vulture.aero is a stateless scraper/proxy (no DB, no accounts): it cannot host sync without becoming a new service. It lacks assetlinks.json for Android App Links.
  7. Recommendation: Firebase Auth (Google via Credential Manager) + Firestore + Cloud Storage, Room as source of truth, WorkManager replication, content-hash flight ids, per-key LWW settings doc; use the Firebase uid as RevenueCat app user id (§8).
AspectiOS implementationSource
FrameworkCore Data (not SwiftData), NSPersistentCloudKitContainer subclass GroupPersistentCloudKitContainerPackages/Persistence/Sources/Persistence/GroupPersistentCloudKitContainer.swift
ModelVulture.xcdatamodeld — one version (Vulture.xcdatamodel), usedWithCloudKit="YES"Packages/Persistence/Sources/Persistence/Model/Vulture.xcdatamodeld/
Store locationApp-group container group.be.fousa.vulture.group (debug: group.be.fousa.vulture.debug.group), Vulture.sqliteGroupPersistentCloudKitContainer.defaultDirectoryURL(), Packages/Preferences/Sources/Preferences/Vulture.swift
Second storeVulture-shared.sqlite beside it — CloudKit shared database scope (tasks other pilots shared with you). Not on watchOS.DataManager.swift (persistentContainer)
CloudKit containeriCloud.be.fousa.vulture.cloud, private DB zone com.apple.coredata.cloudkit.zone, record types prefixed CD_DataManager.cloudKitContainerIdentifier, Modules/App/Sources/Services/System/CloudArtworkCleanup.swift
Store optionspersistent history tracking ON, remote-change notifications ON, NSInferMappingModelAutomaticallyOption + NSMigratePersistentStoresAutomaticallyOption (lightweight migration only)DataManager.configure(_:)
Merge policymergeByPropertyObjectTrump on the view context and on the single long-lived background context; automaticallyMergesChangesFromParent = true; view context pinned to query generation .currentDataManager.swift
Transaction authorbe.fousa.vulture.transation (sic) — used to skip the app’s own historyVulture.transactionAuthor
History tokenArchived NSPersistentHistoryToken in <defaultDirectory>/Vulture/token.dataDataManager.lastHistoryToken
Read-only consumersWidgets open the same SQLite read-only with history tracking (no CloudKit)Packages/Persistence/Sources/Persistence/Widgets/WidgetStore.swift
Separate local storeRecorder.xcdatamodeld (v1, v2, v3 current) — plain NSPersistentContainer, local only, live GPS fixes while recordingPackages/FlightRecording/Sources/FlightRecording/Location/Model/, LocationRecorder.swift
  • Vulture.xcdatamodeld has a single model version. The schema has evolved in place through lightweight (inferred) migrations; there is no mapping model and no custom migration code (a repo-wide grep for migration code finds none outside Mapbox notes).
  • Because the store is CloudKit-mirrored, the schema is additive only in production: fields can never be removed from the CloudKit production schema. Example: build 546 added artworkLight/artworkDark/artworkVersion binary fields on CoreFlightData, Task, File; they were removed from the model on 2026-09-16 and CloudArtworkCleanup now clears those keys (CD_artworkLight, CD_artworkDark, CD_artworkVersion, *_ckAsset) from the user’s CloudKit records once per account (VultureCloudArtworkCleanedUp).
  • Entity-level “versions” are handled by a version attribute + fetch predicates rather than model versions:
    • File.versionPredicate: only version 0…0 is read.
    • Task.versionPredicate: only version 1…1 is read. Version 0 tasks stored turnpoints in the Waypoint relationship; version 1 stores them as an archived [Waypoints.Waypoint] blob in waypointsData. New tasks still write the legacy Waypoint rows on creation for old builds.
    • Flight / FlightData are legacy, dead entities kept only for CloudKit-schema compatibility; current code uses CoreFlight / CoreFlightData. No code migrates them (the Android port can ignore them).
  • Recorder.xcdatamodeld: v1 → v2 → v3, lightweight. Only v3 matters.

1.3 Entities (current model) — enough to define Room entities

Section titled “1.3 Entities (current model) — enough to define Room entities”

Notation: Opt = optional in Core Data. Scalars with a default are non-optional. “Transient” attributes are not stored.

CoreFlight — a logbook (or recents) flight’s metadata + cached analysis

Section titled “CoreFlight — a logbook (or recents) flight’s metadata + cached analysis”

Source: Model/CoreFlight.swift, Extensions/CoreData/CoreFlight+CoreData.swift, CoreFlight+Recents.swift

AttributeCore Data typeOptDefaultMeaning / written by
importedAtDateyes—Import time (makeFlight, addToLogbook)
updatedAtDateyes—Last recompute; dedup sort key (oldest kept)
lastOpenedAtDateyes—Non-nil = in “Recents” (max 50, CoreFlight.recentsLimit)
filenameStringyes—Original IGC filename; dedup narrowing key
pilotName, passengerNameStringyes—From IGC header
gliderRegistration, gliderTypeStringyes—From IGC header
competitionIdentification, competitionClassStringyes—From IGC header
isReadBoolnoNOUser state (unread filter)
isFavoriteBoolnoNOUser state
isContestBoolnoNOUser state
isFieldLandingBoolnoNOUser state
isRecordedByVultureBoolnoNORecorded by the in-app recorder
commentsStringyes—User notes
waterBalast (sic)Int16no0User input
takeoffAtDateyes—IGC date + takeoff fix time; year = list section
takeoffCode, takeoffName, takeoffCountryCodeStringyes—Nearest airport (ICAO, name, ISO)
isTakeoffProcessedBoolnoNOAirport lookup done
isTaskCompletedBoolnoNOBest task attempt completed
taskDeclaredDistance, taskCompletedDistance, taskAverageSpeedDoubleno0.0metres / m·s⁻¹
optimizedDistance, optimizedAverageSpeedDoubleno0.0Free-distance optimisation
fastestAverageSpeedDoubleno0.0Best 2 h speed
durationInt64no0seconds
altitudeGainedInt64no0metres
averageCruisingSpeed, bestThermalVarioDoubleno0.0
rightThermalCount, leftThermalCountInt64no0
trackPreviewBinaryyes—SimplifiedTrack encoding for list/gallery/widget thumbnails
trackPreviewVersionInt16no00 = none, <0 = cannot be generated
sectionTitleStringyes—transient (year string)
RelationshipDestToOptDelete ruleInverse
dataCoreFlightDataoneyesCascadeflight
recentDataCoreFlightDataoneyesCascaderecentFlight

Semantics: a flight with data != nil is a logbook flight (logbookPredicate). A flight opened from a service (WeGlide, SoaringSpot, …) but never added keeps its IGC in recentData (isRecentOnly) and only appears in Recents (recentsPredicate = lastOpenedAt != nil AND (data != nil OR recentData != nil)). “Add to logbook” moves recentData → data.

CoreFlightData — the heavy payload (the IGC file itself)

Section titled “CoreFlightData — the heavy payload (the IGC file itself)”

Source: Model/CoreFlightData.swift, Extensions/CoreData/CoreFlightData+Cache.swift

AttributeTypeOptMeaning
rawContentStringyesThe entire IGC file as text. This is where IGC files are stored — there are no IGC files on disk. CloudKit transparently stores large values as a CKAsset.
elevationsBinaryyesTerrain elevation per fix: [Int] archived with NSKeyedArchiver (Int+Archive.swift) — ~18 000 ints per flight
RelationshipDestOptDelete ruleInverse
flightCoreFlightyesNullifydata
recentFlightCoreFlightyesNullifyrecentData

Source: Model/Task.swift, Extensions/CoreData/Task+CoreData.swift

AttributeTypeOptDefaultMeaning
versionInt16no01 = current format
importedAtDateyes—Also the cross-device identity (with name), matched ±1 s
distanceDoubleno0.0metres
nameStringyes—
minimumAATDurationInt64no0seconds, 0 = not AAT
waypointsDataBinaryyes—NSKeyedArchiver of [Waypoints.Waypoint] (keys: name, code, country, latitude, longitude, elevation, style, runwayDirection, runwayLength, frequency, description, order, zone{type,isLine,isAAT,R1,A1,R2,A2,A12,maximumAltitude,minimumAltitude}, distance, bearing) — Packages/Waypoints/Sources/Waypoints/Models/Waypoint.swift, Zone.swift
waypointsStringStringyes—Comma-joined names for search
externalNameStringyes—Source competition/task name
externalURLURIyes—Legacy source URL
externalURLStringStringyes—Source URL (Soaring Spot / WeGlide); unique key on re-import
externalVersionStringyes—Source version
notesStringyes—
inactiveAirspaceStringyes—Airspaces deactivated for this task
isStartProcessedBoolnoNO
startCountryCodeStringyes—ISO of start airport (grouping by country)
sectionTitleStringyes—transient
RelationshipDestToDelete ruleInverse
waypointsWaypointmanyCascadetask (legacy v0 turnpoints)
AttributeTypeOptDefault
nameStringyes—
orderInt16no0
latitude, longitudeDoubleno0.0
zoneTypeInt16no0 (ZoneType: fixed, symmetric, next, previous, start)
isLine, isAATBoolnoNO
angleOne, angleTwo, angleTwelveInt16yes0
radiusOne, radiusTwoInt16yes0
task → Taskto-oneyesNullify

File — an imported or downloaded waypoint (CUP) or airspace (OpenAir/openAIP) file

Section titled “File — an imported or downloaded waypoint (CUP) or airspace (OpenAir/openAIP) file”

Source: Model/File.swift

AttributeTypeOptDefaultMeaning
versionInt16no0only 0 supported
importedAtDateyes—
filenameStringyes—
typeInt16no0FileType: 0 waypoints, 1 airspace
sourceInt16no0FileSource: 0 user, 1 xcsoar, 2 openaip
itemCountInt16no0number of waypoints/airspaces
contentStringyes—the whole file text (CUP gets a header prepended on read if missing)
isVisibleBoolnoNOdrawn on the map
contentUpdatedAtDateyes—remote file’s last update (auto-update check)
contentAreaStringyes—region/country of the remote file
sectionTitleStringno“local”transient (user/xcsoar/openaip)
AttributeTypeOptDefault
nameStringyes— (logical key)
latitude, longitudeDoubleno0.0
updatedAtDateyes— (most recent = current homebase)

HomebaseManager lists max 5, newest first, de-duplicated by name (CloudKit creates duplicates). Current homebase is also mirrored into app-group defaults (VultureHomebaseLatitude/Longitude) for widgets and into iCloud KVS (VultureHomebaseName/Latitude/Longitude). Default name shown when none: “Klippeneck”.

AttributeTypeOpt
idStringyes (Soaring Spot competition id)
name, location, countryStringyes
startDate, endDate, lastOpenedDateDateyes

Flight mirrors CoreFlight’s user fields plus content (IGC), flownAt, loggerType, version; FlightData has elevations, version. Relationship Flight.data ↔ FlightData.flight, Nullify both ways.

Local recorder store: FixRecord (Recorder-v3)

Section titled “Local recorder store: FixRecord (Recorder-v3)”
AttributeTypeOptDefault
recordedAtDateno—
latitude, longitude, gpsAltitude, speed, varioDoubleno0.0
bearingInt64yes0
heartRateInt16yes0
oxygenSaturationDoubleyes0.0
isPilotEventBoolnoNO
DataiOS locationSynced?
IGC fileCoreFlightData.rawContent (String) in SQLiteYes (CloudKit, as CKAsset when large)
Elevation cacheCoreFlightData.elevations (NSKeyedArchiver [Int])Yes (derivable — Android should not sync it)
Track thumbnailCoreFlight.trackPreview (SimplifiedTrack blob)Yes (derivable)
Waypoint/airspace filesFile.content (String)Yes — including openAIP/XCSoar downloads
Task turnpointsTask.waypointsData (NSKeyedArchiver blob)Yes
Gallery map artworkApplication Support, one folder per row, two JPEGs + version (Modules/App/Sources/Map/Gallery/GalleryArtworkDisk.swift)No (cache; Settings › Advanced clears)
Hidden airspacesDocuments <yyyyMMdd>-hidden-airspace.cache JSON (“hide today” vs permanent) (Packages/MapData/Sources/MapData/MapDataManager+HiddenAirspaces.swift)No
SkySight overlay / satellite tilesCaches dir (SkySightManager+OverlayCache.swift, SatelliteTileManager+Cache.swift)No
Live recording fixesRecorder Core Data storeNo
Backup exportZIP of IGCs per section, tasks as one .cup, files per type dir (Modules/App/Sources/Services/System/Backup.swift)Manual export

Do not copy the iOS quirks (blob-in-row, NSKeyedArchiver, no stable IDs). Proposed Room schema (all tables get sync columns, see §8.5):

@Entity(tableName = "flight", indices = [Index("contentHash", unique = true), Index("takeoffAt"), Index("lastOpenedAt")])
data class FlightEntity(
@PrimaryKey val id: String, // UUIDv7, generated on create — the sync identity
val contentHash: String, // SHA-256 of normalised IGC bytes — the dedup key (replaces filename+rawContent compare)
val filename: String?,
val inLogbook: Boolean, // replaces data vs recentData split
val importedAt: Instant?, val lastOpenedAt: Instant?,
val pilotName: String?, val passengerName: String?, val gliderRegistration: String?, val gliderType: String?,
val competitionId: String?, val competitionClass: String?,
val isRead: Boolean, val isFavorite: Boolean, val isContest: Boolean, val isFieldLanding: Boolean,
val isRecordedByVulture: Boolean, val comments: String?, val waterBallast: Int,
val takeoffAt: Instant?, val takeoffCode: String?, val takeoffName: String?, val takeoffCountryCode: String?,
val isTakeoffProcessed: Boolean,
// cached analysis (local, recomputable, NOT synced)
val isTaskCompleted: Boolean, val taskDeclaredDistance: Double, val taskCompletedDistance: Double, val taskAverageSpeed: Double,
val optimizedDistance: Double, val optimizedAverageSpeed: Double, val fastestAverageSpeed: Double,
val durationS: Long, val altitudeGainedM: Long, val averageCruisingSpeed: Double, val bestThermalVario: Double,
val rightThermalCount: Int, val leftThermalCount: Int,
val analysisVersion: Int, // recompute when the analysis engine changes
// sync bookkeeping
val updatedAt: Instant, val deleted: Boolean, val dirty: Boolean, val serverVersion: Long?, val igcUploaded: Boolean,
)
// IGC bytes: filesDir/igc/<contentHash>.igc (content-addressed). Elevation cache + track preview: separate local-only table or cacheDir.
@Entity(tableName = "task") data class TaskEntity(@PrimaryKey val id: String, val name: String?, val importedAt: Instant?,
val distanceM: Double, val minimumAatDurationS: Long, val turnpointsJson: String, /* kotlinx-serialization of Waypoint+Zone */
val searchText: String, val externalName: String?, val externalUrl: String?, val externalVersion: String?,
val notes: String?, val inactiveAirspace: String?, val startCountryCode: String?,
val updatedAt: Instant, val deleted: Boolean, val dirty: Boolean, val serverVersion: Long?)
@Entity(tableName = "map_file") data class MapFileEntity(@PrimaryKey val id: String, val type: Int /*0 waypoints,1 airspace*/,
val source: Int /*0 user,1 xcsoar,2 openaip*/, val filename: String?, val importedAt: Instant?, val itemCount: Int,
val isVisible: Boolean, val contentUpdatedAt: Instant?, val contentArea: String?, val contentHash: String,
val updatedAt: Instant, val deleted: Boolean, val dirty: Boolean, val serverVersion: Long?)
// file text in filesDir/mapfiles/<contentHash>; only source=user files sync their content, remote ones sync metadata only (re-download)
@Entity(tableName = "homebase", indices = [Index("name", unique = true)]) data class HomebaseEntity(@PrimaryKey val id: String,
val name: String, val lat: Double, val lon: Double, val updatedAt: Instant, val deleted: Boolean, val dirty: Boolean)
@Entity(tableName = "recent_competition") data class RecentCompetitionEntity(@PrimaryKey val id: String /* SoaringSpot id */,
val name: String?, val location: String?, val country: String?, val startDate: LocalDate?, val endDate: LocalDate?, val lastOpenedAt: Instant?)
@Entity(tableName = "fix_record") // local only, recorder
data class FixRecordEntity(@PrimaryKey(autoGenerate = true) val rowId: Long, val recordedAt: Instant, val lat: Double, val lon: Double,
val gpsAltitude: Double, val speed: Double, val vario: Double, val bearing: Long?, val heartRate: Int?, val spo2: Double?, val isPilotEvent: Boolean)

Room notes:

  • Keep IGC text out of SQLite (CursorWindow 2 MB limit, list scroll performance). iOS needed unloadContent() and a separate CoreFlightData entity precisely to avoid faulting the IGC into memory.
  • Unique index on contentHash makes the iOS dedup rule a DB constraint (iOS cannot have one because CloudKit forbids unique constraints).
  • Room @Relation not needed for turnpoints: store them as JSON (they are always read/written as a whole, like waypointsData).
  • Use Flow<List<…>> DAOs for lists; use Paging 3 for the logbook.

2. How iOS sync works (semantics to mirror, not to interoperate with)

Section titled “2. How iOS sync works (semantics to mirror, not to interoperate with)”

iOS has three independent sync channels, all Apple-only:

ChannelWhatMechanismSource
A. Core Data ↔ CloudKit private DBEvery entity of Vulture.xcdatamodel (flights + IGC, tasks, files, homebases, recent competitions)NSPersistentCloudKitContainer automatic mirroringDataManager.swift
B. Core Data ↔ CloudKit shared DBTasks shared between pilots (Notes-style sharing)NSPersistentCloudKitContainer.share(_:to:), acceptShareInvitations, second storeDataManager+Sharing.swift
C. iCloud Key-Value Store (NSUbiquitousKeyValueStore)A subset of preferences, flight declaration, current homebase, default observation zonesManual cloudStore.set on every write + didChangeExternallyNotification observer + pull on launchPreferences.swift (syncCloudSettings, cloudStoreDidChange), SharedDefaults.swift, Zone+Defaults.swift, HomebaseManager.swift

Credentials (keychain) and subscription state are covered in §5/§6.

DataSynced on iOSNotes
CoreFlight + CoreFlightData (IGC, elevations, thumbnails, all analysis fields)Yes (A)Includes recents-only flights (recentData) — recents are cross-device (“last opened on any device”)
Task (+ legacy Waypoint rows)Yes (A); shareable (B)
File (user, XCSoar, openAIP downloads incl. full text)Yes (A)Remote files are re-downloadable — wasteful to sync content
RecentHomebaseYes (A) + current homebase also via KVS (C)
RecentCompetitionYes (A)
Preferences marked “KVS” in §3Yes (C)~60 keys
All other preferences (list sort/filter/layout, panel state, play speed, graph unit, SkySight region/cache, review counters, paywall-shown flag, …)NoUserDefaults.standard
Recorder fixes, hidden airspaces, gallery artwork, SkySight cache, satellite tilesNodevice-local files/stores
Service credentialsKeychain — see §6
  • Record level (A): CloudKit mirroring is last-writer-wins per record as resolved by Core Data’s merge policy NSMergeByPropertyObjectTrumpMergePolicy — per property, in-memory (local) changes beat store values when a save conflicts. There is no field-level vector clock, no user-visible conflict UI.
  • Background context: one long-lived background context with the same policy; comment in DataManager.backgroundContext explains it exists because the default policy failed whole saves on stale objects after CloudKit imports.
  • KVS (C): last-writer-wins per key (Apple’s KVS semantics). On launch syncCloudSettings() prefers the cloud value when present; if the cloud has no value but local does, local is pushed up. On didChangeExternallyNotification the changed keys are applied locally and Combine subjects re-emit, and SwiftUI screens refresh via .onReceive (CLAUDE.md “iCloud sync observation”).
  • First-run seeds (AppDelegate.prepareDefaults) are written locally only, so a value arriving from iCloud still wins.
  • Shared tasks (B): received tasks are read-only (canEdit/canDelete return false for objects in the shared store); a participant can only “leave” the share. pruneRevokedShares() runs on scene activation and deletes received zones whose CKShare is gone/permission-revoked, because the store was observed not to learn of revocations.

2.3 De-duplication rules (because CloudKit has no unique constraints)

Section titled “2.3 De-duplication rules (because CloudKit has no unique constraints)”
EntityRuleWhere
CoreFlightAfter every remote import (persistent history, author ≠ app), for each inserted/updated CoreFlight: find others with the same filename and identical data.rawContent (or, if no filename, identical rawContent); keep the oldest by updatedAt, batch-delete the restDataManager.processPersistentHistory(), deduplicatedFlights(for:in:)
CoreFlight on importcreateFlight first looks up an existing flight with identical data.rawContent; a recents-only flight with the same content is promoted to the logbook instead of a new rowCoreFlight+CoreData.swift
CoreFlight recentsrecordAsRecent matches by filename + content in logbook, else recents-only by content; trims to 50; deletes “stranded” recents-only flights that arrived via iCloud with lastOpenedAt == nilCoreFlight+Recents.swift
TaskRe-import keyed on externalURLString/externalURL; cross-device identity = name + importedAt (±1 s, Double precision loss through iCloud)Task+CoreData.swift
RecentHomebaseKeyed by name on write; read path keeps newest per name, max 5HomebaseManager.recentHomebases()

Confirmed by the settings research (§4): there is no “iCloud” row, no last-synced indicator and no account-status display anywhere; iCloud only surfaces in task sharing (share menu, “Shared by you/with you” filters). There is no CloudKit progress indicator driven by NSPersistentCloudKitContainer.eventChangedNotification (no references in the code); sync is invisible except through data appearing and the external-change refreshes.

WhenWhatSource
didFinishLaunchingprepareDefaultsIfNeeded() (seed version 2, key VultureSeededDefaultsVersion), set applyCloudHomebase hook, Preferences.shared.syncCloudSettings()Modules/App/Sources/Application/AppDelegate.swift
after first frameAppStoreSubscription.shared.setup() (fetches the CloudKit user record to identify the RevenueCat user), SpotlightIndexer.start(), CloudArtworkCleanup.runIfNeeded()same
scene becomes activeDataManager.shared.pruneRevokedShares()Modules/App/Sources/Application/SceneDelegate.swift:195
remote changeprocessPersistentHistory → merge into view context → dedup flightsDataManager.swift
StoreWhatSource
Defaults = UserDefaults.standardAlmost every keyPackages/Preferences/Sources/Preferences/Defaults/Defaults.swift, Defaults+Vulture.swift
SharedDefaults = app-group suite group.be.fousa.vulture.groupSpeed/distance/temperature unit, flight declaration, homebase lat/lon (read by widgets, Watch, extensions)SharedDefaults.swift
iCloud KVS (NSUbiquitousKeyValueStore)Mirror of the “Sync = KVS” rows below, same key stringsPreferences.swift
KeychainService credentials (§6)KeychainServiceCredentials.swift

Gotchas to know when porting:

  • A missing Bool reads false, a missing Int reads 0 (which is why Int-enum prefs default to raw value 0). Real defaults come from first-run seeding in AppDelegate.prepareDefaults() (version key VultureSeededDefaultsVersion = 2), written locally only.
  • MapStyle.adaptive is stored as “key absent” locally but as raw 0 in iCloud.
  • Units are stored as indexes into per-category unit lists, not unit ids.
  • VultureShowTrack is shared by two different UI rows (Performance › Optimized track = None, and In-flight › Show current track). Split it into two keys on Android.
  • VultureFlarmPilot… keys in Defaults+Vulture.swift are legacy, migrated once into the VultureDeclared* keys in SharedDefaults.init().
  • The Unit-key names carry a historic SkySight prefix (VultureSkySightAltitudeUnit etc.) but apply app-wide.

Legend — Sync: KVS = synced through iCloud KVS on iOS; — = local only. Store: std = standard defaults, grp = app group.

User-facing settings (exposed in Settings)

Section titled “User-facing settings (exposed in Settings)”
KeyTypeDefaultAllowed valuesScreen › rowAffectsSyncStore
VultureAnnotationStyleInt enum Sport0 glider0 glider “Gliding”, 1 parapente “Paragliding”, 2 delta “Hanggliding”Your sport › tilesAircraft icon on maps/in-flight; analysis profile when auto-detect offKVSstd
VultureDetectSportAutomaticallyBooltrue (storedValue ?? true)Your sport › Detect sport automaticallyFlight parsing sport profile (parsingSportProfile)KVSstd
VultureSkySightWindSpeedUnitInt index0 → km/h[km/h, mph, kt]Units › WindspeedWind on flight map, SkySight overlay legendKVSstd
VultureSkySightVerticalSpeedUnitInt index0 → m/s[m/s, kt, ft/s]Units › Vertical speedVario, climb rates, SkySightKVSstd
VultureSkySightAltitudeUnitInt index0 → m[m, ft]Units › HeightAltitudes, barograph, SkySightKVSstd
VultureSpeedUnitInt index0 → km/h[km/h, mph, kt]Units › SpeedFlight speedsKVSgrp
VultureDistanceUnitInt index0 → km[km, mi, nm]Units › DistanceDistancesKVSgrp
VultureTemperatureUnitInt index0 → °C[°C, °F]Units › TemperatureOAT graphs, METAR widget (reloads widget timelines)KVSgrp
(homebase, Core Data RecentHomebase) + VultureHomebaseLatitude/LongitudeDoubleKlippeneck 48.1075, 8.7627778any waypointGeneral › Set your home baseInitial map location, widgets, METARKVS (VultureHomebaseName/Latitude/Longitude) + CloudKitgrp
VultureMapStyleInt enum MapStyle0 adaptive (key absent)0 Adaptive, 1 Outdoors, 2 Satellite, 3 Streets, 4 StandardMap › Map styleMapbox style for all mapsKVSstd
VultureOpenAIPAirspaceEnabledBooltrue (seeded on fresh install)Map › Airspace › Custom Airspace (inverted; Pro customAirspace)openAIP vector tiles vs imported airspace filesKVSstd
VultureShowHiddenAirspacesBoolfalseAirspace › Show hidden sectorsAirspace sheet lists hidden sectors greyedKVSstd
VultureAutomaticUpdateEnabledBoolfalseMap › Automatic updatesAuto re-download XCSoar files at launch vs alertKVSstd
VultureShowColoredTrackBooltrue (seeded)Flight display › Show colored trackAltitude-coloured trackKVSstd
VulturePinnedBoolfalseFlight display › Pin current positionMap follows glider in replayKVSstd
VultureShowWindBooltrue (seeded)Flight display › Show wind indicatorWind arrowsKVSstd
VultureArePhotosVisibleBoolfalseFlight display › Show your photosPhoto-library pins on flight mapKVSstd
VultureShowThermalsBooltrueThermals › Show thermalsThermal markersKVSstd
VultureShowThermalAttemptsBooltrueThermals › Show thermal attemptsFailed-climb markersKVSstd
VultureShowAltitudeGainedBooltruePerformance › Show flight eventsEvents on barographKVSstd
VultureShowTrackBooltruePerformance › Optimized track (None ⇒ false) and In-flight › Show current trackOptimised-distance drawing / in-flight heading lineKVSstd
VultureTrackOptimizationInt enum4 fiveTurnpoints (seeded)0 out (“Straight line”), 1 outAndReturn, 2 triangle, 3 threeTurnpoints, 4 fiveTurnpointsPerformance › Optimized trackWhich optimisation is drawn on map/graphKVSstd
VultureShowTaskBooltruePerformance › Show taskDeclared task + OZs on mapKVSstd
VultureShowOptimizedTaskBooltruePerformance › Show optimized task (disabled if task off)KVSstd
VultureShowAssignedTaskTimeBoolfalsePerformance › Show minimum AAT durationAAT line on graphKVSstd
VultureIsFlightPathVisibleBooltrueIn-flight › Show trackKVSstd
VultureIsOptimizedPathVisibleBooltrueIn-flight › Show optimized trackKVSstd
VultureIsTrackToWaypointVisibleBooltrueIn-flight › Show track to waypointKVSstd
VultureWaypointStartZone{Type,Line,R1,A1,R2,A2,A12}Int/Bool/Doublesymmetric(1), line=true, R1=1000, rest 0Type 0 fixed, 1 symmetric, 2 next, 3 previous, 4 start; R 0–50 000 m step 100; A 0–180°Task › Default observation zones (Start)Default OZ for new tasks and for flights whose task has no OZKVSstd
VultureWaypointZone{…}samesymmetric, line=false, R1=3000, A1=45same… (Waypoint)KVSstd
VultureWaypointFinishZone{…}samesymmetric, line=true, R1=1000same… (Finish)KVSstd
VultureDeclaredPilot / Passenger / GliderType / GliderRegistration / GliderCompetitionID / GliderCompetitionClassString?""free text; glider type from bundled glider DB (~668)Task › Flight declarationFLARM declaration, IGC header of recorded flights, profile cardKVSgrp
VultureFlarmLoggerIntervalInt41…8 sFlight declaration › Logger intervalFLARM configKVSstd
VultureFlarmCompetitionModeEnabledBoolfalseFlight declaration › Competition modeFLARM configKVSstd
VultureFlarmStealthModeEnabledBoolfalseFlight declaration › Stealth modeFLARM configKVSstd
VultureSkySightPersistOverlayBooltrue (seeded)Services › SkySight › Remember current weatherRestore last SkySight layer on new map—std
VultureSkySightOverlayOpacityFloat0.3 (read clamped ≥ 0.1)0.1…1.0 sliderServices › SkySight › TransparencyOverlay alphaKVSstd
VultureSkySightAutoDownloadSatelliteBoolfalseServices › SkySight › Auto-download satellite dataSatellite cloud prefetch on flight open—std
VultureSkyLinesShouldPublishBoolfalseServices › SkyLines › Publish on uploadUpload visibility—std
MGLMapboxMetricsEnabledBooltrue (seeded)Advanced › Mapbox telemetryMapbox telemetry—std
(Mixpanel opt-out)SDK stateopted inAdvanced › Client-Side TrackingAnalytics—Mixpanel

Implicit / UI-state preferences (not on a settings screen)

Section titled “Implicit / UI-state preferences (not on a settings screen)”
KeyTypeDefaultValuesWhere setSync
VultureGraphDataUnitInt enum GraphDataUnit0 pressureAltitude29 cases: pressureAltitude, gpsAltitude, groundspeed, vario, windSpeed, averageSpeed, averageTaskSpeed, totalEnergy, trueAirspeed, outsideAirTemperature, gForce, verticalAcceleration, verticalAirMovement, attitudeRollAngle, attitudePitchAngle, electricalVolts, electricalCurrent, fuelLevel, fuelFlow, temperatureExhaustGas, temperatureMotor, temperatureController, temperatureJetPipe, batteryLeft, flapPosition, engineRPM, meansOfPropulsionLevel, heartRate, bloodOxygenLevelFlight graph picker—
VulturePanelStateInt enum0 all0 all, 2 noneFlight map panel—
VulturePlaySpeedInt enum0 x10x10, x25, x50, x100, x200Replay speed—
VulturePhasesSelectionInt enum0 allall, events, thermals, legs, engineFlight detail phases tab—
VultureShowFlightLevelBoolfalseGraph/airspace FL display—
VultureIsTrackVisibleBooltrue (seeded)(no UI uses it)—
VultureIsRecordingBoolfalseRecorder running (resume after relaunch)—
VultureInFlightTaskStringniltask URIIn-flight selected task—
VultureLocationToName / Latitude / LongitudeString / DoublenilIn-flight “go to” target—
VultureSkySightEnabledBoolfalseSkySight overlay toggle on map—
VultureSkySightRegionStringnilSkySight region idSkySight region—
VultureSkySightCacheTimeIntervalDouble—secondsSkySight cache—
VultureTaskLegendCollapsed / VultureInFlightLegendCollapsedBoolfalseSkySight legend—
VultureTrackingModeInt0Map user-tracking mode—
VultureCharronSelectionIntcurrent year (seeded)yearCharron browser—
VultureSoaringSpotSelectionInt0Soaring Spot browser—
VultureListString—list idSidebar selected list—
VultureListFlightFilter / VultureListTaskFilter[String][]filter ids (FlightFilter: none, favorite, contest, fieldLanding, unread, recordedByVulture; task filters all/yours/soaringSpot/weGlide + shared-by-you/with-you)List filter menus—
VultureListFlightSort, VultureListFlight{Speed,Distance,Duration,Date}SortString / Bool (ascending)—Logbook sort—
VultureListRecentsSort, VultureListRecents{Opened,Speed,Distance,Duration,Date}SortString / Bool—Recents sort—
VultureListTaskSort, VultureListTask{Date,Distance}SortString / Bool—Task sort—
VultureListFlightGallery / TaskGallery / WaypointGallery / AirspaceGalleryBoolfalseGallery vs rows per list—
VultureListShowsHiddenFilesBoolfalseFile lists—
VultureListGalleryZoom (legacy VultureListFlightZoom)Int—Gallery card size—
VultureListVisibleBool—A list was on screen last (a compact window reopens it at launch)—
VultureDetailDestinationString“map” (seeded)Last detail screen—
VultureSplitDisplayModeInt—iPad split—
VultureInspectorPageString—Mac inspector page—
VultureLibraryExpanded / VultureDiscoverExpandedBooltrue (seeded)Sidebar sections—
Legacy: VultureFlightsFilter, VultureTaskDistanceSorting (seeded true), VultureTaskDateSorting, VultureTaskFilterOlder list—
VultureReviewTriggerCount / VultureReviewLastVersionInt / String0 / nilIn-app review prompt (Services/System/Review.swift)—
VultureHasShownFirstLaunchPaywallBoolfalseFirst-launch paywall shown. Android: in :data:billing (BillingPreferences, a Preferences DataStore next to the billing override), not in :data:prefs; the iOS import skips the key (P8-D15)—
VultureCloudArtworkCleanedUpBoolfalseiOS-only CloudKit cleanupn/a
VultureSeededDefaultsVersionInt2First-run seeding—
  • Proto DataStore with two protos: SyncedSettings (everything marked KVS, ~60 fields) and LocalSettings (UI state, device-specific: telemetry, tracking, SkySight cache/persist, list sorts, etc.). Proto gives typed enums (no index-into-list ambiguity) and atomic updates; expose Flow<SyncedSettings> and replace iOS’s Combine …DidChange publishers with Flow collection in ViewModels (stateIn).
  • Store units as enum names (KMH, MPH, KT), not indexes; keep an index↔enum map only if iOS interop is ever needed.
  • Seed defaults in the proto’s default values (not a seeding pass), except “openAIP on for fresh installs” — that’s just the proto default true.
  • Synced settings get a per-field updatedAt map in the sync doc (§8.4) to reproduce per-key LWW.
  • Declaration/homebase/units that widgets need: Glance widgets can read DataStore directly (same process), no app-group equivalent needed.

4. Settings screen hierarchy (to rebuild in Compose)

Section titled “4. Settings screen hierarchy (to rebuild in Compose)”

Root files: Packages/SettingsScreens/Sources/SettingsScreens/Settings/Main/*, app routing Modules/App/Sources/Features/Settings/Settings/Infrastructure/MainSettingsDestination+Controller.swift, provider AppMainSettings.swift, presenter SettingsPresenter.swift. Entry points: sidebar “Settings” and “Support”, gear on Flight/Task/In-flight maps, menu commands, deep link straight to a service sign-in (stack Main › Services › WeGlide).

Settings
├─ General
│ ├─ Identity card (pilot name, glider type · reg, avatar = comp ID / initials) → Flight declaration
│ ├─ Your sport (value) → Sport tiles + "Detect sport automatically"
│ ├─ Units → 6 segmented rows (wind, vertical speed, height, speed, distance, temperature)
│ └─ Set your home base (value) → searchable waypoint picker (Recent section, grouped by country)
├─ Map (footer: auto-download explanation)
│ ├─ Map style → picker with thumbnails (Adaptive/Outdoors/Satellite/Streets/Standard)
│ ├─ Airspace → Custom Airspace [Pro] toggle, Show hidden sectors toggle
│ └─ Automatic updates toggle
├─ Flight
│ ├─ Flight display → colored track, pin position, wind, photos
│ ├─ Thermals → show thermals, show attempts
│ ├─ Performance → flight events, optimized track (menu), show task, optimized task, AAT duration
│ └─ In-flight display (not on Mac) → track, optimized track, current track, track to waypoint
├─ Task
│ ├─ Default observation zones → Start/Waypoint/Finish selector, map preview, direction list, Line, R1/A1/R2/A2/A12 steppers, FAI / beercan presets
│ └─ Flight declaration → pilot, passenger, glider type (→ glider search), registration, comp no., class, FLARM interval/competition/stealth; share menu: Bluetooth device / Wi-Fi device / Export flarmcfg
├─ Services
│ └─ Connected services (subtitle lists connected) → SkySight | WeGlide | SkyLines | Charron sign-in screens
├─ Account
│ ├─ (no sub) Vulture Pro → paywall ; User ID
│ ├─ (sub) User ID ; Subscription valid until
│ ├─ Manage subscriptions (store link)
│ └─ Export all data (ZIP share)
└─ Advanced
├─ Clear Map Artwork (size, confirm)
├─ Clear Cloud Overlay Cache (size, confirm)
├─ Client-Side Tracking toggle (Mixpanel opt-in/out)
└─ Mapbox telemetry toggle
Support
├─ Application: Version, Website, FAQ, Send feedback (mailto me+vulture@fousa.be), Rate app, Privacy, Terms
├─ Mapbox: © Mapbox, © OpenStreetMap, Improve this map, © Maxar
├─ Services: WeGlide, SkyLines, Soaring Spot, Charron (links)
└─ Data: AVWX, SkySight, © openAIP, sunrise-sunset.org, OurAirports (links)

Detailed row tables (titles, footers, controls, keys) are in §3.2; additional screen facts:

  • Observation zone screen reloads on any external KVS change with prefix VultureWaypoint.
  • Flight declaration adopts the iCloud copy once on a fresh install if all local fields are blank (restoreFromCloud).
  • Every settings screen refreshes live on external sync changes (CLAUDE.md “iCloud sync observation”); on Android this is automatic when screens collect DataStore Flows.

Android additions (no iOS equivalent): an “Account & sync” section (Google account, sync on/off, sync IGC files on Wi-Fi only, last synced, sync now, sign out, delete account) — see §8.5; “Manage subscription” → https://play.google.com/store/account/subscriptions?sku=…&package=…; “Rate” → Play in-app review API.

  • Helper Packages/Utilities/Sources/Utilities/Keychain.swift: kSecClassGenericPassword, service = bundle id (debug and release separate), account = key string, UTF-8 string value. No access group, not kSecAttrSynchronizable → credentials are per device and are not synced through iCloud Keychain. Users re-enter them on every device.
  • Change signal: Preferences.notifyServicesDidChange() → servicesDidChange publisher (posted by WeGlide/SkyLines/Charron save/clear; not by SkySight). Consumers: Modules/App/Sources/Services/ConnectedServices.swift (single source of truth for “is X connected”), sidebar toolbar, import flights screen.
Account keyServiceNotes
VultureSkySightUsername / VultureSkySightPassword / VultureSkySightKeySkySightpassword present = authenticated; Key = session API key
VultureSkyLinesUsername / VultureSkyLinesPassword / VultureSkyLinesUserIdSkyLinesconnected = all three
VultureWeGlideAPIKey / VultureWeGlideUserId / VultureWeGlideDateOfBirth (yyyy-MM-dd)WeGlideLegacy VultureWeGlideAccessToken/RefreshToken/Name only cleared on sign-out
VultureCharronUsername / VultureCharronPasswordCharron.line
ServiceAuth modelSign-in flowSession / refreshSign-outUsed for
WeGlide (Packages/WeGlide)User API key (OAuth removed in commit 44d98efd6) + numeric user id + date of birthNo network round trip: fields trimmed and stored. Capability ladder (WeGlideCapability.swift): download flights/tasks need key + user id; upload needs + DOB. UI shows capabilities live as fields fillHeader X-API-Key, base https://api.weglide.org/v1/, TLS public-key pinning for weglide.org; no refreshClear all 6 keysGET flight (own/day/latest), GET flight/feed, GET flightdetail/{id} (IGC), GET task (own/starred/explore), POST igcfile (multipart file, user_id, date_of_birth), PATCH flightdetail/{id} comment “Uploaded with Vulture”. Stories disabled (needs partner OAuth)
SkyLines (Packages/SkyLines)Username/password, HTTP Basic on every request, base https://skylines.aero/apiStore → GET settings ping → store returned user id; on failure wipe and alertnoneClear username+passwordGET flights/pilot/{id}, flights/date/{d}, flights/latest; POST flights/upload (multipart files, pilotId); POST flights/{id} {"privacyLevel":0} when “Publish on upload”
Charron.line (Packages/Charron)Scraped website login, cookiesPOST {season}/login.php form gebruikersnaam, paswoord; success if redirected to overzicht.php. Season = year, +1 after SeptemberCookies (shared cookie storage)Clear both keysGET {season}/days.php, flights.php?day=; upload = re-login then open POST {season}/invoer2.php in a web view (multipart file1, copiloot, immatriculatie, motorzwever, wedstrijd, …). Bundled charron_gliders.json, charron_competitions.json (fuzzy match)
SkySight (Packages/SkySight, Modules/App/Sources/Services/SkySight/SkySightManager*.swift)Username/password → session keyPOST https://skysight.io/api/auth {username,password} with static app X-API-KEY → {key, allowed_regions}X-API-KEY: <session key> + Origin: https://skysight.io; interceptor re-auths once on 401, else clears; relogin at launch with network-monitor retries (3×)clearData(): keys + region + cache interval + overlay cache, isSkySightEnabled=falseGET regions, layers?region_id=, tiles on edge/cf.skysight.io, wind cloud function, satellite.skysight.io/tiles
Soaring Spotnone—via vulture.aero proxy (§7)—competitions, IGC downloads
openAIPnone (app API key)—vector tiles api.tiles.openaip.net/.../{z}/{x}/{y}.pbf?apiKey= (z 5–14)—live airspace
FLARMnonelocal Wi-Fi/Bluetooth——declaration/task upload, flight download

Dead code / gaps to not port: Vulture.WeGlide.clientId/clientSecret/redirectURL and the /auth/weglide universal link are unused (server returns 404). WeGlide never appears in the root Settings “Connected services” subtitle (its username is always nil — iOS bug).

  • Store secrets in a Proto/Preferences DataStore encrypted with Tink AEAD whose keyset is wrapped by an Android Keystore master key (tink-android AndroidKeysetManager). androidx.security:security-crypto (EncryptedSharedPreferences) is deprecated as of 2025; avoid it for new code.
  • Exclude the encrypted credential store from Auto Backup (dataExtractionRules), because Keystore keys are not backed up and restored ciphertext would be undecryptable. Behaviour then matches iOS: credentials are per device.
  • Optionally offer Credential Manager password save/retrieve for SkyLines/Charron/SkySight (Google Password Manager syncs them across the user’s Android devices) — an improvement over iOS.
  • Expose ConnectedServicesRepository : Flow<ConnectedServices> (replaces servicesDidChange).
  • Network: Retrofit/Ktor with per-service OkHttp interceptors (WeGlide X-API-Key + CertificatePinner for weglide.org; SkyLines Basic auth; SkySight X-API-KEY/Origin + Authenticator that re-auths once on 401; Charron CookieJar).
ItemValueSource
SDKRevenueCat, StoreKit 2Modules/App/Sources/Services/Subscriptions/AppStoreSubscription.swift
API keyper build config (debug/release) — Apple key; Android needs its own goog_… key on the same RevenueCat projectPackages/Preferences/Sources/Preferences/Vulture.swift
Productsbe.fousa.vulture.subscriptions.monthly, …yearly (debug: be.fousa.vulture.debug.subscriptions.*)Vulture.swift
Entitlementall (active, expirationDate, periodType == .trial)PurchaserInfo+Active.swift
Offeringofferings.current (no hard-coded id); fallback getProducts([monthly, yearly]); else SubscriptionError.noProductsAppStoreSubscription.swift
Offering metadatashow_paywall_on_first_launch (Bool, default false) — the only remote switchsame
App user idCloudKit user record name → Purchases.logIn(recordName); anonymous if no iCloudsame
RefreshgetCustomerInfo on every foreground; PurchasesDelegate updatessame
TrialsIntro offers freeTrial; eligibility via checkTrialOrIntroDiscountEligibilityModules/App/Sources/Features/Settings/Subscription/AppSubscriptionProvider.swift
Non-renewingsupported in UI as “paid once”paywall
Not presentStoreKit config files, family sharing, grandfathering, offer codes—
  • ProAccess.isFreeTierEnabled is compile-time true in release (debug -VultureHardPaywall turns it off → legacy hard paywall). ProAccessPolicy.isUnlocked(f) = !isFreeTierEnabled || isSubscribed; all features share one entitlement.
  • Gate API: ProAccess.shared.allows(feature, from:) (shows paywall when locked) and silent policy.isUnlocked / allowsFlights(count:).
ProFeaturePro unlocksFree keepsMain enforcement points (iOS)
severalFlights>1 flight on a map (competition day, multi-select, Compare, Siri compare)one flight at a timeSplitViewController.swift:396, ListViewController+Actions.swift:206, FlightMapViewModel.swift (allowsFlights)
customAirspaceown OpenAir/openAIP/CUB/XCSoar airspace files on mapopenAIP live airspace + warnings (showsOpenAIPAirspace = setting OR locked)ProAirspaceSettings.swift, MapViewDataSource+Airspace.swift
debrieffull rating breakdown + 25 Q&A, Siri analysisscore and bandFlightDebriefViewController.swift, AppIntents.swift
thermalAnalysisthermal sheet (climb, centring, 3D)thermals on mapThermalController.swift:35
taskPlanningcreate/edit tasks, scan QR into editoropen/fly/share existing & shared tasksListViewController+Actions.swift:90,105, TaskMapViewController.swift:1225
recordingin-flight recording, moving map, warnings, Live Activityresume a running recordingSplitViewController.startCreateFlight()
skySightSkySight overlays + satellite clouds (own account)—ProAccess.showsSkySight, map view controllers
statisticsyearly logbook statistics—ListViewController+Actions.swift:73
uploadsupload to WeGlide/SkyLines/Charrondownloads/imports stay freeListViewController+Menu.swift:242, UIViewController+Weglide.swift
taskOverrideput another task over flightsreset override, competition day taskFlightMapViewController.swift:627,2052

Free: logbook, imports (files, WeGlide, SkyLines, Soaring Spot, Charron), competitions, openAIP airspace, shared tasks, waypoints, iCloud sync.

Files: Packages/SettingsScreens/Sources/SettingsScreens/Subscription/* (SubscriptionView, SubscriptionViewModel, SubscriptionPlanRow, ProBenefitsView, ProBenefit, PaywallContext, PaywallHero), host Modules/App/Sources/Features/Settings/Subscription/SubscriptionHostingController.swift.

  • Layout: 3:2 hero (light/dark per context; hidden at accessibility sizes) → “VULTURE PRO” badge → title/subtitle per PaywallContext → “See everything in Pro ›” (benefits list) → plan rows (longest first, longest renewing preselected; ”%@ a month”, ”%@ free”, “Cancel anytime”/“Doesn’t renew”, “Save %@” if ≥ 5%) → CTA “Start free trial”/“Subscribe”/“Buy” → fine print (“Free for %1$@, then %2$@. Cancel anytime.” + “One subscription for iPhone, iPad and Mac.”) → Restore · Terms · Privacy. Processing overlay. Not swipe-dismissable; close button except hard-paywall launch.
  • Contexts/titles: welcome “Fly further with Vulture”; severalFlights “Put flights side by side”; customAirspace “Fly with your own airspace”; debrief “Read the whole Debrief”; thermalAnalysis “Look inside every thermal”; taskPlanning “Plan your own tasks”; recording “Record your flights”; skySight “SkySight weather on the map”; statistics “Your seasons in numbers” (reuses Debrief hero); uploads “Upload in one tap”; taskOverride “Override a flight’s task”. Strings in 8 locales (cs, de, en, es, fr, it, nl, pl) in Packages/DesignSystem/Sources/DesignSystem/Resources/Localizable.xcstrings (subscription.*).
  • Benefits page: intro “Your logbook, one flight at a time, openAIP airspace and shared tasks stay free. Pro adds:” then 10 rows (thermals, several flights, debrief, airspace, tasks, task override, recording, SkySight, statistics, uploads).
  • Triggers (PaywallTrigger, analytics trigger): launch, promoted, feature(), settings, firstLaunch. First-launch paywall shown once (VultureHasShownFirstLaunchPaywall) only if free tier on, not subscribed, offering metadata flag true.
  • com.revenuecat.purchases:purchases (+ optionally purchases-ui for RevenueCat Paywalls, but the iOS paywall is custom — rebuild it in Compose to keep parity and the per-feature hero contexts).
  • Google Play products: create subscription vulture_pro (Play subscriptions have base plans) with base plans monthly and yearly, free-trial offers on each; attach to entitlement all in the same RevenueCat project; add them to the current offering’s Android packages. Keep show_paywall_on_first_launch metadata on the offering.
  • App user id: iOS uses the CloudKit record name, which Android can never see. Recommendation: when the user signs in for sync, call Purchases.logIn(firebaseUid) on Android, and change iOS to logIn(firebaseUid) too when iOS joins Firebase — RevenueCat then shares the all entitlement across platforms. Until then Android subscribers are anonymous RevenueCat users (restore works per Google account). Note the iOS fine print “One subscription for iPhone, iPad and Mac” — Android copy must not promise cross-platform until identity is unified.
  • Manage subscription → Purchases.getCustomerInfo().managementURL or https://play.google.com/store/account/subscriptions?sku=<id>&package=<pkg>.
  • Play policy: restore button, clear trial terms, and an in-app account-deletion path (only relevant once accounts exist).
  • ProAccessPolicy ports 1:1 as a pure Kotlin class; isFreeTierEnabled as a BuildConfig flag.
  • Packages/Analytics/Sources/Analytics/Analytics.swift (reporter protocol, report(event:properties:)), Event.swift (enum, camelCase raw values = event names); Mixpanel adapter Modules/App/Sources/Services/Analytics/MixpanelAnalyticsReporter.swift (token there; trackAutomaticEvents: true). Release builds only; debug just logs. No identify/people/super properties. Opt-out via Settings › Advanced › Client-Side Tracking (Mixpanel optOutTracking, default opted in). 92 call sites.
  • Events (†: no call site found):
    • Force touch: forceTouchFlightPanelCell, forceTouchTurnpointListCell, forceTouchLegListCell
    • Screens: presentInFlightActionsPopup, presentPhaseSelectionPopup, presentEditFlightScreen†, presentInFlightScreen, presentHomebaseSelectionScreen, presentThermalSheet, presentAirspaceSheet, presentImportFlightSelectionPopup, presentCompareScreen, presentStatisticsScreen, presentInFlightSkySight
    • Functionality: toggleSkySightOverlay, toggleSatelliteOverlay, shareMapSnapshot, startMeasuring, openSoaringSpotCompetition, openCharronCompetitions, openFlightsFromFiles, downloadFlightFromFlarmOverWiFi, sendTaskToFlarmOverWiFi, selectGlidingSport†, selectParaglidingSport†, selectHangglidingSport†, markFlightAsFavorite, markFlightAsContest, markFlightAsFieldLanding, dropAFlightAndReplace, dropAFlightAndAdd, dropAFlightFromFlightList, dropAFlightFromOtherApp, dropATask, dragASelectionOutOfTheList, dropFilesOnTheList, dropFilesOnTheSidebar, centerThermal, centerPhase, centerInfoItem, centerTurnpoint, requestReview
    • Import: updateXCSoarFile, importWaypointFileFromDisk, importWaypointFileFromXCSoar, importAirspaceFileFromXCSoar, importAirspaceFileFromOpenAIP†, importFlightFromAction†, importWaypointsFromAction†, importTaskFromAction†, importAirspaceFromAction†
    • Share/upload: shareWaypointsAsSingleFile†, shareWaypointsAsMultipleFiles†, shareAirspaceAsSingleFile†, shareAirspaceAsMultipleFiles†, backupAllData, uploadFlightFromFlightPanelToSkyLines, …ToWeGlide, …ToCharron, uploadTaskFromTaskListToWeGlide†, shareTaskFromTaskList, shareTaskFromTaskPopup, shareTaskFromFlightPopup, saveTaskToVulture, shareTaskToCupFile, shareTaskToFlarm, shareTaskToLxNavNano
    • Task: renameWaypoint, cancelTask, cancelTaskDuringNavigation, duplicateTask, reverseWaypoints
    • Shortcuts/Siri/Spotlight: createTaskFromShortcut, openMapFromShortcut, startRecordingFromShortcut, stopRecordingFromShortcut, createTaskFromSiri, startRecordingFromSiri, stopRecordingFromSiri, openFlightFromSpotlight
    • Paywall (all with trigger): paywallShown, paywallClosed (outcome subscribed/dismissed), paywallPurchaseStarted/Cancelled (plan), paywallPurchaseFailed (plan, error), paywallPurchased (plan, isTrial), paywallRestoreStarted, paywallRestored, paywallRestoreFoundNothing, paywallRestoreFailed (error)
    • Open in Vulture: openFlightInVulture, openWaypointsInVulture, openAirspaceInVulture
  • Android: Mixpanel Android SDK behind the same AnalyticsReporter interface; keep event names identical so dashboards merge; add a platform super property (Mixpanel adds $os anyway). Drop iOS-only events (force touch, Siri, Spotlight) or map to Android equivalents (App Shortcuts, Assistant). Crash reporting: iOS has none (Sentry removed in 3e1ebb3c9); on Android, Play Console vitals is free; Crashlytics is near-free if Firebase is adopted for sync.
  • Mapbox telemetry (MGLMapboxMetricsEnabled, default on) → Mapbox Android telemetry API.

7.2 Backend: vulture.aero (/Users/fousa/Projects/vulture-web)

Section titled “7.2 Backend: vulture.aero (/Users/fousa/Projects/vulture-web)”
  • Stateless Sinatra 2.1 / Ruby 3.4 / Puma on Heroku: no database, no accounts, no user data — a scraper/proxy. All API calls: GET https://vulture.aero/api/... with Accept: application/json (else 406) and a static Authorization token compared to ENV['API_TOKEN'] (else 403). Token in Packages/Backend/Sources/Backend/VultureServiceConfiguration.swift and Packages/Flights/Sources/Flights/Service/ElevationServiceConfiguration.swift. No certificate pinning (removed after Let’s Encrypt rotations). No cache headers; API hits are logged to Umami.
EndpointUsed by iOS forUpstream
GET /api/xcsoar/waypointsDownload-waypoints list; launch update checkdownload.xcsoar.org HTML index
GET /api/xcsoar/airspaceDownload-airspace list; update checkXCSoar repository file
GET /api/openaip/airspaceeffectively unusedpublic GCS bucket
GET /api/elevations/:tile (N50E005)Terrain elevation per 1° tile when decoding flights302 → S3 vulture.s3.eu-central-1.amazonaws.com/elevations/<tile>.hgt.zip (SRTM3 1201², big-endian Int16), cached in Caches/elevations
GET /api/soaringspot/countriesSoaring Spot filterscrape
GET /api/soaringspot/competitions?country&year&pagelist/search/pagingscrape
GET /api/soaringspot/competitions/:idcompetition + classes/daysscrape
GET /api/soaringspot/competitions/:id/pilotspilots tabscrape
GET /api/soaringspot/competitions/:id/flights/:class/:dayday scoreboardscrape
GET /api/soaringspot/competitions/:id/task/:class/:dayday task; “update task” (Task externalURL stores this relative path — Android must keep the format since it syncs)scrape
GET /api/skysight (+Accept-Language)SkySight layer catalogue, cached 24 h (skysight-catalogue.cache) — the only server-driven “remote config”static Ruby catalogue
/api/open, /api/sgp/*not used by iOS—

Catalogue response: {"records":[{"name","uri","type":"waypoint|airspace","area","update":"yyyy-MM-dd"}]}; files are downloaded directly from uri (no headers) and parsed into File (§1.3) with upsert key filename + source + type; new files isVisible = true. Update check at launch: catalogue update > File.contentUpdatedAt → auto-update if VultureAutomaticUpdateEnabled, else an alert listing files.

Deep links: universal links https://vulture.aero/open/* (/open/soaringspot/flight/<id>, /open/soaringspot/results/<comp>[/<class>/<day>], /open/soaringspot/<comp>, /open/charronline/igc/<year>/<file>, /open/flight?id=), custom scheme vulture://. Android App Links need a new /.well-known/assetlinks.json route in vulture-web (only the Apple AASA is served, from app/association.rb).

Remote config: none beyond the RevenueCat offering metadata flag and the SkySight catalogue. No minimum-version/kill switch/announcements. Third-party hosts called directly: avwx.rest (METAR/TAF), api.sunrise-sunset.org, openAIP tiles, SkySight, WeGlide, SkyLines, Soaring Spot / Charron IGC downloads.

Fit as a sync backend: would need a database, per-user auth, blob storage and a more structured framework — essentially a new service. This is why §8 recommends Firebase instead of extending vulture-web. Security note from the research: vulture-web/docs/ contains what looks like a committed private key (myserver.key) for an old host — worth checking.

8. Recommendation — Google sync on Android

Section titled “8. Recommendation — Google sync on Android”
  1. Offline-first: the logbook, tasks and files must work with no network (glider pilots import at the airfield). Local DB is the source of truth; sync is background replication.
  2. Payload shape: many small metadata records + one large immutable blob per flight (IGC, typically 0.5–5 MB, text, compresses ~4–6x) + occasional large text files (CUP/OpenAir).
  3. Identity: iOS has none (CloudKit uses the Apple ID silently). Android needs an explicit Google sign-in.
  4. Dedup must be structural (iOS needs a post-import dedup pass because CloudKit has no unique constraints).
  5. Settings sync ≈ iCloud KVS: per-key last-writer-wins on ~60 keys.
  6. Nice-to-have: later let iOS sync to the same backend (cross-platform logbook), and share tasks between pilots (iOS feature B).
  7. Solo developer: minimal ops, no servers to patch, predictable cost.
CriterionA. Firebase Auth + Firestore + Cloud StorageB. Google Drive appDataFolderC. Credential Manager sign-in + own backend (vulture-web Rails + Postgres + S3/R2)
Sign-inCredential Manager GetGoogleIdOption → GoogleAuthProvider.getCredential(idToken) → Firebase Auth. Also supports Sign in with Apple, email linkCredential Manager for identity plus Identity.getAuthorizationClient().authorize() for the drive.appdata scope (Credential Manager alone does not grant API scopes)Credential Manager ID token → verify on server (Google JWKS) → own session/JWT
Offline-firstRoom as truth + Firestore SDK has its own offline cache/queued writes; listenersRoom as truth; you build everything (no queries, no listeners)Room as truth; you build the protocol
Change detectionReal-time snapshot listeners, queries on updatedAtchanges.list polling with page tokens; no server-side query on contentWhatever you build (cursor endpoint)
Blobs (IGC)Cloud Storage, resumable uploads, path per hashDrive files (multipart/resumable), user quotaS3/R2 presigned URLs
Conflict supportTransactions, server timestamps, field mergesETag/If-Match only, file granularityFull control
Cost to developerFirestore free tier (≈1 GiB stored, 50k reads/20k writes per day); Cloud Storage requires the Blaze plan (billing account) since late 2024; storage ≈ $0.02–0.026/GB-month + egress. ~1 000 active pilots × 150 MB gzipped IGC ≈ 150 GB ≈ $3–4/month storageZero: storage counts against the user’s Google quotaHosting + DB + object storage + your time
Privacy / GDPRYou are processor of flight tracks (location data) → privacy policy, account deletion flow (Play requires in-app + web deletion)Data stays in the user’s Drive, invisible to you; minimal GDPR surfaceSame as A, plus you secure the server
Google Play / OAuth reviewStandarddrive.appdata is a non-restricted scope but still needs OAuth consent-screen verification; brand verificationStandard
iOS joining laterFirst-class iOS SDK; Firebase Auth supports Sign in with Apple, so iOS users need no Google accountiOS users would need a Google account + GoogleSignIn + Drive REST — awkward for an Apple-first audienceEasy (it is your API), but most work
Sharing tasks between pilots (iOS B)Possible: top-level sharedTasks/{id} with ACL in security rulesDrive sharing of appData files is not possible (appDataFolder is private to the app)Possible
Solo-dev effortLow–mediumMedium (Drive REST ergonomics, polling, no queries)High
Lock-inMedium (Firestore data model is portable JSON; export via gcloud firestore export)LowNone

Also considered and rejected as the primary mechanism:

  • Android Auto Backup / Google One backup: free and automatic, but restore-on-new-device only (no live multi-device sync). Keep it as a safety net (include Room DB + DataStore; exclude anything encrypted with an Android Keystore key, which is not backed up and would fail to decrypt after restore).
  • Firestore offline cache as the only local store: tempting, but list queries over hundreds of flights with sort/filter/aggregate statistics, Paging, and widgets are far better served by Room; the Firestore cache can also be evicted. Use Room as truth.

8.3 Pick: Option A — Firebase Auth (Google via Credential Manager) + Cloud Firestore + Cloud Storage, with Room as the source of truth and WorkManager for replication.

Section titled “8.3 Pick: Option A — Firebase Auth (Google via Credential Manager) + Cloud Firestore + Cloud Storage, with Room as the source of truth and WorkManager for replication.”

Why: lowest effort for a solo developer, real-time cross-device updates, server timestamps and transactions for clean LWW, security rules instead of a server, and the only option that lets the iOS app join later without forcing Apple users onto Google (Firebase Auth supports Sign in with Apple; an iOS SyncEngine can coexist with or replace CloudKit). The cost is small and can be bounded by syncing IGC only for logbook flights and never syncing derivable data (elevations, thumbnails, remote openAIP/XCSoar file content).

Decision to take with the product owner: iOS iCloud sync is free (not a ProFeature). Android sync costs real money per user; options are (a) free for all with a soft cap, (b) sync as part of Pro. Recommended: free metadata/settings sync, IGC blob sync free up to N flights or for subscribers — decide before launch, since it shapes the paywall copy.

users/{uid} // profile: createdAt, lastSeenAt, schemaVersion, platform list
users/{uid}/flights/{flightId} // flightId = SHA-256(normalised IGC) hex -> structural dedup across devices
filename, inLogbook (bool), importedAt, lastOpenedAt,
pilotName, passengerName, gliderRegistration, gliderType, competitionId, competitionClass,
isRead, isFavorite, isContest, isFieldLanding, isRecordedByVulture, comments, waterBallast,
takeoffAt, takeoffCode, takeoffName, takeoffCountryCode,
summary { durationS, altitudeGainedM, optimizedDistance, optimizedAverageSpeed, fastestAverageSpeed,
taskDeclaredDistance, taskCompletedDistance, taskAverageSpeed, isTaskCompleted,
averageCruisingSpeed, bestThermalVario, leftThermalCount, rightThermalCount, analysisVersion },
igc { path: "users/{uid}/igc/{flightId}.igc.gz", sizeBytes, uploaded: bool },
updatedAt: serverTimestamp, updatedBy: deviceId, deleted: bool, deletedAt
users/{uid}/tasks/{taskId} // UUIDv7 generated on create
name, importedAt, distanceM, minimumAatDurationS, turnpoints: [ {name, code, country, lat, lon, elevation, style,
runwayDirection, runwayLength, frequency, description, order, zone{type,isLine,isAAT,r1,a1,r2,a2,a12,minAlt,maxAlt}} ],
externalName, externalUrl, externalVersion, notes, inactiveAirspace, startCountryCode, updatedAt, deleted
users/{uid}/mapFiles/{fileId}
type (waypoints|airspace), source (user|xcsoar|openaip), filename, importedAt, itemCount, isVisible,
contentArea, contentUpdatedAt, contentHash, blobPath (only for source=user), updatedAt, deleted
users/{uid}/homebases/{sha1(lowercased name)} // name-derived id -> no duplicates (iOS dedups by name at read time)
name, lat, lon, usedAt, updatedAt, deleted
users/{uid}/recentCompetitions/{soaringSpotId}
name, location, country, startDate, endDate, lastOpenedAt, updatedAt
users/{uid}/settings/synced // one doc ≈ iCloud KVS
values: { "<key>": { v: <value>, t: <epoch ms>, d: <deviceId> } , ... }
Storage: users/{uid}/igc/{flightId}.igc.gz ; users/{uid}/files/{contentHash}.gz

Security rules: allow read, write: if request.auth.uid == uid on users/{uid}/** (Firestore and Storage), plus size limits on Storage writes (e.g. < 20 MB) and content-type checks.

Local bookkeeping columns on every synced Room table: dirty (bool), localUpdatedAt, serverUpdatedAt (last seen server timestamp), deleted (tombstone). A sync_state table stores per-collection cursors (lastPulledServerTime) and the signed-in uid.

  1. Local write: in one Room transaction write the row, set dirty=1, localUpdatedAt=now; then WorkManager.enqueueUniqueWork("sync", APPEND_OR_REPLACE, OneTimeWorkRequest<SyncWorker>) with NetworkType.CONNECTED, exponential backoff, setExpedited where allowed.
  2. Push (SyncWorker) for each dirty row, oldest first, batched (≤ 500 writes per batch):
    • Flights: if igc.uploaded == false → gzip + putFile to users/{uid}/igc/{flightId}.igc.gz (idempotent path; retry-safe), then write the doc.
    • Write with a Firestore transaction: read the remote doc; if remote.updatedAt > row.serverUpdatedAt (someone else changed it since we last saw it) → merge (rules below) and write the merged result; else write local values. Always set updatedAt = FieldValue.serverTimestamp().
    • On success clear dirty, store the returned server timestamp.
  3. Pull: while the app is in the foreground, a snapshot listener per collection with whereGreaterThan("updatedAt", cursor); in background, a PeriodicWorkRequest (e.g. every 6 h, plus on app start) runs the same query once. For each remote doc in one Room transaction: if the local row is not dirty → overwrite; if dirty → merge and keep dirty for the next push. Advance the cursor to the max updatedAt seen (server timestamps only — never trust device clocks for cursors).
  4. Merge rules (mirror iOS semantics but deterministic):
    • Default: last-writer-wins per document by server updatedAt.
    • Flights: lastOpenedAt = max(local, remote); inLogbook = local || remote (adding to logbook never lost); user flags/comments LWW; summary fields: take the one with the higher analysisVersion; tombstone wins only if deletedAt ≥ the other side’s last edit.
    • Recents trimming (50) is applied locally after merge and pushed as lastOpenedAt = null / tombstone for recents-only flights — same as trimRecents.
    • Settings doc: merge per key by t (≈ KVS per-key LWW). First-run seeds are written locally with t = 0 so any cloud value wins (iOS behaviour).
  5. IGC download: lazy. Metadata (with summary) syncs first so the logbook list and statistics render immediately; the IGC is fetched when the flight is opened, or prefetched by a CONSTRAINT unmetered + charging worker. Store at filesDir/igc/{flightId}.igc.
  6. Deletes: tombstone docs (deleted=true), delete the Storage blob immediately; purge tombstones older than 90 days (client-side, on a periodic worker).
  7. Sign-in / sign-out / account switch: first sign-in on a device with local data → upload everything (flights dedup naturally by hash id). Sign-out → stop listeners, keep local data, clear sync_state. Signing into a different account → ask “Merge local data into this account” vs “Replace with this account’s data”.
  8. Account deletion (Play policy): in-app button → delete Storage prefix + Firestore subtree (Cloud Function onDelete or client-side recursive delete) + FirebaseAuth.currentUser.delete().
  9. Sync status UI: expose a StateFlow<SyncStatus> (Idle/Syncing(n)/Offline/Error(msg)/SignedOut, lastSyncedAt) from the worker + listeners; show it in Settings › Account & Sync and as a subtle indicator in the logbook. (iOS shows nothing; Android can do better at low cost.)
  • iOS adds Firebase Auth (Sign in with Apple or Google) and a FirestoreSyncEngine that maps CoreFlight/Task/File to the same documents; flight id = SHA-256 of the IGC (computable from rawContent), task id needs a new UUID attribute on Task (additive CloudKit change, fine).
  • Run both CloudKit and Firestore on iOS during a transition; CloudKit stays the iOS↔iOS channel until removed.
  • Use the Firebase uid as the RevenueCat appUserID on both platforms so one subscription unlocks both apps (see §6).
iOSAndroid
Core Data + NSPersistentCloudKitContainerRoom (KSP, Flow DAOs, Paging 3) as source of truth + Firestore/Cloud Storage replication (§8)
CoreFlightData.rawContent (IGC in SQLite)Content-addressed files filesDir/igc/<sha256>.igc; Room holds metadata only
NSKeyedArchiver blobs (waypointsData, elevations)kotlinx-serialization JSON for turnpoints; elevations as a local cache file/ByteArray (not synced)
Persistent history + remote-change merge + dedup passUnique indexes + deterministic ids; SyncWorker merges in a Room transaction
Recorder.xcdatamodeld (FixRecord)Separate Room DB or table, local only, written from a foreground Service (location type)
UserDefaults / app-group defaultsProto DataStore: SyncedSettings + LocalSettings (§3.3); Glance widgets read it directly
iCloud KVSusers/{uid}/settings/synced Firestore doc with per-key {v,t}
didChangeExternallyNotification → .onReceive → refreshFromSettings()ViewModels collect DataStore Flows (stateIn), no manual refresh
First-run seeding (VultureSeededDefaultsVersion)Proto field defaults; a seedVersion field for future one-off migrations
Keychain (generic password, per device)Tink AEAD + Android Keystore-wrapped keyset over DataStore; excluded from backup; optional Credential Manager password save
servicesDidChange publisherConnectedServicesRepository: StateFlow<ConnectedServices>
RevenueCat iOS (StoreKit 2)RevenueCat Android (Play Billing 7+), entitlement all, logIn(firebaseUid)
Mixpanel iOSMixpanel Android behind AnalyticsReporter; same event names
URLSession Networking packageOkHttp + Retrofit/Ktor; interceptors per service; Authenticator for SkySight 401
Background tasks (launch-time update check)WorkManager: SyncWorker (unique, network), periodic pull (6 h), IgcPrefetchWorker (unmetered+charging), MapFileUpdateWorker (daily, replaces launch check)
Backup ZIP exportSame ZIP via ACTION_CREATE_DOCUMENT / share sheet
Universal links vulture.aero/open/*, vulture://App Links (needs assetlinks.json on vulture-web) + vulture:// intent filter
#Risk / decisionImpactMitigation
1No Android↔iOS data interop at launch (CloudKit unreachable)A pilot with both platforms sees two logbooksMarket Android sync as Google-based; plan iOS Firebase adoption (§8.6); offer import of the iOS “Export all data” ZIP on Android as a one-shot migration
2Sync cost is borne by the developer (iCloud was free)Storage/egress grows with usersGzip IGCs, never sync derivable data or remote openAIP/XCSoar file content, lazy IGC download; decide free vs Pro sync before launch
3Flight location data on developer-controlled storageGDPR, Play Data safety form, account deletion requirementPrivacy policy update, in-app + web deletion, security rules per uid
4Identity for subscriptionsAndroid purchases not visible on iOS (and vice versa) until identities mergeUse Firebase uid as RevenueCat app user id; iOS later logIn(uid) (RevenueCat aliases/transfers purchases)
5iOS quirks that should not be copiedBugs carried overSplit VultureShowTrack into two settings; store unit enums not indexes; drop dead Flight/FlightData, WeGlide OAuth leftovers, unused isTrackVisible
6Task external URL format (relative soaringspot/competitions/<id>/task/<class>/<day>)Re-import/update breaks if changedKeep the format; it becomes cross-platform once iOS syncs to Firestore
7Secrets hard-coded in the iOS source (RevenueCat, Mixpanel, backend token, openAIP, SkySight app key, unused WeGlide secret)Leak/abuseOn Android put them in local.properties/CI secrets → BuildConfig; consider rotating the backend token per platform
8EncryptedSharedPreferences deprecation, Keystore keys not backed upLost credentials after restoreTink + backup exclusion (§5.3)
9Firestore 1 MiB document limit, 500-write batchesLarge tasks/files failIGC and file text in Cloud Storage only; turnpoints are small
10Task sharing (CKShare) has no Android equivalent yetFeature gapPhase 2: sharedTasks/{id} with participant uid list in rules; or share as .cup/QR (already exists on iOS)