Closer/iphone/ARCHITECTURE_AUDIT.md

1108 lines
48 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Closer iOS App — Architecture Audit
> Generated from the Android (Kotlin) codebase at `app/src/main/java/app/closer/`,
> Cloud Functions at `functions/src/`, and secondary Express server at `server/src/`.
>
> Intended audience: SwiftUI developer building the iOS port without reading Kotlin.
---
## Table of Contents
1. [iOS-Specific Adaptations](#1-ios-specific-adaptations)
2. [Auth & Onboarding Flow](#2-auth--onboarding-flow)
3. [Screen Map (Android → iOS)](#3-screen-map-android--ios)
4. [Navigation Structure](#4-navigation-structure)
5. [Firestore Collections & Documents](#5-firestore-collections--documents)
6. [Domain Models (Swift Equivalents)](#6-domain-models-swift-equivalents)
7. [Cloud Functions](#7-cloud-functions)
8. [Secondary Express Server](#8-secondary-express-server)
9. [RevenueCat / Entitlements](#9-revenuecat--entitlements)
10. [E2EE / Crypto Layer](#10-e2ee--crypto-layer)
11. [UI Design System](#11-ui-design-system)
12. [DI Pattern (Hosting)](#12-di-pattern)
13. [Project File Layout for /iphone](#13-project-file-layout)
---
## 1. iOS-Specific Adaptations
### Android APIs with no direct iOS equivalent
| Android API | What it's used for | iOS Replacement |
|---|---|---|
| `Hilt` (Dagger) | Dependency injection throughout app | Swift manual DI or factory protocol (no Swinject needed; the app is small enough for manual dependency graph) |
| `Navigation Compose` | NavHost, composable route system | `SwiftUI.NavigationStack` + `.navigationDestination` |
| `Material 3` | Theme, components, surfaces | SwiftUI native + custom color/assets |
| `Compose Canvas` | SpinWheel rendering (`Canvas`, `drawArc`, `rotate`) | `SwiftUI Canvas` or `CoreGraphics` rendering |
| `Room` (SQLite) | Local question bank, preloaded DB asset | Bundled JSON or CoreData for local question data |
| `EncryptedSharedPreferences` | Secure key-value crypto storage | `Keychain Services` (for secret/keys) + `UserDefaults` (for non-secret prefs) — managed via `Security.framework` / `CryptoKit` |
| `Tink` (Google crypto) | AES-256-GCM, ECIES, keyset serialization | `CryptoKit` (AES-GCM, P256/Curve25519) or `SwiftCrypto` / `BoringSSL` wrapper — KEY REQUIREMENT: must produce byte-compatible ciphertexts |
| `Google Play Integrity API` | Device integrity check | Apple `DeviceCheck` or `App Attest` — or skip for MVP |
| `Google Sign-In` | Auth | `FirebaseAuth` with `GoogleSignIn-iOS` pod |
| `FCM` via `FirebaseMessaging` | Push notifications | Same — Firebase iOS SDK handles FCM |
| `RevenueCat Android SDK` | Subscriptions | `purchases-ios` (RevenueCat Swift SDK) — same backend |
| `Argon2id` (Bouncy Castle) | KDF for recovery key | `CryptoKit` doesn't include Argon2 — need `SwiftArgon2` wrapper or `IDZSwiftCommonCrypto` — IMPORTANT: must produce identical bytes to verify Android-generated recovery phrases |
| `ActivityResultLauncher` | Google Sign-in activity result | `ASWebAuthenticationSession` / `CredentialManager` |
### iOS-Specific Additions
| Component | Notes |
|---|---|
| `Info.plist` | Push notification entitlement, background modes, URL schemes (Google Sign-In, RevenueCat) |
| `.entitlements` file | Push, Keychain sharing (for RevenueCat), App Groups (optional for widget) |
| `GoogleService-Info.plist` | Firebase config (generate from Firebase Console) |
| `RevenueCat.plist` / API key | Set via code, same as Android `BuildConfig.RC_API_KEY` |
| `Podfile` or SPM | Firebase, RevenueCat, Google Sign-In via SPM |
| `KeychainHelper` | Wrapper for `SecItemAdd`/`SecItemCopyMatching` — replaces `EncryptedSharedPreferences` |
| `Notification Service Extension` | For rich FCM display (optional) |
---
## 2. Auth & Onboarding Flow
### Flow Diagram (Logical)
```
App Launch
├─ AuthState.Unauthenticated ─→ OnboardingScreen
│ │
│ ├─ Sign Up (email + password)
│ ├─ Login (email + password)
│ ├─ Forgot Password
│ └─ Try Anonymously (optional — used
│ for test/dev only, not first run)
├─ AuthState.Authenticated(isAnonymous=true) ─→ CreateProfileScreen
│ │
│ └─ User doc created in Firestore
└─ AuthState.Authenticated(isAnonymous=false) ─→ userDoc.coupleId
├─ null/empty ─→ PairPromptScreen
│ │
│ ├─ CreateInviteScreen (generate code)
│ ├─ AcceptInviteScreen (enter code)
│ └─ EmailInviteScreen (share by email)
└─ exists ─→ HomeScreen (main app)
```
### Client-Side Rate Limiting (AuthRateLimiter)
Android uses `AuthRateLimiter` — a singleton object with per-flow counters:
- **Flows:** `LOGIN`, `SIGN_UP`, `PASSWORD_RESET`, `ANONYMOUS`
- **Soft limit:** 3 failures → exponential backoff (1s × 2^(failures-3), capped at 8s)
- **Hard limit:** 5 failures → 30-second lockout
- Resets on success.
**iOS:** Implement identical logic in a Swift class. No dependency needed — pure math + `Date()`. Pattern:
```swift
final class AuthRateLimiter {
enum Flow { case login, signUp, passwordReset, anonymous }
// ... same constants and logic
}
```
### Auth Data Sources
**FirebaseAuthDataSource** wraps Firebase Auth SDK. Key methods the iOS port must replicate:
| Method | Firebase Auth API (iOS) |
|---|---|
| `signInAnonymously()` | `Auth.auth().signInAnonymously()` |
| `signInWithEmail(email, password)` | `Auth.auth().signInWithEmail(email, password)` |
| `signUpWithEmail(email, password)` | `Auth.auth().createUserWithEmail(email, password)` |
| `sendPasswordResetEmail(email)` | `Auth.auth().sendPasswordResetEmail(email)` |
| `signInWithGoogle(idToken)` | `FIRGoogleAuthProvider.credential(withIDToken:)` inside Google Sign-In delegate |
| `signOut()` | `try Auth.auth().signOut()` |
| `reauthenticateWithEmail(email, password)` | `user.reauthenticate(with:)` |
| `deleteAccount()` | `user.delete()` |
| `authState: Flow<AuthState>` | `Auth.auth().addStateDidChangeListener {}` → combine into `AsyncStream` or `@Published` |
**AuthState enum:**
```swift
enum AuthState {
case loading
case authenticated(userId: String, isAnonymous: Bool)
case unauthenticated
}
```
### Google Sign-In Flow (iOS)
1. Configure `GoogleService-Info.plist` in Xcode target (includes reversed client ID URL scheme)
2. Use `GIDSignIn.sharedInstance.signIn(withPresenting:)` to get `GIDGoogleUser`
3. Extract `idToken` from `user.idToken.tokenString`
4. Create Firebase credential: `GoogleAuthProvider.credential(withIDToken: idToken, accessToken: accessToken)`
5. Call `Auth.auth().signIn(with: credential)`
---
## 3. Screen Map (Android → iOS)
### Grouped by Navigation Tab/Flow
#### Onboarding & Auth (no bottom nav)
| # | Route | Android Composable | Purpose | iOS Screen |
|---|---|---|---|---|
| 1 | `onboarding` | `OnboardingScreen` | Welcome carousel, "Get Started" | `OnboardingView` |
| 2 | `login` | `LoginScreen` | Email/password login form | `LoginView` |
| 3 | `sign_up` | `SignUpScreen` | Email/password registration | `SignUpView` |
| 4 | `forgot_password` | `ForgotPasswordScreen` | Reset email form | `ForgotPasswordView` |
| 5 | `create_profile` | `CreateProfileScreen` | User display name, avatar, sex | `CreateProfileView` |
#### Pairing
| # | Route | Android Composable | Purpose | iOS Screen |
|---|---|---|---|---|
| 6 | `pair_prompt` | `PairPromptScreen` | "Create invite" or "Accept invite" | `PairPromptView` |
| 7 | `create_invite` | `CreateInviteScreen` | 6-char invite code display, share sheet | `CreateInviteView` |
| 8 | `accept_invite` | `AcceptInviteScreen` | Enter code text field | `AcceptInviteView` |
| 9 | `email_invite` | `EmailInviteScreen` | Email entry to send invite | `EmailInviteView` |
| 10 | `invite_confirm/{code}` | `InviteConfirmScreen` | Confirmation before pairing | `InviteConfirmView` |
| 11 | `recovery` | `RecoveryScreen` | Unlock answers with recovery phrase | `RecoveryView` |
| 12 | `encryption_upgrade` | `EncryptionUpgradeScreen` | E2EE upgrade banner/flow | `EncryptionUpgradeView` |
#### Home (tab 1 — bottom nav)
| # | Route | Android Composable | Purpose | iOS Screen |
|---|---|---|---|---|
| 13 | `home` | `HomeScreen` | Main dashboard — streak, question, partner status | `HomeView` |
| 14 | `partner_home` | `PartnerHomeScreen` | Partner's home/activity view | `PartnerHomeView` |
#### Daily Question (tab 2 — bottom nav)
| # | Route | Android Composable | Purpose | iOS Screen |
|---|---|---|---|---|
| 15 | `daily_question` | `DailyQuestionScreen` | Today's question (text, multi-choice, scale) | `DailyQuestionView` |
| 16 | `answer_reveal/{questionId}` | `AnswerRevealScreen` | Reveal partner's sealed answer | `AnswerRevealView` |
| 17 | `answer_history` | `AnswerHistoryScreen` | Past answer history (calendar/list) | `AnswerHistoryView` |
| 18 | `question_packs` | `QuestionPackLibraryScreen` | Browse curated question packs | `QuestionPackLibraryView` |
| 19 | `question_category/{categoryId}` | `QuestionCategoryScreen` | Questions in a pack | `QuestionCategoryView` |
| 20 | `question_composer` | `QuestionComposerScreen` | Create + send custom question | `QuestionComposerView` |
| 21 | `question_thread/{coupleId}/{questionId}` | `QuestionThreadScreen` | Ask-answer-reveal thread for custom questions | `QuestionThreadView` |
#### Play (tab 3 — bottom nav)
| # | Route | Android Composable | Purpose | iOS Screen |
|---|---|---|---|---|
| 22 | `play` | `PlayHubScreen` | Game selection hub (cards) | `PlayHubView` |
| 23 | `this_or_that` | `ThisOrThatScreen` | Binary choice game | `ThisOrThatView` |
| 24 | `how_well` | `HowWellScreen` | "How well do you know me?" quiz | `HowWellView` |
| 25 | `desire_sync` | `DesireSyncScreen` | Intimacy preference matching | `DesireSyncView` |
| 26 | `connection_challenges` | `ConnectionChallengesScreen` | Multi-day challenge programs | `ConnectionChallengesView` |
| 27 | `memory_lane` | `MemoryLaneScreen` | Time capsule viewer | `MemoryLaneView` |
| 28 | `waiting_for_partner` | `WaitingForPartnerScreen` | "Waiting for partner to finish" interstitial | `WaitingForPartnerView` |
| 29 | `game_history` | (inline in PlayHub) | Past game results | `GameHistoryView` |
| 30 | `this_or_that_replay/{sessionId}` | `ThisOrThatReplayScreen` | Replay past TOT result | `ThisOrThatReplayView` |
| 31 | `desire_sync_replay/{sessionId}` | `DSReplayScreen` | Replay past DS result | `DesireSyncReplayView` |
| 32 | `how_well_replay/{sessionId}` | `HowWellReplayScreen` | Replay past HW result | `HowWellReplayView` |
#### Spin Wheel (sub-flow of Play)
| # | Route | Android Composable | Purpose | iOS Screen |
|---|---|---|---|---|
| 33 | `category_picker` | `CategoryPickerScreen` | Pick wheel category | `CategoryPickerView` |
| 34 | `spin_wheel/{categoryId}` | `SpinWheelScreen` | Canvas-animated spin wheel | `SpinWheelView` |
| 35 | `wheel_session/{sessionId}` | `WheelSessionScreen` | During-session steps/prompts | `WheelSessionView` |
| 36 | `wheel_complete/{sessionId}` | `WheelCompleteScreen` | Session results | `WheelCompleteView` |
| 37 | `wheel_history` | `WheelHistoryScreen` | Past wheel sessions | `WheelHistoryView` |
#### Date Ideas (sub-flow)
| # | Route | Android Composable | Purpose | iOS Screen |
|---|---|---|---|---|
| 38 | `date_match` | `DateMatchScreen` | Tinder-style swipe on date ideas | `DateMatchView` |
| 39 | `date_matches` | `DateMatchesScreen` | Revealed matches list | `DateMatchesView` |
| 40 | `date_builder` | `DateBuilderScreen` | Date plan builder/form | `DateBuilderView` |
| 41 | `bucket_list` | `BucketListScreen` | Shared bucket list | `BucketListView` |
#### Settings (tab 5 — bottom nav)
| # | Route | Android Composable | Purpose | iOS Screen |
|---|---|---|---|---|
| 42 | `settings` | `SettingsScreen` | Main settings list | `SettingsView` |
| 43 | `account` | `AccountScreen` | Email, password, delete account | `AccountView` |
| 44 | `notifications` | `NotificationSettingsScreen` | Notification toggles | `NotificationSettingsView` |
| 45 | `privacy` | `PrivacyScreen` | Privacy policy, data export, E2EE info | `PrivacyView` |
| 46 | `subscription` | `SubscriptionScreen` | Plan details, manage subscription | `SubscriptionView` |
| 47 | `relationship_settings` | `RelationshipSettingsScreen` | Partner info, unlink, leave couple | `RelationshipSettingsView` |
| 48 | `delete_account` | `DeleteAccountScreen` | Confirm + delete | `DeleteAccountView` |
#### Paywall (presented modally)
| # | Route | Android Composable | Purpose | iOS Screen |
|---|---|---|---|---|
| 49 | `paywall` | `PaywallScreen` | Subscription offering (premium features) | `PaywallView` |
---
## 4. Navigation Structure
### Android Bottom Nav Tabs
5 top-level tabs:
| Index | Route | Label | Icon |
|---|---|---|---|
| 0 | `home` | Home | `house.fill` |
| 1 | `daily_question` | Today | `heart.fill` |
| 2 | `play` | Play | `play.fill` |
| 3 | `question_packs` | Packs | `star.fill` |
| 4 | `settings` | Settings | `gearshape.fill` |
### iOS NavigationStack Pattern
```swift
struct AppNavigation: View {
@State private var selectedTab: Tab = .home
var body: some View {
TabView(selection: $selectedTab) {
HomeFlow()
.tabItem { ... }
.tag(Tab.home)
DailyQuestionFlow()
.tabItem { ... }
.tag(Tab.dailyQuestion)
PlayFlow()
.tabItem { ... }
.tag(Tab.play)
QuestionPacksFlow()
.tabItem { ... }
.tag(Tab.questionPacks)
SettingsFlow()
.tabItem { ... }
.tag(Tab.settings)
}
}
}
```
Each flow wraps a `NavigationStack` with its own path. Drill-in routes use `.navigationDestination(for:)`.
### Route Parameter Patterns
| Route | Parameter | Type | Notes |
|---|---|---|---|
| `question_category/{categoryId}` | `categoryId` | String | UUID |
| `spin_wheel/{categoryId}` | `categoryId` | String | UUID |
| `wheel_session/{sessionId}` | `sessionId` | String | UUID |
| `wheel_complete/{sessionId}` | `sessionId` | String | UUID |
| `answer_reveal/{questionId}` | `questionId` | String | UUID |
| `invite_confirm/{inviteCode}` | `inviteCode` | String | 6-char alphanumeric |
| `question_thread/{coupleId}/{questionId}?prevId=...&nextId=...` | `coupleId`, `questionId` | String | Optional prevId/nextId |
| `this_or_that_replay/{sessionId}` | `sessionId` | String | UUID |
| `desire_sync_replay/{sessionId}` | `sessionId` | String | UUID |
| `how_well_replay/{sessionId}` | `sessionId` | String | UUID |
---
## 5. Firestore Collections & Documents
### Collection Map
```
/users/{uid}
├── displayName: String
├── email: String
├── photoUrl: String
├── sex: String
├── partnerId: String | null
├── coupleId: String | null
├── plan: String ("free" | "premium")
├── createdAt: Timestamp
├── lastActiveAt: Timestamp
├── fcmToken: String (legacy, single token)
├── fcmTokens/{tokenId} (subcollection — multi-device)
├── entitlements/
│ └── premium (subcollection doc)
│ ├── premium: boolean
│ ├── expiresAt: Timestamp | null
│ └── updatedAt: Timestamp
├── devices/
│ └── primary (subcollection doc — E2EE device key)
│ ├── publicKey: String ("pub:v1:{base64}")
│ └── updatedAt: Timestamp
└── notification_queue/ (for in-app notification center)
└── {notificationId}
/invites/{code} (doc ID = 6-char code)
├── inviterUserId: String
├── inviteeEmail: String | null
├── coupleId: String | null
├── status: String ("pending" | "accepted" | "expired")
├── createdAt: Timestamp
├── expiresAt: Timestamp
├── acceptedAt: Timestamp | null
├── acceptedByUserId: String | null
├── wrappedCoupleKey: String | null (E2EE)
├── kdfSalt: String | null (E2EE)
└── kdfParams: String | null (E2EE)
/couples/{coupleId}
├── userIds: [String, String]
├── inviteCode: String
├── createdAt: Timestamp
├── currentQuestionId: String | null
├── streakCount: Int
├── lastAnsweredAt: Timestamp | null
├── activePackId: String | null
├── encryptionVersion: Int (0=plaintext, 1=migrating, 2=strict E2EE)
├── wrappedCoupleKey: String | null (E2EE)
├── kdfSalt: String | null (E2EE)
├── kdfParams: String | null (E2EE)
├── encryptionMigrationUsers: Map<String, Bool>
├── daily_question/{date} (subcollection — date = YYYY-MM-DD)
│ ├── questionId: String
│ ├── date: String
│ ├── assignedAt: Timestamp
│ ├── expiresAt: Timestamp
│ ├── answers/{userId} (subcollection)
│ │ ├── sealedAnswer: String | null ("sealed:v1:..." ciphertext)
│ │ ├── commitment: String | null ("sha256:..." hash)
│ │ ├── questionType: String
│ │ ├── submittedAt: Timestamp
│ │ ├── releaseKeys/{userId} (subcollection — keybox at reveal)
│ │ │ └── keybox: String ("keybox:v1:..." wrapped key)
│ │ └── ...
│ └── ...
├── question_threads/{threadId} (subcollection)
│ ├── questionId: String
│ ├── senderId: String
│ ├── status: String
│ ├── answers/ (subcollection)
│ ├── messages/ (subcollection)
│ ├── reactions/ (subcollection)
│ ├── releaseKeys/ (subcollection)
│ └── ...
├── date_swipes/{dateIdeaId} (subcollection)
│ └── actions: Map<uid, {action: String, swipedAt: Number}>
├── date_matches/{dateIdeaId} (subcollection — server-write-only)
├── date_plan_preferences/{userId} (subcollection)
├── date_plans/{planId} (subcollection)
├── bucket_list/{itemId} (subcollection)
├── challenges/{challengeId} (subcollection)
├── capsules/{capsuleId} (subcollection)
│ ├── status: String ("sealed" | "unlocked")
│ ├── unlockAt: Timestamp
│ └── ...
├── this_or_that/{sessionId} (subcollection)
├── wheel/{sessionId} (subcollection)
├── desire_sync/{sessionId} (subcollection)
├── how_well/{sessionId} (subcollection)
├── sessions/{sessionId} (subcollection — generic game sessions)
├── gentle_reminders/{date} (subcollection — rate-limit lock)
└── ... (future subcollections)
```
### Security Rules Patterns (from code context)
- `daily_question` docs: server-write-only (`allow write: if false` in rules)
- `date_matches`: server-write-only (trigger-based)
- `date_swipes`: client-write, each user can only write own `actions[uid]`
- `answers`: client-write own answer, read when both have submitted or revealed
- `invites`: server-read via callable only (6-char code = enumerable, so clients don't read invites directly)
- `users`: client-read own, client-write own fields except `coupleId`
- `couples`: member-read, member-write non-critical fields
- `entitlements/{userId}/premium`: server-write only (RevenueCat webhook)
### Stale Code / Deprecated Fields
- `fcmToken` (single string on user doc) is legacy — iOS should use `fcmTokens/{tokenId}` subcollection
- `answer.answerText` on the `Answer` model is likely legacy — actual daily question answers use `sealedAnswer` + `commitment`
---
## 6. Domain Models (Swift Equivalents)
### Firestore-Mapped Models
#### User
```swift
struct User: Codable, Identifiable {
let id: String
var email: String
var displayName: String
var photoUrl: String
var sex: String
var partnerId: String?
var coupleId: String?
var plan: String // "free" | "premium"
var createdAt: Timestamp
var lastActiveAt: Timestamp
}
```
#### Couple
```swift
struct Couple: Codable, Identifiable {
let id: String
var userIds: [String]
var inviteCode: String
var createdAt: Timestamp
var currentQuestionId: String?
var streakCount: Int
var lastAnsweredAt: Timestamp?
var activePackId: String?
// E2EE
var encryptionVersion: Int // 0=plaintext, 1=migrating, 2=strict
var wrappedCoupleKey: String?
var kdfSalt: String?
var kdfParams: String?
var encryptionMigrationUsers: [String: Bool]
}
```
#### Invite
```swift
struct Invite: Codable, Identifiable {
let id: String // = document ID
var code: String
var inviterUserId: String
var inviteeEmail: String?
var coupleId: String?
var status: String // "pending" | "accepted" | "expired"
var createdAt: Timestamp
var expiresAt: Timestamp
var acceptedAt: Timestamp?
var acceptedByUserId: String?
// E2EE
var wrappedCoupleKey: String?
var kdfSalt: String?
var kdfParams: String?
}
```
#### Entitlement
```swift
struct Entitlement: Codable, Identifiable {
let id: String
var userId: String
var source: String
var productId: String
var isActive: Bool
var expiresAt: Timestamp?
var updatedAt: Timestamp
}
```
#### Answer (Daily Question)
```swift
struct DailyAnswer: Codable, Identifiable {
let id: String // = userId
var sealedAnswer: String? // "sealed:v1:{base64}"
var commitment: String? // "sha256:{urlsafe-base64}"
var questionType: String // "text" | "multiple_choice" | "scale"
var submittedAt: Timestamp
}
```
#### Streak
```swift
enum Streak {
struct Couple: StreakType { ... }
struct Personal: StreakType { ... }
struct WeeklyRhythm: StreakType { ... }
}
protocol StreakType {
var count: Int { get }
var lastActiveDate: Date? { get }
var includesToday: Bool { get }
}
struct StreakResult {
let coupleStreak: Streak.Couple
let personalStreak: Streak.Personal
let weeklyRhythm: Streak.WeeklyRhythm
let milestoneCopy: String?
let canRepair: Bool
let repairDueDate: Date?
}
```
### Other Models (read model files for full field lists)
| Model | File | Notes |
|---|---|---|
| `QuestionCategory` | `QuestionCategory.kt` | id, name, description, icon, color, packId |
| `QuestionPack` | `QuestionPack.kt` | id, name, description, categories[] |
| `Question` | `Question.kt` | id, text, type, options[], scaleMin/Max, categoryId |
| `QuestionThread` | `QuestionThread.kt` | coupls question thread |
| `QuestionMessage` | `QuestionMessage.kt` | messages within a thread |
| `QuestionReaction` | `QuestionReaction.kt` | reactions on answers |
| `QuestionSession` | `QuestionSession.kt` | session state |
| `AuthState` | `AuthState.kt` | sealed class: Loading, Authenticated, Unauthenticated |
| `GoogleSignInResult` | `GoogleSignInResult.kt` | uid, displayName, photoUrl, email, isAnonymous |
| `BucketListItem` | `BucketListItem.kt` | id, title, description, createdBy, completed, completedAt |
| `DateIdea` | `DateIdea.kt` | id, title, description, category, cost, duration, location |
| `DateMatch` | `DateMatch.kt` | derived from DateIdea when both match |
| `DateSwipe` | `DateSwipe.kt` | userId, action ("love" | "pass"), swipedAt |
| `DatePlan` | `DatePlan.kt` | planned date details |
| `DatePlanPreference` | `DatePlanPreference.kt` | preferences for date builder |
| `DateMatchSuggestion` | `DateMatchSuggestion.kt` | suggested matches |
| `DatePlanSuggestion` | `DatePlanSuggestion.kt` | suggested plans |
| `DateSuggestion` | `DateSuggestion.kt` | general suggestions |
| `ConnectionChallenge` | `ConnectionChallenge.kt` | id, title, description, durationDays, tasks[] |
| `ChallengeState` | `ChallengeState.kt` | challenge progress per couple |
| `GameType` | `GameType.kt` | constants: "wheel", "this_or_that", "how_well", "desire_sync", "connection_challenges" |
| `LocalAnswer` | `LocalAnswer.kt` | local-only cached answers |
| `MemoryCapsule` | `MemoryCapsule.kt` | id, title, content, status, unlockAt |
| `TimeCapsule` | `TimeCapsule.kt` | similar to MemoryCapsule |
| `SessionLength` | `SessionLength.kt` | enum: short, medium, long |
| `QuestionSessionStatus` | `QuestionSessionStatus.kt` | session progress state |
| `WeeklyRecap` | `WeeklyRecap.kt` | weekly summary stats |
| `QuestionAnswer` | `QuestionAnswer.kt` | answer model for custom questions |
| `InviteStatus` | `InviteStatus.kt` | invite lifecycle state |
---
## 7. Cloud Functions
### Callable Functions (Client Invokes via `functions.httpsCallable`)
| Function | File | Input | Output | Auth Required |
|---|---|---|---|---|
| `acceptInviteCallable` | `couples/acceptInviteCallable.ts` | `{ code: string, recoveryPhrase?: string }` | `{ coupleId: string }` | ✅ |
| `leaveCoupleCallable` | `couples/leaveCoupleCallable.ts` | `_data: any` | `{ success: bool }` | ✅ |
| `syncEntitlement` | `billing/syncEntitlement.ts` | `{}` | `EntitlementState { premium, expiresAt, updatedAt }` | ✅ |
| `sendDailyQuestionReminder` | `notifications/reminders.ts` | `{ type, userId, title, body }` | `{ queued: bool }` | ✅ |
| `sendPartnerAnsweredNotification` | `notifications/reminders.ts` | `{ type, userId, title, body }` | `{ queued: bool }` | ✅ |
| `sendGentleReminderCallable` | `notifications/sendGentleReminderCallable.ts` | `{}` | `{ success: bool }` | ✅ |
| `assignDailyQuestionCallable` | `questions/assignDailyQuestion.ts` | (manual trigger) | `{ assigned: Int }` | Admin |
| `checkDeviceIntegrity` | `security/checkDeviceIntegrity.ts` | `{ token: string }` | `{ passed: bool, verdicts: string[] }` | ✅ (Android only — skip for iOS MVP) |
### Firebase Auth / Firestore Triggers
| Function | File | Trigger | Purpose |
|---|---|---|---|
| `onUserDelete` | `users/onUserDelete.ts` | `auth.user().onDelete` | Cascade: unlink couple, delete user doc/subcollections, clean up Storage |
| `onCoupleLeave` | `couples/onCoupleLeave.ts` | `users/{uid}.onUpdate` (coupleId → null) | FCM to remaining partner |
| `onAnswerWritten` | `questions/onAnswerWritten.ts` | `couples/{c}/daily_question/{d}/answers/{uid}.onCreate` | FCM to partner "your partner answered" |
| `createDateMatchOnMutualLove` | `dates/createDateMatch.ts` | `couples/{c}/date_swipes/{ideaId}.onWrite` | If both LOVE, create match doc |
| `onGameSessionUpdate` | `games/onGameSessionUpdate.ts` | `couples/{c}/sessions/{s}.onWrite` | FCM for session start/complete |
### Scheduled Functions
| Function | File | Schedule | Purpose |
|---|---|---|---|
| `assignDailyQuestion` | `questions/assignDailyQuestion.ts` | Daily at 6 PM CST (`0 23 * * *` America/Chicago) | Assign today's question to all couples |
| `unlockDueMemoryCapsules` | `notifications/gameRetention.ts` | Every 1 hour | Unlock sealed capsules past their unlockAt |
| `sendChallengeDayReminders` | `notifications/gameRetention.ts` | (via retention module) | Reminders for multi-day challenge progress |
### Webhook HTTP Functions
| Function | File | Method | Purpose |
|---|---|---|---|
| `revenueCatWebhook` | `billing/revenueCatWebhook.ts` | POST (Ed25519 signed) | RevenueCat server events → Firestore entitlements |
| `health` | `index.ts` | GET | `{ status: "ok" }` |
### iOS Firebase Functions Integration Pattern
```swift
import FirebaseFunctions
let functions = Functions.functions()
// Call a callable function
func acceptInvite(code: String, recoveryPhrase: String?) async throws -> String {
let data: [String: Any] = ["code": code]
let result = try await functions.httpsCallable("acceptInviteCallable").call(data)
let coupleId = (result.data as? [String: Any])?["coupleId"] as? String ?? ""
return coupleId
}
```
---
## 8. Secondary Express Server
The `server/` directory contains a standalone Express server, **NOT** Firebase Cloud Functions. It runs separately.
| File | Purpose |
|---|---|
| `src/index.ts` | Express app init, helmet, morgan, raw body capture |
| `src/config/env.ts` | ENV validation |
| `src/config/firebase.ts` | Firebase Admin SDK init (for Firestore + FCM access) |
| `src/routes/health.ts` | `GET /health` |
| `src/routes/webhooks.ts` | Webhook endpoint (rate-limited) |
| `src/middleware/rateLimiter.ts` | Rate limiting middleware |
| `src/services/entitlement.ts` | Server-side entitlement logic (redundant with Cloud Functions?) |
| `src/services/fcm.ts` | FCM send helpers |
| `src/listeners/answerListener.ts` | Firestore listener for answer changes |
| `src/types/index.ts` | Shared TypeScript types |
**iOS Impact:** The Express server is not client-facing — it handles internal webhooks and secondary Firestore listeners. iOS app only needs to call Firebase through the Firebase iOS SDK and Cloud Functions (Section 7). The Express server runs independently.
**Note:** The `/server` directory contains code that overlaps with Firebase Cloud Functions. The iOS app should treat Cloud Functions (Section 7) as the authoritative backend contract.
---
## 9. RevenueCat / Entitlements
### Architecture
```
RevenueCat API
revenueCatWebhook (Cloud Function) ──→ Firestore users/{uid}/entitlements/premium
│ { premium: bool, expiresAt, updatedAt }
syncEntitlement (callable function) ←── Client calls after purchase/restore
Client:
Purchases SDK (RevenueCat)
├─ getOfferings() → show paywall plans
├─ purchase(pkg) → buy subscription
├─ restorePurchases() → restore
└─ updatedCustomerInfoListener → flows through EntitlementChecker
```
### EntitlementChecker Protocol (Android interface)
```swift
protocol EntitlementChecker {
/// Reactive stream emitting premium status
var isPremium: AsyncStream<Bool> { get }
/// One-shot check
func hasPremium() async -> Bool
/// Notify of RevenueCat CustomerInfo update
func onCustomerInfoUpdated(_ info: CustomerInfo)
}
```
**iOS Implementation Strategy:**
1. Integrate RevenueCat via SPM (`purchases-ios`)
2. Configure with API key (same `RC_API_KEY` as Android BuildConfig)
3. Use `Purchases.shared.customerInfoStream` (AsyncSequence) instead of listener
4. After purchase, call `syncEntitlement` Cloud Function
5. Also observe Firestore `users/{uid}/entitlements/premium` as server-side source of truth
6. `EntitlementChecker` merges RevenueCat `CustomerInfo.isEntitledTo("closer_premium")` with Firestore document
### What's Premium vs Free (from UI-PLAN.md + code context)
| Feature | Free | Premium |
|---|---|---|
| Daily Questions | ✅ | ✅ |
| Answer Reveal | ✅ | ✅ |
| Question Packs (browse) | ✅ | ✅ |
| Custom Questions | 🚫 | ✅ |
| Spin Wheel | 🚫 | ✅ |
| This or That | 🚫 | ✅ |
| How Well Do You Know Me | 🚫 | ✅ |
| Desire Sync | 🚫 | ✅ |
| Connection Challenges | 🚫 | ✅ |
| Memory Lane / Capsules | 🚫 | ✅ |
| Date Matching (swipe) | ✅ (limited) | ✅ (unlimited) |
| Date Builder | 🚫 | ✅ |
| Bucket List | ✅ (limited) | ✅ (unlimited) |
| Paid plans (from PAYWALL): | | Monthly / Annual |
**Paywall gating:** The `PaywallScreen` is presented modally when the user taps a premium feature. The `BillingRepository` protocol exposes getOfferings/purchase/restore.
### BillingRepository Protocol
```swift
protocol BillingRepository {
func getOfferings() async -> Result<Offerings, Error>
func purchase(package: Package) async -> Result<PurchaseResult, Error>
func restorePurchases() async -> Result<CustomerInfo, Error>
var customerInfo: AsyncStream<Result<CustomerInfo, Error>> { get }
}
```
### RevenueCat Entitlement ID
The entitlement ID used in the webhook and throughout the codebase is:
> **`closer_premium`**
Product IDs from the codebase (in entitlement logic tests):
- `closer_premium_monthly`
- `closer_premium_annual`
---
## 10. E2EE / Crypto Layer
### Architecture Overview
The Android app implements **optional E2EE** for daily question answers. When enabled, answers are:
1. **Sealed at submit time** — encrypted with a one-time AES-256-GCM key
2. **Committed** — SHA-256 hash of plaintext written to Firestore
3. **Revealed** — one-time key released to partner via ECIES key exchange
### Encryption Tiers
| `encryptionVersion` | Meaning |
|---|---|
| 0 | Legacy plaintext — no encryption |
| 1 | Migration in progress — mixed plaintext + encrypted |
| 2 | Strict E2EE — all answer paths encrypted |
### Key Components
| Android Class | iOS Equivalent | Purpose |
|---|---|---|
| `SealedAnswerEncryptor` | `SealedAnswerEncryptor` | AES-256-GCM per-answer encrypt/decrypt. Wire: `"sealed:v1:{base64}"`, AAD = `"{coupleId}\|{questionId}\|{userId}"` |
| `AnswerCommitment` | `AnswerCommitment` | SHA-256 commitment. Input: `"v1\|{coupleId}\|{questionId}\|{userId}\|{canonicalJson}"`, output: `"sha256:{urlsafe-base64}"` |
| `UserKeyManager` | `UserKeyManager` | Per-user ECIES keypair (Curve25519 or P-256), stored in Keychain. Public key published to `users/{uid}/devices/primary` |
| `CoupleKeyStore` | `CoupleKeyStore` | Persists couple keypairs, uses Keychain |
| `CoupleEncryptionManager` | `CoupleEncryptionManager` | Orchestrates setup, migration, recovery phrase |
| `RecoveryKeyManager` | `RecoveryKeyManager` | Argon2id KDF + phrase generation (BIP39-style wordlist). m=46MiB, t=3, p=1. **Must produce identical output to verify Android-generated phrases.** |
| `PendingAnswerKeyStore` | `PendingAnswerKeyStore` | Stores one-time answer keys pre-reveal (Keychain) |
| `ReleaseKeyEncryptor` | `ReleaseKeyEncryptor` | Wraps one-time key to partner's public key via ECIES. Wire: `"keybox:v1:{urlsafe-base64}"` |
| `SealedRevealManager` | `SealedRevealManager` | Two-sided reveal: release own key → decrypt partner's answer |
| `FieldEncryptor` | `FieldEncryptor` | Per-field encrypt for Firestore fields. Wire: `"enc:v1:{base64}"`, AAD = coupleId |
### Answer Flow (E2EE)
```
1. Compose answer (text/choice/scale)
2. Compute commitment SHA-256 → Firestore
3. Generate one-time AES-256-GCM key → store locally (PendingAnswerKeyStore)
4. Encrypt payload → Firestore sealedAnswer field
5. [Partner answers too]
6. Release: wrap one-time key to partner's public key (ECIES) → Firestore releaseKeys/{partnerId}
7. Recover: read partner's keybox, unwrap with own private key → decrypt sealedAnswer
```
### iOS Crypto Strategy
**Critical constraint:** iOS crypto MUST produce byte-for-byte compatible output with Android's Tink-based implementation.
| Crypto Operation | Android | iOS |
|---|---|---|
| AES-256-GCM | Tink `AeadKeyTemplates.AES256_GCM` | `CryptoKit.AES.GCM` with 96-bit IV (12 bytes) — same as Tink default |
| SHA-256 | `MessageDigest.getInstance("SHA-256")` | `CryptoKit.SHA256` |
| ECIES (key exchange) | Tink `HybridKeyTemplates.ECIES_P256_HKDF_HMAC_SHA256_AES128_CTR_HMAC_SHA256` | `CryptoKit.P256.KeyExchange` + HKDF + AES-GCM wrapping — **MUST match wire format** |
| Argon2id | Bouncy Castle | `SwiftArgon2` (Swift package) — **MUST match Android params: m=46MiB, t=3, p=1** |
| Key serialization | Tink JSON keyset format (custom JSON) | Custom JSON key serialization matching Tink's format |
**⚠️ WARNING:** The Tink keyset serialization format is proprietary. iOS must either:
- (A) Use a Tink-in-Swift port (unavailable), OR
- (B) **Skip E2EE for MVP** — treat encryptionVersion=0/1/2 and store answers as non-encrypted plaintext initially, then implement iOS-native crypto that's mutually compatible with Tink as a v2 feature.
**Recommendation:** Skip E2EE for initial iOS port. Store encryptedVersion=0 on new iOS couples. Implement key-compatible encryption in a follow-up batch using `CryptoKit` + interoperability testing.
---
## 11. UI Design System
### Color Palette (from Android theme)
| Token | Hex | Usage |
|---|---|---|
| Primary | `#B98AF4` | Buttons, active states, tab selection |
| Secondary | `#E7A2D1` | Secondary elements, accents |
| Background | `#FFFBFE` | Main background |
| Surface | (Material 3 light surface) | Cards, sheets |
| OnPrimary | `#FFFFFF` | Text on primary |
| Notable: Purple/pink palette, evergreen/gold/danger for meaning/contrast only |
### Visual Guidelines (from UI-PLAN.md)
- **No emoji in chrome** (navigation, cards, game heroes, completion states)
- **No stock photos** — use Material icons + small custom vector glyphs
- **Material 3 aesthetic** — expressive color, shape, motion
- **Category glyphs** for browse/list pages
- **Light, purposeful motion** for game state changes (spin wheel, card swipes)
- **Settings:** quiet, utility-focused
- **Games:** motion + visual identity without childish feel
### iOS Theme Implementation
```swift
struct CloserTheme {
static let primary = Color(hex: "#B98AF4")
static let secondary = Color(hex: "#E7A2D1")
static let background = Color(hex: "#FFFBFE")
// ... extend with UI-PLAN.md tokens
}
```
### iOS Animation Notes
- **SpinWheel:** `Canvas` view with `drawRect`/`drawPath` + `.rotationEffect()` with animation. Match Android's Canvas-based rendering exactly.
- **DateSwipe (Tinder cards):** `DragGesture` + `offset` + `rotation` modifiers. Use `ZStack` for card deck.
- **Game completions:** Purposeful motion (scale + opacity transitions), not decorative.
- **System:** Prefer `.spring()` animation for interactive transitions.
---
## 12. DI Pattern
Android uses Hilt. iOS should use **manual dependency injection** (no Swinject needed):
```swift
@MainActor
final class AppDependencies {
// Singletons
let auth: AuthService
let firestore: FirestoreService
let billing: BillingService
let functions: CloudFunctionsService
// Repositories
lazy var userRepository: UserRepository = ...
lazy var coupleRepository: CoupleRepository = ...
lazy var questionRepository: QuestionRepository = ...
init() {
// Firebase SDKs initialize themselves on first use (plist-based config)
self.auth = AuthService()
self.firestore = FirestoreService()
self.billing = BillingService(apiKey: Secrets.rcApiKey)
self.functions = CloudFunctionsService()
}
}
// Pass via environment:
struct AppDependenciesKey: EnvironmentKey {
static let defaultValue: AppDependencies? = nil
}
extension EnvironmentValues {
var deps: AppDependencies? {
get { self[AppDependenciesKey.self] }
set { self[AppDependenciesKey.self] = newValue }
}
}
```
---
## 13. Project File Layout for /iphone
```
iphone/
├── ARCHITECTURE_AUDIT.md ← This file
├── Closer.xcodeproj/ ← Xcode project
├── Closer/ ← Main app target
│ ├── CloserApp.swift ← @main, WindowGroup, root NavigationStack
│ ├── AppDependencies.swift ← DI container
│ ├── Secrets.swift ← API keys (RevenueCat, etc.)
│ ├── Info.plist
│ ├── GoogleService-Info.plist ← Firebase config
│ ├── Closer.entitlements
│ │
│ ├── Core/
│ │ ├── Auth/
│ │ │ ├── AuthService.swift ← Firebase auth wrapper
│ │ │ └── AuthRateLimiter.swift ← Client-side throttling
│ │ ├── Analytics/
│ │ │ └── AnalyticsService.swift
│ │ ├── Billing/
│ │ │ ├── BillingService.swift ← RevenueCat wrapper
│ │ │ └── EntitlementChecker.swift ← Premium status protocol
│ │ └── Notifications/
│ │ └── NotificationService.swift ← FCM / push
│ │
│ ├── Crypto/ ← E2EE (v2 — optional MVP)
│ │ ├── SealedAnswerEncryptor.swift
│ │ ├── AnswerCommitment.swift
│ │ ├── UserKeyManager.swift
│ │ ├── CoupleKeyStore.swift
│ │ ├── CoupleEncryptionManager.swift
│ │ ├── RecoveryKeyManager.swift
│ │ ├── PendingAnswerKeyStore.swift
│ │ ├── ReleaseKeyEncryptor.swift
│ │ ├── SealedRevealManager.swift
│ │ └── FieldEncryptor.swift
│ │
│ ├── Models/ ← Swift domain models
│ │ ├── User.swift
│ │ ├── Couple.swift
│ │ ├── Invite.swift
│ │ ├── Entitlement.swift
│ │ ├── AuthState.swift
│ │ ├── Answer.swift
│ │ ├── DailyQuestion.swift
│ │ ├── Question.swift
│ │ ├── QuestionCategory.swift
│ │ ├── QuestionPack.swift
│ │ ├── QuestionThread.swift
│ │ ├── QuestionMessage.swift
│ │ ├── QuestionReaction.swift
│ │ ├── QuestionSession.swift
│ │ ├── Streak.swift
│ │ ├── DateIdea.swift
│ │ ├── DateMatch.swift
│ │ ├── DateSwipe.swift
│ │ ├── BucketListItem.swift
│ │ ├── ConnectionChallenge.swift
│ │ ├── ChallengeState.swift
│ │ ├── MemoryCapsule.swift
│ │ ├── TimeCapsule.swift
│ │ ├── LocalAnswer.swift
│ │ └── GameType.swift
│ │
│ ├── Services/ ← Firestore repositories
│ │ ├── FirestoreService.swift ← Generic query helpers
│ │ ├── UserService.swift
│ │ ├── CoupleService.swift
│ │ ├── InviteService.swift
│ │ ├── QuestionService.swift
│ │ ├── AnswerService.swift
│ │ ├── GameService.swift
│ │ ├── DateService.swift
│ │ └── BucketListService.swift
│ │
│ ├── Navigation/
│ │ ├── AppNavigation.swift ← TabView + NavigationStacks
│ │ ├── AppRoute.swift ← Route constants (mirrors AppRoute.kt)
│ │ └── DeepLinkHandler.swift ← Handle notification deep links
│ │
│ ├── Theme/
│ │ ├── CloserTheme.swift ← Color, typography, spacing tokens
│ │ ├── ColorExtension.swift
│ │ └── ViewModifiers.swift ← Common styled modifiers
│ │
│ ├── Components/ ← Reusable SwiftUI components
│ │ ├── CategoryGlyph.swift ← Custom vector icons per category
│ │ ├── AnswerCard.swift
│ │ ├── StreakIndicator.swift
│ │ ├── PremiumBadge.swift
│ │ ├── LoadingView.swift
│ │ ├── ErrorView.swift
│ │ ├── EmptyStateView.swift
│ │ ├── PartnerStatusRow.swift
│ │ └── TappableHeartAnimation.swift
│ │
│ ├── Onboarding/
│ │ ├── OnboardingView.swift
│ │ ├── LoginView.swift
│ │ ├── SignUpView.swift
│ │ ├── ForgotPasswordView.swift
│ │ └── CreateProfileView.swift
│ │
│ ├── Pairing/
│ │ ├── PairPromptView.swift
│ │ ├── CreateInviteView.swift
│ │ ├── AcceptInviteView.swift
│ │ ├── EmailInviteView.swift
│ │ ├── InviteConfirmView.swift
│ │ ├── RecoveryView.swift
│ │ └── EncryptionUpgradeView.swift
│ │
│ ├── Home/
│ │ ├── HomeView.swift
│ │ └── PartnerHomeView.swift
│ │
│ ├── Questions/
│ │ ├── DailyQuestionView.swift
│ │ ├── AnswerRevealView.swift
│ │ ├── AnswerHistoryView.swift
│ │ ├── QuestionPackLibraryView.swift
│ │ ├── QuestionCategoryView.swift
│ │ ├── QuestionComposerView.swift
│ │ └── QuestionThreadView.swift
│ │
│ ├── Play/
│ │ ├── PlayHubView.swift
│ │ ├── ThisOrThatView.swift
│ │ ├── HowWellView.swift
│ │ ├── DesireSyncView.swift
│ │ ├── ConnectionChallengesView.swift
│ │ ├── MemoryLaneView.swift
│ │ ├── WaitingForPartnerView.swift
│ │ ├── GameHistoryView.swift
│ │ ├── ThisOrThatReplayView.swift
│ │ ├── DesireSyncReplayView.swift
│ │ └── HowWellReplayView.swift
│ │
│ ├── Wheel/
│ │ ├── CategoryPickerView.swift
│ │ ├── SpinWheelView.swift ← Canvas-based spinning wheel
│ │ ├── WheelSessionView.swift
│ │ ├── WheelCompleteView.swift
│ │ └── WheelHistoryView.swift
│ │
│ ├── Dates/
│ │ ├── DateMatchView.swift
│ │ ├── DateMatchesView.swift
│ │ ├── DateBuilderView.swift
│ │ └── BucketListView.swift
│ │
│ ├── Settings/
│ │ ├── SettingsView.swift
│ │ ├── AccountView.swift
│ │ ├── NotificationSettingsView.swift
│ │ ├── PrivacyView.swift
│ │ ├── SubscriptionView.swift
│ │ ├── RelationshipSettingsView.swift
│ │ └── DeleteAccountView.swift
│ │
│ ├── Paywall/
│ │ └── PaywallView.swift
│ │
│ └── Resources/
│ ├── Assets.xcassets/
│ ├── questions.json ← Bundled local question bank
│ └── wordlist.json ← BIP39-style recovery phrase wordlist
├── CloserUITests/
│ └── ... ← UI snapshot tests
├── CloserTests/
│ └── ... ← Unit tests
├── Podfile ← Or use Swift Package Manager exclusively
└── README-iOS.md
```
---
## Quick Reference: Key Firebase iOS SDKs (via SPM)
| SDK | Product | Import |
|---|---|---|
| `firebase-ios-sdk/Auth` | Firebase Auth | `import FirebaseAuth` |
| `firebase-ios-sdk/Firestore` | Firestore | `import FirebaseFirestore` |
| `firebase-ios-sdk/Messaging` | FCM | `import FirebaseMessaging` |
| `firebase-ios-sdk/Functions` | Cloud Functions | `import FirebaseFunctions` |
| `firebase-ios-sdk/Storage` | Firebase Storage | `import FirebaseStorage` |
| `RevenueCat/purchases-ios` | RevenueCat | `import RevenueCat` |
| `google/GoogleSignIn-iOS` | Google Sign-In | `import GoogleSignIn` |
---
## Analysis Summary
| Metric | Count |
|---|---|
| Android Kotlin files | 249 |
| Android screens (Composables) | ~48 |
| Firestore top-level collections | 3 (`users`, `couples`, `invites`) |
| Firestore subcollections | ~20 |
| Cloud Functions (callable) | 6 |
| Cloud Functions (triggers) | 4 |
| Cloud Functions (scheduled) | 2 |
| Cloud Functions (webhook) | 1 |
| Domain models to port | 35 |
| Crypto classes to port | 10 (recommended: skip for MVP) |
| Express server files | 11 (not client-facing) |
---
*Generated from Android Kotlin codebase v0.2.0 — use as the single source of truth for iOS development.*