# 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` | `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 ├── 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 ├── 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 { 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 func purchase(package: Package) async -> Result func restorePurchases() async -> Result var customerInfo: AsyncStream> { 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.*