Closer/iphone/ARCHITECTURE_AUDIT.md

48 KiB
Raw Blame 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
  2. Auth & Onboarding Flow
  3. Screen Map (Android → iOS)
  4. Navigation Structure
  5. Firestore Collections & Documents
  6. Domain Models (Swift Equivalents)
  7. Cloud Functions
  8. Secondary Express Server
  9. RevenueCat / Entitlements
  10. E2EE / Crypto Layer
  11. UI Design System
  12. DI Pattern (Hosting)
  13. Project File Layout for /iphone

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:

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:

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

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

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

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

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

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)

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

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"
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

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)

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

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

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):

@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.