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
- iOS-Specific Adaptations
- Auth & Onboarding Flow
- Screen Map (Android → iOS)
- Navigation Structure
- Firestore Collections & Documents
- Domain Models (Swift Equivalents)
- Cloud Functions
- Secondary Express Server
- RevenueCat / Entitlements
- E2EE / Crypto Layer
- UI Design System
- DI Pattern (Hosting)
- 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)
- Configure
GoogleService-Info.plist in Xcode target (includes reversed client ID URL scheme)
- Use
GIDSignIn.sharedInstance.signIn(withPresenting:) to get GIDGoogleUser
- Extract
idToken from user.idToken.tokenString
- Create Firebase credential:
GoogleAuthProvider.credential(withIDToken: idToken, accessToken: accessToken)
- 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:
- Integrate RevenueCat via SPM (
purchases-ios)
- Configure with API key (same
RC_API_KEY as Android BuildConfig)
- Use
Purchases.shared.customerInfoStream (AsyncSequence) instead of listener
- After purchase, call
syncEntitlement Cloud Function
- Also observe Firestore
users/{uid}/entitlements/premium as server-side source of truth
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:
- Sealed at submit time — encrypted with a one-time AES-256-GCM key
- Committed — SHA-256 hash of plaintext written to Firestore
- 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.