Closer/iphone/ARCHITECTURE_AUDIT.md

1108 lines
48 KiB
Markdown
Raw Normal View History

# 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.*