1108 lines
48 KiB
Markdown
1108 lines
48 KiB
Markdown
|
|
# 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.*
|