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.
0. Key findings
Section titled “0. Key findings”- 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 byNSPersistentCloudKitContainer(containeriCloud.be.fousa.vulture.cloud), plus a second “shared” store for task sharing. - 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.
- 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.
- No sync status UI exists on iOS at all.
- Monetisation: RevenueCat, single entitlement
all, monthly + yearly with free trials, 10ProFeatures gated by one rule; free tier always on; the only remote switch is offering metadatashow_paywall_on_first_launch. RevenueCat user id = CloudKit record name — Android needs a different identity. - Backend vulture.aero is a stateless scraper/proxy (no DB, no accounts): it cannot host sync without becoming a new service. It lacks
assetlinks.jsonfor Android App Links. - 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).
1. Persistence — the Core Data store
Section titled “1. Persistence — the Core Data store”1.1 Stack overview
Section titled “1.1 Stack overview”| Aspect | iOS implementation | Source |
|---|---|---|
| Framework | Core Data (not SwiftData), NSPersistentCloudKitContainer subclass GroupPersistentCloudKitContainer | Packages/Persistence/Sources/Persistence/GroupPersistentCloudKitContainer.swift |
| Model | Vulture.xcdatamodeld — one version (Vulture.xcdatamodel), usedWithCloudKit="YES" | Packages/Persistence/Sources/Persistence/Model/Vulture.xcdatamodeld/ |
| Store location | App-group container group.be.fousa.vulture.group (debug: group.be.fousa.vulture.debug.group), Vulture.sqlite | GroupPersistentCloudKitContainer.defaultDirectoryURL(), Packages/Preferences/Sources/Preferences/Vulture.swift |
| Second store | Vulture-shared.sqlite beside it — CloudKit shared database scope (tasks other pilots shared with you). Not on watchOS. | DataManager.swift (persistentContainer) |
| CloudKit container | iCloud.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 options | persistent history tracking ON, remote-change notifications ON, NSInferMappingModelAutomaticallyOption + NSMigratePersistentStoresAutomaticallyOption (lightweight migration only) | DataManager.configure(_:) |
| Merge policy | mergeByPropertyObjectTrump on the view context and on the single long-lived background context; automaticallyMergesChangesFromParent = true; view context pinned to query generation .current | DataManager.swift |
| Transaction author | be.fousa.vulture.transation (sic) — used to skip the app’s own history | Vulture.transactionAuthor |
| History token | Archived NSPersistentHistoryToken in <defaultDirectory>/Vulture/token.data | DataManager.lastHistoryToken |
| Read-only consumers | Widgets open the same SQLite read-only with history tracking (no CloudKit) | Packages/Persistence/Sources/Persistence/Widgets/WidgetStore.swift |
| Separate local store | Recorder.xcdatamodeld (v1, v2, v3 current) — plain NSPersistentContainer, local only, live GPS fixes while recording | Packages/FlightRecording/Sources/FlightRecording/Location/Model/, LocationRecorder.swift |
1.2 Model versions and migrations
Section titled “1.2 Model versions and migrations”Vulture.xcdatamodeldhas 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/artworkVersionbinary fields onCoreFlightData,Task,File; they were removed from the model on 2026-09-16 andCloudArtworkCleanupnow 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
versionattribute + fetch predicates rather than model versions:File.versionPredicate: onlyversion0…0 is read.Task.versionPredicate: onlyversion1…1 is read. Version 0 tasks stored turnpoints in theWaypointrelationship; version 1 stores them as an archived[Waypoints.Waypoint]blob inwaypointsData. New tasks still write the legacyWaypointrows on creation for old builds.Flight/FlightDataare legacy, dead entities kept only for CloudKit-schema compatibility; current code usesCoreFlight/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
| Attribute | Core Data type | Opt | Default | Meaning / written by |
|---|---|---|---|---|
| importedAt | Date | yes | — | Import time (makeFlight, addToLogbook) |
| updatedAt | Date | yes | — | Last recompute; dedup sort key (oldest kept) |
| lastOpenedAt | Date | yes | — | Non-nil = in “Recents” (max 50, CoreFlight.recentsLimit) |
| filename | String | yes | — | Original IGC filename; dedup narrowing key |
| pilotName, passengerName | String | yes | — | From IGC header |
| gliderRegistration, gliderType | String | yes | — | From IGC header |
| competitionIdentification, competitionClass | String | yes | — | From IGC header |
| isRead | Bool | no | NO | User state (unread filter) |
| isFavorite | Bool | no | NO | User state |
| isContest | Bool | no | NO | User state |
| isFieldLanding | Bool | no | NO | User state |
| isRecordedByVulture | Bool | no | NO | Recorded by the in-app recorder |
| comments | String | yes | — | User notes |
| waterBalast (sic) | Int16 | no | 0 | User input |
| takeoffAt | Date | yes | — | IGC date + takeoff fix time; year = list section |
| takeoffCode, takeoffName, takeoffCountryCode | String | yes | — | Nearest airport (ICAO, name, ISO) |
| isTakeoffProcessed | Bool | no | NO | Airport lookup done |
| isTaskCompleted | Bool | no | NO | Best task attempt completed |
| taskDeclaredDistance, taskCompletedDistance, taskAverageSpeed | Double | no | 0.0 | metres / m·s⁻¹ |
| optimizedDistance, optimizedAverageSpeed | Double | no | 0.0 | Free-distance optimisation |
| fastestAverageSpeed | Double | no | 0.0 | Best 2 h speed |
| duration | Int64 | no | 0 | seconds |
| altitudeGained | Int64 | no | 0 | metres |
| averageCruisingSpeed, bestThermalVario | Double | no | 0.0 | |
| rightThermalCount, leftThermalCount | Int64 | no | 0 | |
| trackPreview | Binary | yes | — | SimplifiedTrack encoding for list/gallery/widget thumbnails |
| trackPreviewVersion | Int16 | no | 0 | 0 = none, <0 = cannot be generated |
| sectionTitle | String | yes | — | transient (year string) |
| Relationship | Dest | To | Opt | Delete rule | Inverse |
|---|---|---|---|---|---|
| data | CoreFlightData | one | yes | Cascade | flight |
| recentData | CoreFlightData | one | yes | Cascade | recentFlight |
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
| Attribute | Type | Opt | Meaning |
|---|---|---|---|
| rawContent | String | yes | The 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. |
| elevations | Binary | yes | Terrain elevation per fix: [Int] archived with NSKeyedArchiver (Int+Archive.swift) — ~18 000 ints per flight |
| Relationship | Dest | Opt | Delete rule | Inverse |
|---|---|---|---|---|
| flight | CoreFlight | yes | Nullify | data |
| recentFlight | CoreFlight | yes | Nullify | recentData |
Task — a planned/imported task
Section titled “Task — a planned/imported task”Source: Model/Task.swift, Extensions/CoreData/Task+CoreData.swift
| Attribute | Type | Opt | Default | Meaning |
|---|---|---|---|---|
| version | Int16 | no | 0 | 1 = current format |
| importedAt | Date | yes | — | Also the cross-device identity (with name), matched ±1 s |
| distance | Double | no | 0.0 | metres |
| name | String | yes | — | |
| minimumAATDuration | Int64 | no | 0 | seconds, 0 = not AAT |
| waypointsData | Binary | yes | — | 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 |
| waypointsString | String | yes | — | Comma-joined names for search |
| externalName | String | yes | — | Source competition/task name |
| externalURL | URI | yes | — | Legacy source URL |
| externalURLString | String | yes | — | Source URL (Soaring Spot / WeGlide); unique key on re-import |
| externalVersion | String | yes | — | Source version |
| notes | String | yes | — | |
| inactiveAirspace | String | yes | — | Airspaces deactivated for this task |
| isStartProcessed | Bool | no | NO | |
| startCountryCode | String | yes | — | ISO of start airport (grouping by country) |
| sectionTitle | String | yes | — | transient |
| Relationship | Dest | To | Delete rule | Inverse |
|---|---|---|---|---|
| waypoints | Waypoint | many | Cascade | task (legacy v0 turnpoints) |
Waypoint — legacy task turnpoint (v0)
Section titled “Waypoint — legacy task turnpoint (v0)”| Attribute | Type | Opt | Default |
|---|---|---|---|
| name | String | yes | — |
| order | Int16 | no | 0 |
| latitude, longitude | Double | no | 0.0 |
| zoneType | Int16 | no | 0 (ZoneType: fixed, symmetric, next, previous, start) |
| isLine, isAAT | Bool | no | NO |
| angleOne, angleTwo, angleTwelve | Int16 | yes | 0 |
| radiusOne, radiusTwo | Int16 | yes | 0 |
| task → Task | to-one | yes | Nullify |
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
| Attribute | Type | Opt | Default | Meaning |
|---|---|---|---|---|
| version | Int16 | no | 0 | only 0 supported |
| importedAt | Date | yes | — | |
| filename | String | yes | — | |
| type | Int16 | no | 0 | FileType: 0 waypoints, 1 airspace |
| source | Int16 | no | 0 | FileSource: 0 user, 1 xcsoar, 2 openaip |
| itemCount | Int16 | no | 0 | number of waypoints/airspaces |
| content | String | yes | — | the whole file text (CUP gets a header prepended on read if missing) |
| isVisible | Bool | no | NO | drawn on the map |
| contentUpdatedAt | Date | yes | — | remote file’s last update (auto-update check) |
| contentArea | String | yes | — | region/country of the remote file |
| sectionTitle | String | no | “local” | transient (user/xcsoar/openaip) |
RecentHomebase
Section titled “RecentHomebase”| Attribute | Type | Opt | Default |
|---|---|---|---|
| name | String | yes | — (logical key) |
| latitude, longitude | Double | no | 0.0 |
| updatedAt | Date | yes | — (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”.
RecentCompetition
Section titled “RecentCompetition”| Attribute | Type | Opt |
|---|---|---|
| id | String | yes (Soaring Spot competition id) |
| name, location, country | String | yes |
| startDate, endDate, lastOpenedDate | Date | yes |
Legacy (do not port): Flight, FlightData
Section titled “Legacy (do not port): Flight, FlightData”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)”| Attribute | Type | Opt | Default |
|---|---|---|---|
| recordedAt | Date | no | — |
| latitude, longitude, gpsAltitude, speed, vario | Double | no | 0.0 |
| bearing | Int64 | yes | 0 |
| heartRate | Int16 | yes | 0 |
| oxygenSaturation | Double | yes | 0.0 |
| isPilotEvent | Bool | no | NO |
1.4 Where blobs and files live
Section titled “1.4 Where blobs and files live”| Data | iOS location | Synced? |
|---|---|---|
| IGC file | CoreFlightData.rawContent (String) in SQLite | Yes (CloudKit, as CKAsset when large) |
| Elevation cache | CoreFlightData.elevations (NSKeyedArchiver [Int]) | Yes (derivable — Android should not sync it) |
| Track thumbnail | CoreFlight.trackPreview (SimplifiedTrack blob) | Yes (derivable) |
| Waypoint/airspace files | File.content (String) | Yes — including openAIP/XCSoar downloads |
| Task turnpoints | Task.waypointsData (NSKeyedArchiver blob) | Yes |
| Gallery map artwork | Application Support, one folder per row, two JPEGs + version (Modules/App/Sources/Map/Gallery/GalleryArtworkDisk.swift) | No (cache; Settings › Advanced clears) |
| Hidden airspaces | Documents <yyyyMMdd>-hidden-airspace.cache JSON (“hide today” vs permanent) (Packages/MapData/Sources/MapData/MapDataManager+HiddenAirspaces.swift) | No |
| SkySight overlay / satellite tiles | Caches dir (SkySightManager+OverlayCache.swift, SatelliteTileManager+Cache.swift) | No |
| Live recording fixes | Recorder Core Data store | No |
| Backup export | ZIP of IGCs per section, tasks as one .cup, files per type dir (Modules/App/Sources/Services/System/Backup.swift) | Manual export |
1.5 Room mapping proposal
Section titled “1.5 Room mapping proposal”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, recorderdata 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 separateCoreFlightDataentity precisely to avoid faulting the IGC into memory. - Unique index on
contentHashmakes the iOS dedup rule a DB constraint (iOS cannot have one because CloudKit forbids unique constraints). - Room
@Relationnot needed for turnpoints: store them as JSON (they are always read/written as a whole, likewaypointsData). - 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:
| Channel | What | Mechanism | Source |
|---|---|---|---|
| A. Core Data ↔ CloudKit private DB | Every entity of Vulture.xcdatamodel (flights + IGC, tasks, files, homebases, recent competitions) | NSPersistentCloudKitContainer automatic mirroring | DataManager.swift |
| B. Core Data ↔ CloudKit shared DB | Tasks shared between pilots (Notes-style sharing) | NSPersistentCloudKitContainer.share(_:to:), acceptShareInvitations, second store | DataManager+Sharing.swift |
C. iCloud Key-Value Store (NSUbiquitousKeyValueStore) | A subset of preferences, flight declaration, current homebase, default observation zones | Manual cloudStore.set on every write + didChangeExternallyNotification observer + pull on launch | Preferences.swift (syncCloudSettings, cloudStoreDidChange), SharedDefaults.swift, Zone+Defaults.swift, HomebaseManager.swift |
Credentials (keychain) and subscription state are covered in §5/§6.
2.1 What is synced vs local-only
Section titled “2.1 What is synced vs local-only”| Data | Synced on iOS | Notes |
|---|---|---|
| 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 |
| RecentHomebase | Yes (A) + current homebase also via KVS (C) | |
| RecentCompetition | Yes (A) | |
| Preferences marked “KVS” in §3 | Yes (C) | ~60 keys |
| All other preferences (list sort/filter/layout, panel state, play speed, graph unit, SkySight region/cache, review counters, paywall-shown flag, …) | No | UserDefaults.standard |
| Recorder fixes, hidden airspaces, gallery artwork, SkySight cache, satellite tiles | No | device-local files/stores |
| Service credentials | Keychain — see §6 |
2.2 Conflict handling
Section titled “2.2 Conflict handling”- 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.backgroundContextexplains 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. OndidChangeExternallyNotificationthe 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/canDeletereturn 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)”| Entity | Rule | Where |
|---|---|---|
| CoreFlight | After 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 rest | DataManager.processPersistentHistory(), deduplicatedFlights(for:in:) |
| CoreFlight on import | createFlight 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 row | CoreFlight+CoreData.swift |
| CoreFlight recents | recordAsRecent 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 == nil | CoreFlight+Recents.swift |
| Task | Re-import keyed on externalURLString/externalURL; cross-device identity = name + importedAt (±1 s, Double precision loss through iCloud) | Task+CoreData.swift |
| RecentHomebase | Keyed by name on write; read path keeps newest per name, max 5 | HomebaseManager.recentHomebases() |
2.4 Sync status in the UI
Section titled “2.4 Sync status in the UI”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.
2.5 Lifecycle hooks
Section titled “2.5 Lifecycle hooks”| When | What | Source |
|---|---|---|
didFinishLaunching | prepareDefaultsIfNeeded() (seed version 2, key VultureSeededDefaultsVersion), set applyCloudHomebase hook, Preferences.shared.syncCloudSettings() | Modules/App/Sources/Application/AppDelegate.swift |
| after first frame | AppStoreSubscription.shared.setup() (fetches the CloudKit user record to identify the RevenueCat user), SpotlightIndexer.start(), CloudArtworkCleanup.runIfNeeded() | same |
| scene becomes active | DataManager.shared.pruneRevokedShares() | Modules/App/Sources/Application/SceneDelegate.swift:195 |
| remote change | processPersistentHistory → merge into view context → dedup flights | DataManager.swift |
3. Preferences — complete table
Section titled “3. Preferences — complete table”3.1 Storage model on iOS
Section titled “3.1 Storage model on iOS”| Store | What | Source |
|---|---|---|
Defaults = UserDefaults.standard | Almost every key | Packages/Preferences/Sources/Preferences/Defaults/Defaults.swift, Defaults+Vulture.swift |
SharedDefaults = app-group suite group.be.fousa.vulture.group | Speed/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 strings | Preferences.swift |
| Keychain | Service credentials (§6) | KeychainServiceCredentials.swift |
Gotchas to know when porting:
- A missing Bool reads
false, a missing Int reads0(which is why Int-enum prefs default to raw value 0). Real defaults come from first-run seeding inAppDelegate.prepareDefaults()(version keyVultureSeededDefaultsVersion= 2), written locally only. MapStyle.adaptiveis stored as “key absent” locally but as raw0in iCloud.- Units are stored as indexes into per-category unit lists, not unit ids.
VultureShowTrackis 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 inDefaults+Vulture.swiftare legacy, migrated once into theVultureDeclared*keys inSharedDefaults.init().- The Unit-key names carry a historic
SkySightprefix (VultureSkySightAltitudeUnitetc.) but apply app-wide.
3.2 Table
Section titled “3.2 Table”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)”| Key | Type | Default | Allowed values | Screen › row | Affects | Sync | Store |
|---|---|---|---|---|---|---|---|
| VultureAnnotationStyle | Int enum Sport | 0 glider | 0 glider “Gliding”, 1 parapente “Paragliding”, 2 delta “Hanggliding” | Your sport › tiles | Aircraft icon on maps/in-flight; analysis profile when auto-detect off | KVS | std |
| VultureDetectSportAutomatically | Bool | true (storedValue ?? true) | Your sport › Detect sport automatically | Flight parsing sport profile (parsingSportProfile) | KVS | std | |
| VultureSkySightWindSpeedUnit | Int index | 0 → km/h | [km/h, mph, kt] | Units › Windspeed | Wind on flight map, SkySight overlay legend | KVS | std |
| VultureSkySightVerticalSpeedUnit | Int index | 0 → m/s | [m/s, kt, ft/s] | Units › Vertical speed | Vario, climb rates, SkySight | KVS | std |
| VultureSkySightAltitudeUnit | Int index | 0 → m | [m, ft] | Units › Height | Altitudes, barograph, SkySight | KVS | std |
| VultureSpeedUnit | Int index | 0 → km/h | [km/h, mph, kt] | Units › Speed | Flight speeds | KVS | grp |
| VultureDistanceUnit | Int index | 0 → km | [km, mi, nm] | Units › Distance | Distances | KVS | grp |
| VultureTemperatureUnit | Int index | 0 → °C | [°C, °F] | Units › Temperature | OAT graphs, METAR widget (reloads widget timelines) | KVS | grp |
(homebase, Core Data RecentHomebase) + VultureHomebaseLatitude/Longitude | Double | Klippeneck 48.1075, 8.7627778 | any waypoint | General › Set your home base | Initial map location, widgets, METAR | KVS (VultureHomebaseName/Latitude/Longitude) + CloudKit | grp |
| VultureMapStyle | Int enum MapStyle | 0 adaptive (key absent) | 0 Adaptive, 1 Outdoors, 2 Satellite, 3 Streets, 4 Standard | Map › Map style | Mapbox style for all maps | KVS | std |
| VultureOpenAIPAirspaceEnabled | Bool | true (seeded on fresh install) | Map › Airspace › Custom Airspace (inverted; Pro customAirspace) | openAIP vector tiles vs imported airspace files | KVS | std | |
| VultureShowHiddenAirspaces | Bool | false | Airspace › Show hidden sectors | Airspace sheet lists hidden sectors greyed | KVS | std | |
| VultureAutomaticUpdateEnabled | Bool | false | Map › Automatic updates | Auto re-download XCSoar files at launch vs alert | KVS | std | |
| VultureShowColoredTrack | Bool | true (seeded) | Flight display › Show colored track | Altitude-coloured track | KVS | std | |
| VulturePinned | Bool | false | Flight display › Pin current position | Map follows glider in replay | KVS | std | |
| VultureShowWind | Bool | true (seeded) | Flight display › Show wind indicator | Wind arrows | KVS | std | |
| VultureArePhotosVisible | Bool | false | Flight display › Show your photos | Photo-library pins on flight map | KVS | std | |
| VultureShowThermals | Bool | true | Thermals › Show thermals | Thermal markers | KVS | std | |
| VultureShowThermalAttempts | Bool | true | Thermals › Show thermal attempts | Failed-climb markers | KVS | std | |
| VultureShowAltitudeGained | Bool | true | Performance › Show flight events | Events on barograph | KVS | std | |
| VultureShowTrack | Bool | true | Performance › Optimized track (None ⇒ false) and In-flight › Show current track | Optimised-distance drawing / in-flight heading line | KVS | std | |
| VultureTrackOptimization | Int enum | 4 fiveTurnpoints (seeded) | 0 out (“Straight line”), 1 outAndReturn, 2 triangle, 3 threeTurnpoints, 4 fiveTurnpoints | Performance › Optimized track | Which optimisation is drawn on map/graph | KVS | std |
| VultureShowTask | Bool | true | Performance › Show task | Declared task + OZs on map | KVS | std | |
| VultureShowOptimizedTask | Bool | true | Performance › Show optimized task (disabled if task off) | KVS | std | ||
| VultureShowAssignedTaskTime | Bool | false | Performance › Show minimum AAT duration | AAT line on graph | KVS | std | |
| VultureIsFlightPathVisible | Bool | true | In-flight › Show track | KVS | std | ||
| VultureIsOptimizedPathVisible | Bool | true | In-flight › Show optimized track | KVS | std | ||
| VultureIsTrackToWaypointVisible | Bool | true | In-flight › Show track to waypoint | KVS | std | ||
| VultureWaypointStartZone{Type,Line,R1,A1,R2,A2,A12} | Int/Bool/Double | symmetric(1), line=true, R1=1000, rest 0 | Type 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 OZ | KVS | std |
| VultureWaypointZone{…} | same | symmetric, line=false, R1=3000, A1=45 | same | … (Waypoint) | KVS | std | |
| VultureWaypointFinishZone{…} | same | symmetric, line=true, R1=1000 | same | … (Finish) | KVS | std | |
| VultureDeclaredPilot / Passenger / GliderType / GliderRegistration / GliderCompetitionID / GliderCompetitionClass | String? | "" | free text; glider type from bundled glider DB (~668) | Task › Flight declaration | FLARM declaration, IGC header of recorded flights, profile card | KVS | grp |
| VultureFlarmLoggerInterval | Int | 4 | 1…8 s | Flight declaration › Logger interval | FLARM config | KVS | std |
| VultureFlarmCompetitionModeEnabled | Bool | false | Flight declaration › Competition mode | FLARM config | KVS | std | |
| VultureFlarmStealthModeEnabled | Bool | false | Flight declaration › Stealth mode | FLARM config | KVS | std | |
| VultureSkySightPersistOverlay | Bool | true (seeded) | Services › SkySight › Remember current weather | Restore last SkySight layer on new map | — | std | |
| VultureSkySightOverlayOpacity | Float | 0.3 (read clamped ≥ 0.1) | 0.1…1.0 slider | Services › SkySight › Transparency | Overlay alpha | KVS | std |
| VultureSkySightAutoDownloadSatellite | Bool | false | Services › SkySight › Auto-download satellite data | Satellite cloud prefetch on flight open | — | std | |
| VultureSkyLinesShouldPublish | Bool | false | Services › SkyLines › Publish on upload | Upload visibility | — | std | |
| MGLMapboxMetricsEnabled | Bool | true (seeded) | Advanced › Mapbox telemetry | Mapbox telemetry | — | std | |
| (Mixpanel opt-out) | SDK state | opted in | Advanced › Client-Side Tracking | Analytics | — | Mixpanel |
Implicit / UI-state preferences (not on a settings screen)
Section titled “Implicit / UI-state preferences (not on a settings screen)”| Key | Type | Default | Values | Where set | Sync |
|---|---|---|---|---|---|
| VultureGraphDataUnit | Int enum GraphDataUnit | 0 pressureAltitude | 29 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, bloodOxygenLevel | Flight graph picker | — |
| VulturePanelState | Int enum | 0 all | 0 all, 2 none | Flight map panel | — |
| VulturePlaySpeed | Int enum | 0 x10 | x10, x25, x50, x100, x200 | Replay speed | — |
| VulturePhasesSelection | Int enum | 0 all | all, events, thermals, legs, engine | Flight detail phases tab | — |
| VultureShowFlightLevel | Bool | false | Graph/airspace FL display | — | |
| VultureIsTrackVisible | Bool | true (seeded) | (no UI uses it) | — | |
| VultureIsRecording | Bool | false | Recorder running (resume after relaunch) | — | |
| VultureInFlightTask | String | nil | task URI | In-flight selected task | — |
| VultureLocationToName / Latitude / Longitude | String / Double | nil | In-flight “go to” target | — | |
| VultureSkySightEnabled | Bool | false | SkySight overlay toggle on map | — | |
| VultureSkySightRegion | String | nil | SkySight region id | SkySight region | — |
| VultureSkySightCacheTimeInterval | Double | — | seconds | SkySight cache | — |
| VultureTaskLegendCollapsed / VultureInFlightLegendCollapsed | Bool | false | SkySight legend | — | |
| VultureTrackingMode | Int | 0 | Map user-tracking mode | — | |
| VultureCharronSelection | Int | current year (seeded) | year | Charron browser | — |
| VultureSoaringSpotSelection | Int | 0 | Soaring Spot browser | — | |
| VultureList | String | — | list id | Sidebar 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}Sort | String / Bool (ascending) | — | Logbook sort | — | |
| VultureListRecentsSort, VultureListRecents{Opened,Speed,Distance,Duration,Date}Sort | String / Bool | — | Recents sort | — | |
| VultureListTaskSort, VultureListTask{Date,Distance}Sort | String / Bool | — | Task sort | — | |
| VultureListFlightGallery / TaskGallery / WaypointGallery / AirspaceGallery | Bool | false | Gallery vs rows per list | — | |
| VultureListShowsHiddenFiles | Bool | false | File lists | — | |
| VultureListGalleryZoom (legacy VultureListFlightZoom) | Int | — | Gallery card size | — | |
| VultureListVisible | Bool | — | A list was on screen last (a compact window reopens it at launch) | — | |
| VultureDetailDestination | String | “map” (seeded) | Last detail screen | — | |
| VultureSplitDisplayMode | Int | — | iPad split | — | |
| VultureInspectorPage | String | — | Mac inspector page | — | |
| VultureLibraryExpanded / VultureDiscoverExpanded | Bool | true (seeded) | Sidebar sections | — | |
| Legacy: VultureFlightsFilter, VultureTaskDistanceSorting (seeded true), VultureTaskDateSorting, VultureTaskFilter | Older list | — | |||
| VultureReviewTriggerCount / VultureReviewLastVersion | Int / String | 0 / nil | In-app review prompt (Services/System/Review.swift) | — | |
| VultureHasShownFirstLaunchPaywall | Bool | false | First-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) | — | |
| VultureCloudArtworkCleanedUp | Bool | false | iOS-only CloudKit cleanup | n/a | |
| VultureSeededDefaultsVersion | Int | 2 | First-run seeding | — |
3.3 Android mapping for preferences
Section titled “3.3 Android mapping for preferences”- Proto DataStore with two protos:
SyncedSettings(everything marked KVS, ~60 fields) andLocalSettings(UI state, device-specific: telemetry, tracking, SkySight cache/persist, list sorts, etc.). Proto gives typed enums (no index-into-list ambiguity) and atomic updates; exposeFlow<SyncedSettings>and replace iOS’s Combine…DidChangepublishers 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
updatedAtmap 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.
5. Connected services and credentials
Section titled “5. Connected services and credentials”5.1 Storage on iOS
Section titled “5.1 Storage on iOS”- 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, notkSecAttrSynchronizable→ credentials are per device and are not synced through iCloud Keychain. Users re-enter them on every device. - Change signal:
Preferences.notifyServicesDidChange()→servicesDidChangepublisher (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 key | Service | Notes |
|---|---|---|
| VultureSkySightUsername / VultureSkySightPassword / VultureSkySightKey | SkySight | password present = authenticated; Key = session API key |
| VultureSkyLinesUsername / VultureSkyLinesPassword / VultureSkyLinesUserId | SkyLines | connected = all three |
VultureWeGlideAPIKey / VultureWeGlideUserId / VultureWeGlideDateOfBirth (yyyy-MM-dd) | WeGlide | Legacy VultureWeGlideAccessToken/RefreshToken/Name only cleared on sign-out |
| VultureCharronUsername / VultureCharronPassword | Charron.line |
5.2 Login flows
Section titled “5.2 Login flows”| Service | Auth model | Sign-in flow | Session / refresh | Sign-out | Used for |
|---|---|---|---|---|---|
WeGlide (Packages/WeGlide) | User API key (OAuth removed in commit 44d98efd6) + numeric user id + date of birth | No 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 fill | Header X-API-Key, base https://api.weglide.org/v1/, TLS public-key pinning for weglide.org; no refresh | Clear all 6 keys | GET 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/api | Store → GET settings ping → store returned user id; on failure wipe and alert | none | Clear username+password | GET 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, cookies | POST {season}/login.php form gebruikersnaam, paswoord; success if redirected to overzicht.php. Season = year, +1 after September | Cookies (shared cookie storage) | Clear both keys | GET {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 key | POST 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=false | GET regions, layers?region_id=, tiles on edge/cf.skysight.io, wind cloud function, satellite.skysight.io/tiles |
| Soaring Spot | none | — | via vulture.aero proxy (§7) | — | competitions, IGC downloads |
| openAIP | none (app API key) | — | vector tiles api.tiles.openaip.net/.../{z}/{x}/{y}.pbf?apiKey= (z 5–14) | — | live airspace |
| FLARM | none | local 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).
5.3 Android mapping for credentials
Section titled “5.3 Android mapping for credentials”- Store secrets in a Proto/Preferences DataStore encrypted with Tink AEAD whose keyset is wrapped by an Android Keystore master key (
tink-androidAndroidKeysetManager).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>(replacesservicesDidChange). - Network: Retrofit/Ktor with per-service OkHttp interceptors (WeGlide
X-API-Key+CertificatePinnerfor weglide.org; SkyLines Basic auth; SkySightX-API-KEY/Origin+Authenticatorthat re-auths once on 401; CharronCookieJar).
6. Subscriptions and monetisation
Section titled “6. Subscriptions and monetisation”6.1 RevenueCat configuration (iOS)
Section titled “6.1 RevenueCat configuration (iOS)”| Item | Value | Source |
|---|---|---|
| SDK | RevenueCat, StoreKit 2 | Modules/App/Sources/Services/Subscriptions/AppStoreSubscription.swift |
| API key | per build config (debug/release) — Apple key; Android needs its own goog_… key on the same RevenueCat project | Packages/Preferences/Sources/Preferences/Vulture.swift |
| Products | be.fousa.vulture.subscriptions.monthly, …yearly (debug: be.fousa.vulture.debug.subscriptions.*) | Vulture.swift |
| Entitlement | all (active, expirationDate, periodType == .trial) | PurchaserInfo+Active.swift |
| Offering | offerings.current (no hard-coded id); fallback getProducts([monthly, yearly]); else SubscriptionError.noProducts | AppStoreSubscription.swift |
| Offering metadata | show_paywall_on_first_launch (Bool, default false) — the only remote switch | same |
| App user id | CloudKit user record name → Purchases.logIn(recordName); anonymous if no iCloud | same |
| Refresh | getCustomerInfo on every foreground; PurchasesDelegate updates | same |
| Trials | Intro offers freeTrial; eligibility via checkTrialOrIntroDiscountEligibility | Modules/App/Sources/Features/Settings/Subscription/AppSubscriptionProvider.swift |
| Non-renewing | supported in UI as “paid once” | paywall |
| Not present | StoreKit config files, family sharing, grandfathering, offer codes | — |
6.2 Free tier and gating
Section titled “6.2 Free tier and gating”ProAccess.isFreeTierEnabledis compile-time true in release (debug-VultureHardPaywallturns 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 silentpolicy.isUnlocked/allowsFlights(count:).
| ProFeature | Pro unlocks | Free keeps | Main enforcement points (iOS) |
|---|---|---|---|
| severalFlights | >1 flight on a map (competition day, multi-select, Compare, Siri compare) | one flight at a time | SplitViewController.swift:396, ListViewController+Actions.swift:206, FlightMapViewModel.swift (allowsFlights) |
| customAirspace | own OpenAir/openAIP/CUB/XCSoar airspace files on map | openAIP live airspace + warnings (showsOpenAIPAirspace = setting OR locked) | ProAirspaceSettings.swift, MapViewDataSource+Airspace.swift |
| debrief | full rating breakdown + 25 Q&A, Siri analysis | score and band | FlightDebriefViewController.swift, AppIntents.swift |
| thermalAnalysis | thermal sheet (climb, centring, 3D) | thermals on map | ThermalController.swift:35 |
| taskPlanning | create/edit tasks, scan QR into editor | open/fly/share existing & shared tasks | ListViewController+Actions.swift:90,105, TaskMapViewController.swift:1225 |
| recording | in-flight recording, moving map, warnings, Live Activity | resume a running recording | SplitViewController.startCreateFlight() |
| skySight | SkySight overlays + satellite clouds (own account) | — | ProAccess.showsSkySight, map view controllers |
| statistics | yearly logbook statistics | — | ListViewController+Actions.swift:73 |
| uploads | upload to WeGlide/SkyLines/Charron | downloads/imports stay free | ListViewController+Menu.swift:242, UIViewController+Weglide.swift |
| taskOverride | put another task over flights | reset override, competition day task | FlightMapViewController.swift:627,2052 |
Free: logbook, imports (files, WeGlide, SkyLines, Soaring Spot, Charron), competitions, openAIP airspace, shared tasks, waypoints, iCloud sync.
6.3 Paywall screens
Section titled “6.3 Paywall screens”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, analyticstrigger): launch, promoted, feature(), settings, firstLaunch. First-launch paywall shown once ( VultureHasShownFirstLaunchPaywall) only if free tier on, not subscribed, offering metadata flag true.
6.4 Android mapping
Section titled “6.4 Android mapping”com.revenuecat.purchases:purchases(+ optionallypurchases-uifor 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 plansmonthlyandyearly, free-trial offers on each; attach to entitlementallin the same RevenueCat project; add them to thecurrentoffering’s Android packages. Keepshow_paywall_on_first_launchmetadata 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 tologIn(firebaseUid)too when iOS joins Firebase — RevenueCat then shares theallentitlement 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().managementURLorhttps://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).
ProAccessPolicyports 1:1 as a pure Kotlin class;isFreeTierEnabledas aBuildConfigflag.
7. Analytics, backend API, remote content
Section titled “7. Analytics, backend API, remote content”7.1 Analytics (Mixpanel)
Section titled “7.1 Analytics (Mixpanel)”Packages/Analytics/Sources/Analytics/Analytics.swift(reporter protocol,report(event:properties:)),Event.swift(enum, camelCase raw values = event names); Mixpanel adapterModules/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 (MixpaneloptOutTracking, 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 (outcomesubscribed/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
AnalyticsReporterinterface; keep event names identical so dashboards merge; add aplatformsuper property (Mixpanel adds$osanyway). Drop iOS-only events (force touch, Siri, Spotlight) or map to Android equivalents (App Shortcuts, Assistant). Crash reporting: iOS has none (Sentry removed in3e1ebb3c9); 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/...withAccept: application/json(else 406) and a staticAuthorizationtoken compared toENV['API_TOKEN'](else 403). Token inPackages/Backend/Sources/Backend/VultureServiceConfiguration.swiftandPackages/Flights/Sources/Flights/Service/ElevationServiceConfiguration.swift. No certificate pinning (removed after Let’s Encrypt rotations). No cache headers; API hits are logged to Umami.
| Endpoint | Used by iOS for | Upstream |
|---|---|---|
GET /api/xcsoar/waypoints | Download-waypoints list; launch update check | download.xcsoar.org HTML index |
GET /api/xcsoar/airspace | Download-airspace list; update check | XCSoar repository file |
GET /api/openaip/airspace | effectively unused | public GCS bucket |
GET /api/elevations/:tile (N50E005) | Terrain elevation per 1° tile when decoding flights | 302 → S3 vulture.s3.eu-central-1.amazonaws.com/elevations/<tile>.hgt.zip (SRTM3 1201², big-endian Int16), cached in Caches/elevations |
GET /api/soaringspot/countries | Soaring Spot filter | scrape |
GET /api/soaringspot/competitions?country&year&page | list/search/paging | scrape |
GET /api/soaringspot/competitions/:id | competition + classes/days | scrape |
GET /api/soaringspot/competitions/:id/pilots | pilots tab | scrape |
GET /api/soaringspot/competitions/:id/flights/:class/:day | day scoreboard | scrape |
GET /api/soaringspot/competitions/:id/task/:class/:day | day 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”8.1 Requirements distilled from iOS
Section titled “8.1 Requirements distilled from iOS”- 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.
- 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).
- Identity: iOS has none (CloudKit uses the Apple ID silently). Android needs an explicit Google sign-in.
- Dedup must be structural (iOS needs a post-import dedup pass because CloudKit has no unique constraints).
- Settings sync ≈ iCloud KVS: per-key last-writer-wins on ~60 keys.
- Nice-to-have: later let iOS sync to the same backend (cross-platform logbook), and share tasks between pilots (iOS feature B).
- Solo developer: minimal ops, no servers to patch, predictable cost.
8.2 Options compared
Section titled “8.2 Options compared”| Criterion | A. Firebase Auth + Firestore + Cloud Storage | B. Google Drive appDataFolder | C. Credential Manager sign-in + own backend (vulture-web Rails + Postgres + S3/R2) |
|---|---|---|---|
| Sign-in | Credential Manager GetGoogleIdOption → GoogleAuthProvider.getCredential(idToken) → Firebase Auth. Also supports Sign in with Apple, email link | Credential 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-first | Room as truth + Firestore SDK has its own offline cache/queued writes; listeners | Room as truth; you build everything (no queries, no listeners) | Room as truth; you build the protocol |
| Change detection | Real-time snapshot listeners, queries on updatedAt | changes.list polling with page tokens; no server-side query on content | Whatever you build (cursor endpoint) |
| Blobs (IGC) | Cloud Storage, resumable uploads, path per hash | Drive files (multipart/resumable), user quota | S3/R2 presigned URLs |
| Conflict support | Transactions, server timestamps, field merges | ETag/If-Match only, file granularity | Full control |
| Cost to developer | Firestore 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 storage | Zero: storage counts against the user’s Google quota | Hosting + DB + object storage + your time |
| Privacy / GDPR | You 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 surface | Same as A, plus you secure the server |
| Google Play / OAuth review | Standard | drive.appdata is a non-restricted scope but still needs OAuth consent-screen verification; brand verification | Standard |
| iOS joining later | First-class iOS SDK; Firebase Auth supports Sign in with Apple, so iOS users need no Google account | iOS users would need a Google account + GoogleSignIn + Drive REST — awkward for an Apple-first audience | Easy (it is your API), but most work |
| Sharing tasks between pilots (iOS B) | Possible: top-level sharedTasks/{id} with ACL in security rules | Drive sharing of appData files is not possible (appDataFolder is private to the app) | Possible |
| Solo-dev effort | Low–medium | Medium (Drive REST ergonomics, polling, no queries) | High |
| Lock-in | Medium (Firestore data model is portable JSON; export via gcloud firestore export) | Low | None |
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.
8.4 Firestore data model
Section titled “8.4 Firestore data model”users/{uid} // profile: createdAt, lastSeenAt, schemaVersion, platform listusers/{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, deletedAtusers/{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, deletedusers/{uid}/mapFiles/{fileId} type (waypoints|airspace), source (user|xcsoar|openaip), filename, importedAt, itemCount, isVisible, contentArea, contentUpdatedAt, contentHash, blobPath (only for source=user), updatedAt, deletedusers/{uid}/homebases/{sha1(lowercased name)} // name-derived id -> no duplicates (iOS dedups by name at read time) name, lat, lon, usedAt, updatedAt, deletedusers/{uid}/recentCompetitions/{soaringSpotId} name, location, country, startDate, endDate, lastOpenedAt, updatedAtusers/{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}.gzSecurity 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.
8.5 Sync algorithm (Room ⇄ Firestore)
Section titled “8.5 Sync algorithm (Room ⇄ Firestore)”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.
- Local write: in one Room transaction write the row, set
dirty=1,localUpdatedAt=now; thenWorkManager.enqueueUniqueWork("sync", APPEND_OR_REPLACE, OneTimeWorkRequest<SyncWorker>)withNetworkType.CONNECTED, exponential backoff,setExpeditedwhere allowed. - Push (SyncWorker) for each dirty row, oldest first, batched (≤ 500 writes per batch):
- Flights: if
igc.uploaded == false→ gzip +putFiletousers/{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 setupdatedAt = FieldValue.serverTimestamp(). - On success clear
dirty, store the returned server timestamp.
- Flights: if
- Pull: while the app is in the foreground, a snapshot listener per collection with
whereGreaterThan("updatedAt", cursor); in background, aPeriodicWorkRequest(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 maxupdatedAtseen (server timestamps only — never trust device clocks for cursors). - 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 higheranalysisVersion; tombstone wins only ifdeletedAt≥ 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 astrimRecents. - Settings doc: merge per key by
t(≈ KVS per-key LWW). First-run seeds are written locally witht = 0so any cloud value wins (iOS behaviour).
- Default: last-writer-wins per document by server
- 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 aCONSTRAINT unmetered + chargingworker. Store atfilesDir/igc/{flightId}.igc. - Deletes: tombstone docs (
deleted=true), delete the Storage blob immediately; purge tombstones older than 90 days (client-side, on a periodic worker). - 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”. - Account deletion (Play policy): in-app button → delete Storage prefix + Firestore subtree (Cloud Function
onDeleteor client-side recursive delete) +FirebaseAuth.currentUser.delete(). - 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.)
8.6 Path for iOS to join later
Section titled “8.6 Path for iOS to join later”- iOS adds Firebase Auth (Sign in with Apple or Google) and a
FirestoreSyncEnginethat mapsCoreFlight/Task/Fileto the same documents; flight id = SHA-256 of the IGC (computable fromrawContent), task id needs a new UUID attribute onTask(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
uidas the RevenueCatappUserIDon both platforms so one subscription unlocks both apps (see §6).
9. Android technology mapping (summary)
Section titled “9. Android technology mapping (summary)”| iOS | Android |
|---|---|
Core Data + NSPersistentCloudKitContainer | Room (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 pass | Unique 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 defaults | Proto DataStore: SyncedSettings + LocalSettings (§3.3); Glance widgets read it directly |
| iCloud KVS | users/{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 publisher | ConnectedServicesRepository: StateFlow<ConnectedServices> |
| RevenueCat iOS (StoreKit 2) | RevenueCat Android (Play Billing 7+), entitlement all, logIn(firebaseUid) |
| Mixpanel iOS | Mixpanel Android behind AnalyticsReporter; same event names |
URLSession Networking package | OkHttp + 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 export | Same ZIP via ACTION_CREATE_DOCUMENT / share sheet |
Universal links vulture.aero/open/*, vulture:// | App Links (needs assetlinks.json on vulture-web) + vulture:// intent filter |
10. Risks and open decisions
Section titled “10. Risks and open decisions”| # | Risk / decision | Impact | Mitigation |
|---|---|---|---|
| 1 | No Android↔iOS data interop at launch (CloudKit unreachable) | A pilot with both platforms sees two logbooks | Market 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 |
| 2 | Sync cost is borne by the developer (iCloud was free) | Storage/egress grows with users | Gzip IGCs, never sync derivable data or remote openAIP/XCSoar file content, lazy IGC download; decide free vs Pro sync before launch |
| 3 | Flight location data on developer-controlled storage | GDPR, Play Data safety form, account deletion requirement | Privacy policy update, in-app + web deletion, security rules per uid |
| 4 | Identity for subscriptions | Android purchases not visible on iOS (and vice versa) until identities merge | Use Firebase uid as RevenueCat app user id; iOS later logIn(uid) (RevenueCat aliases/transfers purchases) |
| 5 | iOS quirks that should not be copied | Bugs carried over | Split VultureShowTrack into two settings; store unit enums not indexes; drop dead Flight/FlightData, WeGlide OAuth leftovers, unused isTrackVisible |
| 6 | Task external URL format (relative soaringspot/competitions/<id>/task/<class>/<day>) | Re-import/update breaks if changed | Keep the format; it becomes cross-platform once iOS syncs to Firestore |
| 7 | Secrets hard-coded in the iOS source (RevenueCat, Mixpanel, backend token, openAIP, SkySight app key, unused WeGlide secret) | Leak/abuse | On Android put them in local.properties/CI secrets → BuildConfig; consider rotating the backend token per platform |
| 8 | EncryptedSharedPreferences deprecation, Keystore keys not backed up | Lost credentials after restore | Tink + backup exclusion (§5.3) |
| 9 | Firestore 1 MiB document limit, 500-write batches | Large tasks/files fail | IGC and file text in Cloud Storage only; turnpoints are small |
| 10 | Task sharing (CKShare) has no Android equivalent yet | Feature gap | Phase 2: sharedTasks/{id} with participant uid list in rules; or share as .cup/QR (already exists on iOS) |
