Closer/docs/Engineering_Reference_Manua...

57 KiB

Closer Engineering Reference Manual

Private daily questions for couples who want honest answers before shared conversations.

This is the source of truth for Closer's architecture, security model, data model, and engineering conventions. Read it before changing anything that crosses layers: auth, crypto, Firestore rules, Cloud Functions, billing, or cross-platform data contracts.

How to use this document


Table of Contents

  1. System overview
  2. Repository layout
  3. Authentication and pairing flow
  4. End-to-end encryption model
  5. Daily question lifecycle
  6. Firestore data model
  7. Firestore security rules
  8. Cloud Functions
  9. Billing
  10. Notifications
  11. iOS-specific notes
  12. Build and release
  13. Engineering conventions
  14. Where to look first

System overview

Closer is a couples relationship app. The product goal is private, mutual-reveal relationship questions with real encryption and calmer UX. It is not a social network: there are no public feeds, no likes, and no followers. The core loop is one partner answers a private prompt, the other partner answers independently, and both choose when to reveal.

Three platform split

Platform Stack Role
Android Kotlin, Jetpack Compose, Material 3, Hilt, Room, DataStore Reference implementation; owns the E2EE crypto layer
iOS SwiftUI, MVVM, async/await, Firebase iOS SDK, RevenueCat Screen parity with Android; E2EE cross-compatibility not yet implemented
Backend Firebase Auth, Firestore, Cloud Functions (TypeScript), FCM, App Check Shared source of truth for both apps

Data ownership

  • Each user owns their own users/{uid} document and subcollections.
  • A couple owns the couples/{coupleId} document and all subcollections beneath it.
  • The server (Cloud Functions / Admin SDK) owns invite creation, daily question assignment, entitlement events, and any cross-user writes.
  • Clients never write to another user's document or to another couple's document.
  • E2EE answer content is encrypted on the device. The server sees only ciphertext.

Key architectural decisions

  • Clean architecture on Androidcore/, data/, domain/, ui/ layers with Hilt wiring. The crypto/ package is a peer of core/ because it has its own internal state and lifecycle.
  • MVVM on iOSAppState ObservableObject + EnvironmentObject, per-feature ViewModels. The codebase is small enough that no DI framework is used; dependencies are passed by hand via initializers and shared singletons.
  • Server-mediated pairing — 6-character invite codes are enumerable, so invite reads/writes are server-side only. Direct client writes to invites/ are denied in Firestore rules.
  • Server-verified billing — RevenueCat webhooks write entitlements; the Android app observes Firestore for premium state, with the local RevenueCat SDK as a fallback. iOS does not yet observe Firestore entitlements and reads RevenueCat only.
  • Local-first questions — Question content ships in the app so daily questions load instantly; only assignment and sync hit the network.
  • Encrypted answers, plaintext couple metadata — Couple names, photo URLs, and rhythm stats (streakCount, lastAnsweredAt) are plaintext. Only answer content and key material is encrypted.

Repository layout

Android

app/src/main/java/app/closer/
├── MainActivity.kt
├── core/
│   ├── analytics/         # Firebase Analytics + Crashlytics wrappers
│   ├── billing/           # EntitlementChecker + FirestoreEntitlementChecker
│   ├── crash/             # CrashReporter abstraction
│   ├── feature/           # Feature flags
│   ├── navigation/        # AppRoute constants, NavHost, ExternalLinks
│   ├── notifications/     # FCM service, TokenRegistrar, quiet hours
│   └── security/          # Auth rate limiter
├── crypto/                # E2EE: Tink AEAD, BouncyCastle Argon2id, key stores
├── data/
│   ├── local/             # Room DAOs, DataStore, EncryptedSharedPreferences
│   ├── remote/            # Firestore data sources, Cloud Functions callable wrappers
│   └── repository/        # Repository implementations
├── domain/
│   ├── model/             # Plain data classes
│   └── repository/        # Repository interfaces
├── di/                    # Hilt modules
└── ui/                    # Compose screens + ViewModels
    ├── answers/           # Answer write/reveal/history
    ├── auth/              # Auth screens
    ├── brand/             # Logo, splash, illustrated empty states
    ├── challenges/        # Connection Challenges
    ├── components/        # Shared Compose components
    ├── dates/             # Date builder, matches, bucket list
    ├── desiresync/        # Preferences alignment exercise
    ├── games/             # Game scaffolding
    ├── home/              # Home dashboard + partner state
    ├── howwell/           # How Well Do You Know Me game
    ├── memorylane/        # Time capsules
    ├── onboarding/        # Onboarding screens
    ├── outcomes/          # 30/60/90 day check-ins
    ├── pairing/           # Invite create/accept/confirm/recovery
    ├── paywall/           # Subscription paywall
    ├── play/              # Play hub
    ├── questions/         # Daily question, packs, history
    ├── settings/          # All settings screens (account, privacy, security, subscription, …)
    ├── theme/             # CloserTheme
    ├── thisorthat/        # This or That game
    └── wheel/             # Spin the wheel

The Android settings package contains: SettingsScreen, SettingsViewModel, SettingsVisuals, AccountScreen, EditProfileScreen + EditProfileViewModel, AppearanceScreen, DeleteAccountScreen, NotificationSettingsScreen, PrivacyScreen, RelationshipSettingsScreen, SecurityScreen, SubscriptionScreen. The SecurityScreen is biometric-gated for the recovery phrase reveal.

The app/src/main/res/drawable-nodpi/ folder holds brand illustrations (onboarding, invite, paywall, subscription, history).

iOS

iphone/
├── ARCHITECTURE_AUDIT.md          # iOS port blueprint (generated from Android source)
├── project.yml                     # XcodeGen spec
├── Package.swift                   # SPM dependency manifest
├── Closer.entitlements             # Push, Keychain, App Groups
├── Info.plist                      # Bundle config, push entitlement, URL schemes
├── GoogleService-Info.plist        # Firebase config — gitignored, copy from your project
└── Closer/
    ├── CloserApp.swift             # @main, AppState, AppDelegate, RevenueCat init
    ├── ContentView.swift           # Root NavigationStack + TabView
    ├── Core/
    │   ├── Auth/AuthService.swift
    │   ├── Billing/BillingService.swift
    │   └── Notifications/NotificationService.swift
    ├── Crypto/                     # Intended for CryptoKit E2EE parity — currently empty
    ├── Models/                     # Codable Firestore + domain types
    ├── Services/FirestoreService.swift  # Firestore + callable wrappers
    ├── Theme/CloserTheme.swift     # Colors, typography, spacing
    ├── Components/                 # Shared SwiftUI components
    ├── Navigation/                 # Root routing (paired with ContentView)
    ├── Onboarding/                 # Onboarding, login, signup
    ├── Pairing/                    # Invite code, partner confirm, recovery
    ├── Home/                       # Home dashboard, partner mirror
    ├── Questions/                  # Daily question, answer reveal, history, packs
    ├── Play/                       # Play hub + games
    ├── Wheel/                      # Spin wheel
    ├── Dates/                      # Date swipe, matches, builder, bucket list
    ├── Settings/                   # Settings, paywall, subscription, help, data export
    ├── Paywall/                    # Placeholder — paywall screen is rendered from Settings
    └── Resources/                  # Illustrations, assets

The iOS Crypto/ folder is intentionally empty today. The Swift port defers E2EE parity to a follow-up batch. The current iOS path creates encryptionVersion = 0 couples and uses the plaintext answer path. See iOS E2EE gap for the precise scope and risk.

Paywall/ is currently a placeholder; the actual paywall screen is rendered from Settings/SettingsViews.swift. A dedicated paywall view is a future cleanup.

Cloud Functions

functions/src/
├── index.ts                          # Admin SDK init + exports
├── billing/
│   ├── revenueCatWebhook.ts          # HTTPS webhook — Ed25519 signature verify
│   ├── entitlementLogic.ts           # Idempotent entitlement event handlers
│   ├── entitlementLogic.test.ts      # Vitest unit tests
│   └── syncEntitlement.ts            # Callable — forced re-sync from client
├── couples/
│   ├── createInviteCallable.ts       # Server-side invite creation
│   ├── acceptInviteCallable.ts       # Code validation, couple creation, rate limit
│   ├── leaveCoupleCallable.ts        # Voluntary leave + cleanup
│   ├── onCoupleLeave.ts              # Trigger when coupleId cleared
│   ├── submitOutcomeCallable.ts      # 30/60/90 day check-in
│   └── scheduledOutcomesReminder.ts  # Pub/Sub schedule: 30/60/90 reminders
├── dates/
│   └── createDateMatch.ts            # Trigger: mutual-love → date match
├── games/
│   └── onGameSessionUpdate.ts        # Trigger: game state changes → notify partner
├── notifications/
│   ├── reminders.ts                  # sendDailyQuestionReminder, sendPartnerAnsweredNotification
│   ├── sendGentleReminderCallable.ts # Manual gentle reminder
│   └── gameRetention.ts              # Challenge day reminders, capsule unlocks
├── questions/
│   ├── assignDailyQuestion.ts        # Pub/Sub schedule + manual callable
│   ├── onAnswerWritten.ts            # Trigger: notify partner on answer
│   └── onMessageWritten.ts           # Trigger: thread messages
├── security/
│   └── checkDeviceIntegrity.ts       # Play Integrity verdict verification
└── users/
    └── onUserDelete.ts               # Auth user deletion cascade

There is no auth/ module. Authentication is handled entirely by the Firebase Auth client SDK; the Admin SDK is used in the users/onUserDelete.ts trigger and in couples/acceptInviteCallable.ts to read user docs.

Shared configuration

firestore.rules          # Security rules (single source of truth)
firestore.indexes.json   # Composite indexes and TTL field overrides
seed/                    # Question pack JSON and local DB generation
server/                  # Optional Express webhook/health service (not client-facing)
docs/                    # This manual, QA notes, release prep, store assets

Authentication and pairing flow

Auth providers

Firebase Auth supports three sign-in paths:

  1. Anonymous — used for the trial onboarding flow. The user can use the app without an account and is prompted to upgrade before any irreversible action.
  2. Email/password — standard sign-up and login.
  3. Google Sign-In — via Credential Manager on Android, the Google Sign-In SDK on iOS.

The Android FirebaseAuthDataSource exposes the standard Firebase upgrade paths; iOS uses the same Firebase Auth APIs through AuthService.swift. Anonymous accounts are linked to email/Google credentials when the user upgrades. If linking fails because the credential already exists, the app signs into the existing account.

Pairing flow

The pairing flow is server-mediated because 6-character codes are enumerable. The flow is identical on both platforms.

Inviter (Android or iOS)
  1. Generate couple keyset + recovery phrase (CoupleEncryptionManager on Android;
     iOS skips step 1 — see iOS E2EE gap).
  2. Generate 6-char code.
  3. Encrypt phrase with code (RecoveryKeyManager.encryptPhraseWithCode) — Android only.
  4. Call createInviteCallable(code, wrappedKey, salt, params, encryptedPhrase).
  5. Server writes /invites/{code} with 24h TTL and a `notification_queue` entry.
  6. Inviter shows code, copies/shares it.

Acceptor (any platform)
  7. Enter code.
  8. Call acceptInviteCallable({ code }).
  9. Server validates code, creates /couples/{coupleId}, links both user docs,
     returns wrappedKey + encryptedRecoveryPhrase.
  10. Acceptor decrypts phrase with code (decryptPhraseWithCode) — Android only.
  11. Acceptor unwraps keyset with phrase (CoupleEncryptionManager.unwrapAndStore) — Android only.
  12. Both users now share the same couple key (or plaintext for iOS couples).

The couples document is never written by clients. Even legitimate field updates like streakCount go through Cloud Functions or are blocked by rules. See Firestore security rules for the per-field immutability matrix.

The couples document model

/couples/{coupleId}
  id: string
  userIds: [string, string]
  inviteCode: string
  createdAt: timestamp (server-side)
  streakCount: int
  lastAnsweredAt: timestamp | null
  currentQuestionId: string | null     # server-controlled, read by clients
  activePackId: string | null          # server-controlled, read by clients
  encryptionVersion: int               # 0 plaintext, 1 migrating, 2 strict
  wrappedCoupleKey: string | null
  kdfSalt: string | null
  kdfParams: string | null
  encryptionMigrationUsers: map<string, bool>

currentQuestionId and activePackId exist as fields and are read by clients to display "today's question" state, but they are server-controlled — clients cannot write them.

Rate limiting on accept

functions/src/couples/acceptInviteCallable.ts enforces a rolling-window rate limit:

  • Window: 1 hour.
  • Max attempts per caller: 10.
  • Storage: users/{uid}/invite_attempts with a Firestore TTL field (expiresAt, 25 hours) so old attempts age out automatically.
  • Index: TTL field override is declared in firestore.indexes.json under fieldOverrides for the invite_attempts collection group.

This prevents brute-forcing the 6-character invite code space.

Recovery phrase flow

The recovery phrase is the only human-readable secret in the system. It is never sent to the server in plaintext.

  1. When an Android inviter creates a couple, RecoveryKeyManager.generateRecoveryPhrase() produces a 10-word phrase from a 256-word list. The phrase has roughly 80 bits of raw entropy; Argon2id makes brute-force infeasible.
  2. The inviter encrypts the phrase with the invite code using encryptPhraseWithCode and stores the blob on the invite document.
  3. The acceptor receives the encrypted blob, decrypts it with the same code, and stores the phrase locally.
  4. The phrase is used to unwrap the couple keyset from wrappedCoupleKey.
  5. The recovery phrase can be shown in settings and used to recover the couple key on a new device. Changing the recovery phrase re-wraps the locally-held keyset and uploads a new wrappedCoupleKey to Firestore.

iOS does not generate or store a recovery phrase in the current build. iOS couples have no recovery path; the couple key (when iOS E2EE ships) will need a different recovery story or the gap will need to be communicated to users.

Key Android files

  • app/src/main/java/app/closer/data/remote/FirebaseAuthDataSource.kt — Firebase Auth wrapper.
  • app/src/main/java/app/closer/data/remote/FirestoreInviteDataSource.kt — callable wrappers for invite create/accept.
  • app/src/main/java/app/closer/data/repository/InviteRepositoryImpl.kt — invite business logic, code retry, phrase encryption/decryption.
  • app/src/main/java/app/closer/crypto/CoupleEncryptionManager.kt — keyset orchestration.
  • app/src/main/java/app/closer/crypto/RecoveryKeyManager.kt — Argon2id KDF, phrase generation, wrap/unwrap.
  • app/src/main/java/app/closer/ui/pairing/CreateInviteViewModel.kt and AcceptInviteViewModel.kt — UI layer.

Key Cloud Functions

  • functions/src/couples/createInviteCallable.ts
  • functions/src/couples/acceptInviteCallable.ts

End-to-end encryption model

Encryption versions

couples/{coupleId}.encryptionVersion is the single source of truth for a couple's encryption state. The mapping is canonical in app/src/main/java/app/closer/crypto/EncryptionVersion.kt and mirrored in Cloud Functions.

Version Name Meaning
0 PLAINTEXT No couple key; answers may be plaintext. Used by iOS couples until E2EE parity ships.
1 MIGRATING A couple key exists but historical content is still being rewritten by both partners. Kept for backwards compatibility with older couples; no new couples should be created at v1.
2 STRICT All answer-bearing paths require encryption. Default for all new Android couples.

The Cloud Function acceptInviteCallable.ts derives encryptionVersion from whether E2EE fields are present: if wrappedCoupleKey, kdfSalt, and kdfParams are all non-null, the couple is created at v2; otherwise v0. This keeps the iOS-and-Android-different-defaults case from breaking.

Couple key wrapping with Argon2id

The couple keyset is a Tink AES-256-GCM keyset generated once per couple.

  • RecoveryKeyManager.newCoupleKeyset() creates the keyset.
  • RecoveryKeyManager.wrap(keyset, phrase) derives a 32-byte key with Argon2id:
    • memory: 46 MiB (46080 KiB)
    • iterations: 3
    • parallelism: 1
    • salt: 16 random bytes
  • The keyset plaintext is encrypted with AES-256-GCM using the derived key. AAD is the fixed string "closer_couple_key" so the blob is portable across invite-code reconciliation.
  • The wrapped result is stored on the couple document as wrappedCoupleKey, kdfSalt, kdfParams.

The Argon2id parameters are deliberately chosen to take ~2-3 seconds on a mid-range phone — slow enough to make offline brute-force infeasible, fast enough that recovery on a new device is bearable. Do not change these parameters without auditing cross-platform compatibility.

Tink AEAD

  • FieldEncryptor.kt encrypts individual Firestore fields. Wire format: enc:v1:{base64(tinkCiphertext)}. AAD is the coupleId.
  • SealedAnswerEncryptor.kt encrypts sealed answer payloads. Wire format: sealed:v1:{urlsafe-base64-no-padding}. AAD is coupleId|questionId|userId.
  • CoupleKeyStore.kt persists Tink keyset handles in Keystore-backed EncryptedSharedPreferences.

Recovery phrase

See Recovery phrase flow. The recovery phrase is the only human-readable secret. It is never sent to the server in plaintext.

Sealed-answer partner-proof mode

Sealed answers (schemaVersion = 3) provide partner-proof privacy: even a malicious or compromised device cannot read the partner's answer until both partners have submitted and released their one-time keys.

Flow:

1. User composes answer.
2. App generates a one-time AES-256-GCM key.
3. App computes SHA-256 commitment over canonical JSON payload.
4. App seals payload with one-time key → writes encryptedPayload + commitmentHash
   to Firestore.
5. App stores one-time key locally in PendingAnswerKeyStore.
6. Partner does the same.
7. After both answers exist, each app:
   a. Reads partner's public key from users/{partnerId}/devices/primary.
   b. Wraps its own one-time key to partner's public key with ECIES P-256.
   c. Writes keybox to releaseKeys/{partnerId}.
8. Each app reads the keybox written for them, unwraps with their private key,
   and decrypts the partner's sealed payload.

Wire formats:

Field Format Where stored
Sealed payload sealed:v1:{urlsafe-base64-no-padding} answers/{userId}.encryptedPayload
Commitment hash sha256:{urlsafe-base64-no-padding} (43 chars) answers/{userId}.commitmentHash
Keybox keybox:v1:{urlsafe-base64-no-padding} (120+ chars) answers/{userId}/releaseKeys/{recipientId}.encryptedAnswerKey
Public key pub:v1:{urlsafe-base64-no-padding} users/{uid}/devices/primary.publicKey

The commitment hash lets the reveal step verify that the decrypted payload matches what was originally sealed. If a malicious server (or a future bug) tampers with encryptedPayload and re-seals it with a new key, the commitment check fails at reveal time.

ECIES P-256 details

  • UserKeyManager.kt generates a per-user ECIES P-256 keypair using Tink's ECIES_P256_HKDF_HMAC_SHA256_AES128_GCM template.
  • The private key is stored in EncryptedSharedPreferences. The public key is extracted and published to Firestore.
  • ReleaseKeyEncryptor.kt wraps a one-time answer key to the recipient's public key.
  • Context info for ECIES: coupleId|questionId|senderUserId|recipientUserId. This binds the wrapped key to a specific origin and destination.

Known limitation: single-device keys

UserKeyManager.kt documents a known limitation: there is one keypair per user, stored only on the device that created it. If a user signs in on a second device and generates a new keypair, sealed answers whose keys were wrapped for the old public key become undecryptable. The fix path is multi-device key distribution, but it is not implemented. Do not market multi-device support until this is resolved.

Firestore rules regex helpers

The security rules validate E2EE wire formats using regex helpers. These helpers are the contract — any client writing sealed answers must match them exactly.

isCiphertext(value)        → ^enc:v1:[A-Za-z0-9+/]+={0,2}$
isSealedPayload(value)     → ^sealed:v1:[A-Za-z0-9_-]{80,}$
isKeybox(value)            → ^keybox:v1:[A-Za-z0-9_-]{120,}$
isCommitmentHash(value)    → ^sha256:[A-Za-z0-9_-]{43}$

Bumping the version prefix (e.g. sealed:v1:sealed:v2:) is a wire-format break. Plan migration carefully.

Key Android crypto files

  • app/src/main/java/app/closer/crypto/EncryptionVersion.kt — canonical version constants.
  • app/src/main/java/app/closer/crypto/RecoveryKeyManager.kt — Argon2id, phrase generation, couple key wrap/unwrap.
  • app/src/main/java/app/closer/crypto/CoupleEncryptionManager.kt — orchestration.
  • app/src/main/java/app/closer/crypto/CoupleKeyStore.kt — local secure storage for keysets.
  • app/src/main/java/app/closer/crypto/FieldEncryptor.ktenc:v1: field encryption.
  • app/src/main/java/app/closer/crypto/SealedAnswerEncryptor.kt — sealed payloads.
  • app/src/main/java/app/closer/crypto/SealedRevealManager.kt — release-key flow orchestration.
  • app/src/main/java/app/closer/crypto/ReleaseKeyEncryptor.kt — ECIES wrapping of one-time keys.
  • app/src/main/java/app/closer/crypto/UserKeyManager.kt — per-user ECIES keypair lifecycle.
  • app/src/main/java/app/closer/crypto/AnswerCommitment.kt — canonical JSON + SHA-256.
  • app/src/main/java/app/closer/crypto/PendingAnswerKeyStore.kt — local store for one-time keys awaiting partner reveal.

Daily question lifecycle

Assignment

assignDailyQuestion is a scheduled Pub/Sub function:

  • Schedule: 0 23 * * * in America/Chicago timezone.
  • What it does: picks a random active free question from the questions Firestore collection, then writes a daily_question/{date} document under each couple.
  • Document path: couples/{coupleId}/daily_question/{YYYY-MM-DD}.
  • Idempotency: uses docRef.create({...}) and catches ALREADY_EXISTS so a re-run on the same day is a no-op.
  • Document shape:
    couples/{coupleId}/daily_question/{date}
      questionId: string
      date: string (YYYY-MM-DD)
      assignedAt: Timestamp
      expiresAt: Timestamp
    

There is also assignDailyQuestionCallable for manual / on-demand assignment (used when a couple is created mid-day and shouldn't wait for the next scheduled run).

Date math — known DST bug

The function uses CST_OFFSET_HOURS = -6 and does not account for daylight saving time. The actual UTC offset for America/Chicago is -5 (CDT) in summer and -6 (CST) in winter. This means:

  • In summer, the date key is computed by adding -6h to UTC. The 6 PM cron is at 23:00 UTC, so date is correct in summer.
  • In winter, the same 23:00 UTC cron fires at 5 PM local. Adding -6h gives the local date as intended.

In practice the date key is correct most of the time, but the comment "America/Chicago 6:00 PM == 23:00 UTC" is only true in CDT. During CST, the cron actually runs at 5 PM local and the offset is still correct. The fix is to use a proper IANA tz library (e.g. date-fns-tz) rather than a hardcoded offset. Track this in FUTURE.md.

Answer write

Answers are written by the client under couples/{coupleId}/daily_question/{date}/answers/{userId}. The Firestore rules require the document to match one of two shapes:

  1. Legacy / company-proof (any schemaVersion ≠ 3): enc:v1: ciphertext fields, content is encrypted with the couple key.
  2. Sealed / partner-proof (schemaVersion = 3): sealed:v1: payload + sha256: commitment; content key is released only after both partners have submitted.

A write must match exactly one of these shapes — the rules reject anything else.

Partner notification

functions/src/questions/onAnswerWritten.ts is a Firestore trigger on couples/{coupleId}/daily_question/{date}/answers/{userId} (onCreate). It looks up the partner's FCM tokens and sends:

  • A data message so the client can route directly to the reveal screen.
  • A notification block for system-tray display when the app is in the background.

Token lookup reads both a legacy fcmToken field on the user doc and a dedicated fcmTokens subcollection for multi-device.

Reveal flow

The reveal happens client-side after both partners have submitted:

  1. Each app checks for the partner's answers/{partnerId} doc.
  2. Each app writes a releaseKeys/{partnerId}.encryptedAnswerKey containing the sender's one-time key wrapped to the partner's ECIES public key.
  3. Each app reads the releaseKeys/{selfUserId}.encryptedAnswerKey written by the partner.
  4. Each app unwraps the key with its private key and decrypts the partner's encryptedPayload.
  5. Each app verifies the decrypted payload's SHA-256 commitment matches commitmentHash.
  6. The reveal screen shows both answers side-by-side.

The reveal state is gated by Firestore rules: only the sender writes the keybox, only the recipient reads it. The sealed payload is created with answerKeyReleased: false; the rules only allow the reveal-metadata fields (isRevealed, answerKeyReleased, updatedAt) to change after creation.

Thread questions

Thread questions follow the same sealed flow but use a different path:

  • couples/{coupleId}/threads/{threadId}/messages/{userId} — the thread message, not the daily answer.
  • The Firestore rules use isSealedThreadAnswerCreate / isSealedThreadAnswerUpdate helpers, which are identical to the answer helpers except there is no answerDate and no isRevealed field (reveal state is tracked by the thread VM, not the rules).

Firestore data model

/users/{uid}
  email: string
  displayName: string
  photoUrl: string | null
  coupleId: string | null
  hasPremium: bool            # server-only write
  platform: 'android' | 'ios' | null
  /entitlements/premium        # written by Cloud Functions only
    premium: bool
    expiresAt: Timestamp | null
  /fcmTokens/{tokenId}         # owned by the user
    token: string
    platform: 'android' | 'ios'
    updatedAt: Timestamp
  /devices/{deviceId}          # ECIES public keys for sealed answers
    publicKey: string          # 'pub:v1:...'
    platform: 'android' | 'ios'
    updatedAt: Timestamp
  /outcomes/{dayKey}           # day_0, day_30, day_60, day_90; server-only
    submittedAt: Timestamp
    answers: map
  /notification_queue/{id}     # server-only; partner-pending notifications
    type: string
    payload: map
    createdAt: Timestamp
    delivered: bool
  /invite_attempts/{id}        # rate-limit; Firestore TTL
    code: string
    attemptedAt: Timestamp
    expiresAt: Timestamp       # TTL field

/couples/{coupleId}
  id, userIds[2], inviteCode, createdAt
  streakCount, lastAnsweredAt
  currentQuestionId, activePackId
  encryptionVersion, wrappedCoupleKey, kdfSalt, kdfParams
  encryptionMigrationUsers: map<uid, bool>
  /daily_question/{YYYY-MM-DD}
    questionId, date, assignedAt, expiresAt
    /answers/{userId}            # schemaVersion 2 (enc:v1:) or 3 (sealed:v1:)
    /releaseKeys/{recipientId}   # keybox:v1:
  /threads/{threadId}
    questionId, createdAt, createdByUserId
    /messages/{userId}           # schemaVersion 3 sealed
  /this_or_that/{sessionId}      # games: enc:v1: shared couple key
  /desire_sync/{sessionId}
  /how_well/{sessionId}
  /wheel/{sessionId}
  /date_swipes/{swipeId}
    userId, dateIdeaId, action: 'love' | 'maybe' | 'skip'
  /date_matches/{matchId}
    userIds, dateIdeaId, createdAt
  /date_plans/{planId}
    title, dateTime, status: 'draft' | 'planned' | 'completed'
  /date_plan_preferences/{uid}
    categories: map
  /bucket_list/{itemId}
    title, category, addedByUserId, completedAt
  /gentle_reminders/{YYYY-MM-DD}   # one doc per calendar day per couple; daily lock

/invites/{code}                  # server-only writes; 24h TTL
  code, inviterUserId, status: 'pending' | 'accepted' | 'expired'
  createdAt, expiresAt
  wrappedCoupleKey, kdfSalt, kdfParams, recoveryPhrase

/questions/{questionId}          # read-only catalog (admin seeded)
  text, categoryId, active, isPremium

/date_ideas/{dateIdeaId}         # read-only catalog
  title, description, category, imageUrl

/rate_limits/{uid}_gentle_reminder   # server-only; rolling-hour transaction counter
  windowStart: Timestamp
  count: int

/entitlement_events/{eventId}    # server-only; idempotency markers
  userId, type, source, processedAt

Cross-references

  • users/{uid}.coupleIdcouples/{coupleId}.
  • couples/{coupleId}.userIdsusers/{uid} (the two members).
  • couples/{coupleId}/daily_question/{date}/answers/{userId}.userIdusers/{uid}.
  • couples/{coupleId}/date_swipes/{swipeId}.userIdusers/{uid}.
  • entitlement_events/{eventId}.userIdusers/{uid}.

Firestore security rules

firestore.rules is the single source of truth for client authorization. Admin SDK / Cloud Functions bypass these rules, so anything that must be server-only is denied for direct client writes.

Helper functions

The rules file is organized into helper functions first, then per-collection match blocks.

isSignedIn()                          request.auth != null
isOwner(uid)                          request.auth.uid == uid
isCouplesMember(coupleId)             request.auth.uid in couples/{coupleId}.userIds
isValidInviteCode(code)               matches('^[a-zA-Z0-9]{6}$')
isImmutable(fields)                   diff(...).affectedKeys().hasOnly(fields)
isCiphertext(value)                   matches('^enc:v1:[A-Za-z0-9+/]+={0,2}$')
isSealedPayload(value)                matches('^sealed:v1:[A-Za-z0-9_-]{80,}$')
isKeybox(value)                       matches('^keybox:v1:[A-Za-z0-9_-]{120,}$')
isCommitmentHash(value)               matches('^sha256:[A-Za-z0-9_-]{43}$')
isSealedAnswerCreate(data)            sealed-answer shape + sealed:v1 + sha256:
isSealedAnswerUpdate()                only reveal-metadata fields
isStartingEncryptionMigration()       v0/v1 → v1 with empty migration map
isCompletingOwnEncryptionMigration()  v1 → v1/v2 with self in migration map
isUpdatingRecoveryWrap()              only wrappedCoupleKey/kdfSalt/kdfParams
isUpdatingCoupleRhythm()              only streakCount/lastAnsweredAt

Per-collection enforcement

users/{uid} — owner can read/create/update their own doc but hasPremium is server-only. entitlements/, notification_queue/, and outcomes/ are server-only writes. fcmTokens/ and devices/ are owner-writable. The devices/ public key is readable by the user's current couple partner only (to wrap release keys) — restricting it prevents speculative pre-encryption by non-partners.

date_ideas/ — read-only for any signed-in user; writes are admin-only.

invites/{code} — reads are restricted to the inviter (request.auth.uid == resource.data.inviterUserId). All writes are denied for clients. This is the core defense against 6-character code enumeration: even legitimate create/update/delete must go through a Cloud Function, which can enforce rate limits, uniqueness, and key-material checks.

couples/{coupleId} — only the two members may read. Writes are denied for clients entirely; the rules restrict the shape of the doc and let Cloud Functions do all updates. Field-level immutability helpers (isUpdatingCoupleRhythm, isUpdatingRecoveryWrap, isStartingEncryptionMigration, isCompletingOwnEncryptionMigration) define what each update path is allowed to touch.

couples/{coupleId}/daily_question/... — server-only writes. Daily-question assignment and answer-related subcollections are tightly constrained.

couples/{coupleId}/daily_question/{date}/answers/{userId} — the answer is private to its author until reveal. The isSealedAnswerCreate / isSealedAnswerUpdate helpers enforce the sealed-answer shape. Legacy answers (schemaVersion ≠ 3) must use enc:v1: ciphertext.

couples/{coupleId}/daily_question/{date}/answers/{userId}/releaseKeys/{recipientId} — create-only by the answer owner, readable only by the named recipient. keybox:v1: shape is enforced.

couples/{coupleId}/{this_or_that|desire_sync|how_well|wheel}/{sessionId}enc:v1: ciphertext per user. Games are company-proof (server can't read), but not partner-proof (a modified client could read the partner's encrypted slot before the reveal). Sealed per-answer keys are not used here because games are real-time simultaneous — both players submit and see results together.

entitlement_events/ — no client access.

Why invariants matter

The rules are not just access control — they are a wire-format contract. A client that writes a malformed sealed payload is denied at write time, which prevents bad data from propagating. Bumping a wire format version (e.g. sealed:v1:sealed:v2:) is a rules change AND a client change; do them together.


Cloud Functions

Module pattern

Every function module follows the same shape:

  • One or more exported handlers (callable, onRequest, onCall trigger, onCreate trigger, Pub/Sub schedule).
  • Lazy admin.firestore() / admin.messaging() access at invocation time. The Admin SDK is initialized once in functions/src/index.ts.
  • The function name is the export name. index.ts re-exports each handler explicitly — no glob imports.
  • Cloud Function logs are prefixed with the function name (e.g. [acceptInviteCallable]) for grep-ability.

Handler types

Type Example Notes
HTTPS onRequest revenueCatWebhook, health Path-based; bypass callable auth. Webhook requires Ed25519 signature verification.
HTTPS onCall createInviteCallable, acceptInviteCallable, syncEntitlement, sendDailyQuestionReminder, sendPartnerAnsweredNotification, sendGentleReminderCallable, submitOutcomeCallable, leaveCoupleCallable, checkDeviceIntegrity, assignDailyQuestionCallable Caller must be authenticated. Errors throw HttpsError.
Firestore onCreate onAnswerWritten, onMessageWritten, onCoupleLeave, onUserDelete, onGameSessionUpdate, createDateMatchOnMutualLove Event-driven; best-effort.
Auth onDelete onUserDelete Auth user deletion cascade.
Pub/Sub schedule assignDailyQuestion, scheduledOutcomesReminder Cron expression in America/Chicago.

Per-module responsibilities

  • billing — RevenueCat webhook, entitlement event handlers, forced re-sync callable. Entitlement writes are idempotent (write the same Firestore doc and use entitlement_events/ as a dedup marker).
  • couples — invite create/accept/leave, outcome submission, scheduled 30/60/90 reminders.
  • dates — mutual-love trigger creates a date match document.
  • games — game session updates notify the partner and append to notification_queue.
  • notifications — daily question reminders, partner-answered notifications, gentle reminders, challenge day reminders, capsule unlock schedule.
  • questions — daily question assignment, answer write trigger, thread message trigger.
  • security — Play Integrity verdict verification.
  • users — Auth user deletion cascade.

Webhook reliability

revenueCatWebhook acknowledges with HTTP 200 immediately after signature verification and parses the event, then before applying the entitlement write. This is intentional to prevent RevenueCat retries. If applyEntitlementEvent fails after the 200, the failure is logged but the event is not retried. The webhook handler does not currently use a dead-letter queue. Risk: a transient Firestore outage could lose entitlement events. The mitigation today is entitlement_events/{eventId} as an idempotency marker — re-running the webhook for a missing event would dedup on event ID. A future fix should add a Cloud Tasks-based retry or a dead-letter entitlement_events_failed/ collection.

Schedule

assignDailyQuestion          0 23 * * *   America/Chicago
scheduledOutcomesReminder    * * * * *    America/Chicago   (per-minute, scans couples)
unlockDueMemoryCapsules      every 1 hours                       (gameRetention.ts)
sendChallengeDayReminders    (in gameRetention.ts; check source for cron)

scheduledOutcomesReminder currently scans all couples with no pagination. It will need to shard or paginate as the user base grows.


Billing

RevenueCat integration

Both clients use the RevenueCat native SDK (purchases:8.20.0 on Android, purchases-ios via SPM). The SDK handles the platform billing surface (Google Play Billing on Android, StoreKit on iOS) and exposes a normalized CustomerInfo API.

The Android app reads the API key from BuildConfig.RC_API_KEY, which is sourced from local.properties or the RC_API_KEY env var. A release build with a missing or placeholder key fails fast with a GradleException from app/build.gradle.kts — there is a doFirst guard on every release assemble/bundle task.

iOS reads RC_API_KEY from Info.plist via the Secrets enum in CloserApp.swift. A missing or empty key logs a warning and the app continues, but the paywall will not be functional.

Server-verified entitlements

The Android FirestoreEntitlementChecker is the source of truth for premium state. It:

  1. Observes users/{userId}/entitlements/premium for real-time changes.
  2. If the server document does not exist, falls back to the local RevenueCat CustomerInfo.
  3. Treats an expiresAt in the past as premium = false.
  4. Fails closed: on Firestore listener error, isPremium() emits false (i.e. "not premium") rather than guessing.

The iOS DefaultEntitlementChecker actor does not observe Firestore entitlements. It reads RevenueCat CustomerInfo only, via Purchases.shared.customerInfoStream. iOS premium state is therefore client-verified, not server-verified — this is a known gap that should be closed before production.

The EntitlementChecker interface (Android) is intentionally narrow:

interface EntitlementChecker {
  fun isPremium(): Flow<Boolean>
  suspend fun hasPremium(): Boolean
  fun onCustomerInfoUpdated(customerInfo: CustomerInfo)
}

Callers collect isPremium() reactively rather than caching a one-time snapshot. After a successful purchase, the Android repository calls onCustomerInfoUpdated(...) to push the new CustomerInfo into the fallback so the next emit reflects it.

Webhook

functions/src/billing/revenueCatWebhook.ts:

  • Path: HTTPS, POST only. GETs return 405.
  • Auth: Ed25519 signature verification. REVENUECAT_SIGNING_KEY env var holds the base64-encoded DER/SPKI public key. Missing key → 500 (config error). Invalid/missing signature → 401.
  • Body: RevenueCat event payload. Malformed payload → 400.
  • Flow: acknowledge 200 immediately, then call applyEntitlementEvent(event).
  • Idempotency: entitlement_events/{eventId} records processed events. Re-delivered events are dropped.

Notifications

FCM

Both clients use the Firebase Messaging SDK. Android uses FirebaseMessagingService; iOS uses APNs + FCM bridge. Tokens are stored under users/{uid}/fcmTokens/{tokenId} with platform metadata.

TokenRegistrar (Android)

TokenRegistrar runs in the Android core/notifications/ module. On token refresh it writes to Firestore with the current device's platform, device ID, and timestamp. The trigger function onAnswerWritten reads both a legacy fcmToken field and the fcmTokens subcollection for fan-out.

Quiet hours

QuietHours is a DataStore-persisted data class in SettingsRepository. It suppresses non-critical notifications during a configured window. Server-side quiet-hour suppression is not implemented today — all suppression happens on the client. The push is still delivered, the client decides whether to display it. This is a known gap; if battery/UX becomes a problem, the suppression should move to the server.

Daily question reminders

sendDailyQuestionReminder is a callable in functions/src/notifications/reminders.ts. It is currently a placeholder — its own source comment states "This is a placeholder scheduler. The actual daily scheduling will be handled by a Firestore trigger / Cloud Scheduler integration later." What it does today:

  • Validates that data.userId === context.auth.uid (no cross-user spam).
  • Writes a record to users/{userId}/notification_queue with type: 'daily_question', a default title, a default body, read: false, sent: false.
  • Returns { queued: true, type: 'daily_question' }.

It does not actually send an FCM push. The notification is only delivered if a separate process consumes the queue and dispatches FCM. Today there is no such process, so sent: false records accumulate. The scheduled assignment function assignDailyQuestion is the authoritative daily-question pipeline; the reminder callable is a stub for the future FCM-based nudge.

sendPartnerAnsweredNotification has the same placeholder shape — it writes a notification_queue record but does not dispatch FCM. Real-time partner-answered push is currently driven by the Firestore trigger functions/src/questions/onAnswerWritten.ts, which does send an FCM with both a data payload (routing to the reveal screen) and a notification block (system-tray display).

When implementing real FCM delivery, consume the queue in this order: read unprocessed records from users/{uid}/notification_queue where sent: false, send FCM, mark sent: true. Use a notification_queue_dispatched counter to prevent double-send on retries.

Partner-answered notification

functions/src/questions/onAnswerWritten.ts is a Firestore onCreate trigger on couples/{coupleId}/daily_question/{date}/answers/{userId}. It looks up the partner's tokens and sends an FCM with a data payload (routing to the reveal screen) and a notification block (system-tray display).

Gentle reminders and challenges

sendGentleReminderCallable is a real, rate-limited function for nudges. It enforces two limits:

  • Per-user: max 5 gentle reminders per rolling hour, gated by a server-side transaction on rate_limits/{uid}_gentle_reminder. The Android-side NotificationRateLimiter is a UX hint, not authoritative.
  • Per-couple: one reminder per couple per calendar day (UTC). The lock is stored in couples/{coupleId}/gentle_reminders/{date} so it survives function restarts and is visible to both partners.

The notification is both an FCM push (for the system tray) and an entry in users/{partnerId}/notification_queue. On rate-limit hit, the function returns { allowed: false, retryAfterMinutes } rather than throwing.

sendChallengeDayReminders and unlockDueMemoryCapsules (both in gameRetention.ts) handle scheduled nudges. The memory capsule unlock is a Pub/Sub schedule every 1 hours that opens capsules whose lock date has passed. The challenge reminder is scheduled daily.

Note on stale names: gameRetention.ts is the file but its responsibilities are challenge reminders and memory capsule unlocks, not generic "game retention". The file name is a legacy name from an earlier product direction; renaming it is a low-priority cleanup.

Per-user notification_queue

users/{uid}/notification_queue/ is a server-only collection that stores pending partner notifications. The FCM push is the user-visible surface; the queue is for in-app polling and for tracking delivery. Reads are denied for clients; the app reacts to FCM, not the queue.


iOS-specific notes

Architecture

  • @main is in CloserApp.swift. AppState is the root @StateObject; views receive it via @EnvironmentObject.
  • AppState owns authState, currentUser, currentCouple, currentPartner, and isPremium. It listens to auth state and partner changes.
  • The app uses NavigationStack + .navigationDestination for routing. There is no DI framework; dependencies are passed through shared singletons (AuthService.shared, FirestoreService.shared, BillingService.shared).
  • iOS 17+ is the deployment target.

CloserTheme

CloserTheme.swift defines color tokens, typography, spacing, and radius. The brand is documented in docs/brand/visual-identity.md and copy is in docs/copy-guide.md. Brand color references use Color.closerPrimary, Color.closerBackground, etc. (not raw hex).

XcodeGen

The iOS project is generated by XcodeGen from iphone/project.yml. After editing project.yml, run xcodegen generate and reopen Closer.xcodeproj. Do not commit changes to Closer.xcodeproj — it is regenerated.

Build

cd iphone
xcodegen generate
xed Closer.xcodeproj
# or
xcodebuild -project iphone/Closer.xcodeproj \
           -scheme Closer \
           -destination 'platform=iOS Simulator,name=iPhone 15' \
           build

iOS E2EE gap

The iOS port does not implement E2EE today. Concrete consequences:

  • iOS couples are created with encryptionVersion = 0. Their couples doc has no wrappedCoupleKey / kdfSalt / kdfParams.
  • iOS answer writes use the plaintext path. Firestore rules allow plaintext only when encryptionVersion < 1.
  • iOS does not generate or store a recovery phrase.
  • iOS does not have a CryptoKit implementation of Tink keyset serialization, Argon2id KDF, or sealed-answer ECIES.
  • iOS premium state is RevenueCat-only, not server-verified.

A user who pairs an Android device with an iOS device today creates a mixed-couple (encryptionVersion = 2 from Android, but the iOS partner's flow is still plaintext). The Android-side rules tolerate this, but the Android user is the only one whose answers are actually encrypted — the iOS user's answers are plaintext. Do not ship this combination to users without:

  1. A CryptoKit E2EE implementation that produces byte-compatible ciphertexts with the Android Tink paths.
  2. An Argon2id implementation that produces identical bytes for the recovery phrase KDF (e.g. SwiftArgon2 with the exact same parameters: m=46080 KiB, t=3, p=1, salt=16 bytes).
  3. Server-side gating that refuses to pair cross-platform couples until iOS has parity, OR an explicit user-facing "iOS answers are not yet encrypted" notice.

iOS CryptoKit guidance (future)

When implementing iOS E2EE parity:

  • Use CryptoKit's AES.GCM for symmetric encryption. AAD binding must match Android exactly.
  • Use P256.KeyAgreement + HKDF + AES.GCM for ECIES equivalent. Tink's ECIES_P256_HKDF_HMAC_SHA256_AES128_GCM is not bit-compatible with raw CryptoKit, so this is a non-trivial port. Consider keeping Tink via BoringSSL-TLC or porting the exact KDF/AEAD composition.
  • Use SecItemAdd / SecItemCopyMatching for keychain storage. Replace EncryptedSharedPreferences with Keychain in a way that survives app reinstalls on the same device.
  • For Argon2id, the open-source SwiftArgon2 package is the only reasonable option. Verify byte output against the Android BouncyCastle reference before shipping.

Build and release

Android

  • Module: app/
  • Package: app.closer (Java/Kotlin namespace)
  • Application ID: closer.app (the on-device package identifier used by Google Play)
  • compileSdk: 35, minSdk: 26, targetSdk: 35
  • Java/Kotlin: 17
  • Versioning: versionCode is integer; versionName is a string. Current state: versionCode = 1, versionName = "0.1.0". HISTORY.md describes versions up to 0.2.x. Bump versionName in app/build.gradle.kts when cutting a release.

Biometric recovery phrase reveal

app/src/main/java/app/closer/ui/settings/SecurityScreen.kt gates the recovery phrase behind BiometricPrompt with BIOMETRIC_STRONG or DEVICE_CREDENTIAL. The setting biometricLoginEnabled is persisted via DataStore. On a device without biometric hardware the prompt falls back to device credential (PIN/pattern/password). The phrase is held in SecurityViewModel's _recoveryPhrase MutableStateFlow and cleared on dialog dismiss — never written to logs or analytics.

Required build secrets

  • RC_API_KEY — RevenueCat public SDK key, sourced from local.properties or env. Release builds fail without it.
  • google-services.json — Firebase Android config. The repo template does not include a real one; copy from your Firebase project.
  • GoogleService-Info.plist — Firebase iOS config. The repo gitignores this file; copy from your Firebase project into iphone/Closer/GoogleService-Info.plist.
  • local.properties — local-only, never committed.

Gradle config

app/build.gradle.kts declares:

  • Compose BOM 2025.01.01
  • Hilt 2.53.1
  • Room 2.6.1
  • DataStore 1.1.2
  • Firebase BoM 33.8.0 (auth, firestore, messaging, config, analytics, crashlytics, appcheck, appcheck-playintegrity, storage, functions)
  • RevenueCat 8.20.0
  • Tink 1.13.0, BouncyCastle 1.78.1
  • Play Integrity 1.4.0
  • Biometric 1.1.0
  • Credential Manager 1.3.0

ProGuard

app/proguard-rules.pro keeps Tink reflection paths and other crypto classes. Release builds run minifyEnabled = true and shrinkResources = true. Always smoke-test a release build before publishing — ProGuard rules for new libraries are easy to forget.

Common commands

./gradlew :app:assembleDebug
./gradlew :app:installDebug
./gradlew :app:compileDebugKotlin      # fast typecheck
./gradlew :app:assembleRelease         # fails without RC_API_KEY

Firebase Functions

cd functions
npm install
npm run build        # TypeScript → dist/
npm run serve        # local emulator
firebase deploy --only functions

dist/ is committed so the deployed function code is reproducible without running npm run build at deploy time.

Optional Express server

server/ is an Express webhook/health service that is not client-facing. It exists for environments where Cloud Functions are not the right deployment surface (e.g. custom VPC). Most teams will not need it.


Engineering conventions

Git

  • dev is the working branch. main is the stable/release branch. All feature work happens on dev.
  • One commit per batch. No bundling multiple batches into one commit. Each commit message has a clear scope (feat(scope): description (batch X.Y.Z)).
  • Push to dev after every commit. Don't accumulate locally.
  • Forgejo remote: ssh://forgejo/null/Closer.git (the repo was renamed from relationship-app to Closer in 2026).

Files that must never be committed

Add to every clone's .gitignore:

FUTURE.md
HISTORY.md
PROJECT.md
STRUCTURE.md
project-requirements.md
DEVELOPMENT_LOG.md
BUILD_SUMMARY.md
SCRIPTS.md
.learnings/
.kotlin/

These are agent-only or workspace-only docs and have no place in the public repo.

Versioning

  • Major: 0 → 1 is the MVP-to-public cut. Today we are 0.x.
  • Minor: a complete feature batch (e.g. E2EE parity, payment integration, full iOS port).
  • Patch: bug fixes, polish, internal refactors.
  • Source of truth: app/build.gradle.kts for Android versionName. HISTORY.md is the changelog. Keep them in sync.

Naming

  • Android: Kotlin package app.closer.* (do not resurrect the old com.couplesconnect package).
  • iOS: Swift module Closer. Folder names match Android's screen names where they exist.
  • Cloud Functions: one module per domain (billing/, couples/, ...). Function names match the file name (acceptInviteCallable.tsacceptInviteCallable).

Logging

  • Cloud Functions prefix every log line with the function name: [acceptInviteCallable] ....
  • Android production builds must not log secrets, recovery phrases, keyset bytes, or invite codes. Wrap android.util.Log calls in BuildConfig.DEBUG guards (see app/src/main/java/app/closer/data/local/QuestionJsonParser.kt for an example).
  • Crashlytics is the production observability path. Do not log to both Crashlytics and console in production.

Error handling

  • Cloud Functions: throw HttpsError(code, message) with the closest matching code. Never throw a plain Error.
  • Android: repositories return Result<T> for fallible operations. ViewModels expose StateFlow<UiState> with a sealed Error variant.
  • iOS: throws for fallible paths; AsyncStream for reactive state.
  • Offline behavior: question packs are bundled so the app is fully usable offline. Daily question assignment, partner reveal, and notifications all require network.

Testing

  • Android unit tests live in app/src/test/. JVM only; no device/emulator.
  • Cloud Functions: entitlementLogic.test.ts uses Vitest. Run with npm test in functions/.
  • Manual QA: see docs/qa/private-mvp-checklist.md and docs/qa/ui-review.md.

Privacy and data retention

  • Couples are deleted on user account deletion (cascade in onUserDelete).
  • Invite attempts auto-expire via Firestore TTL (25h).
  • Invites auto-expire after 24h.
  • Notification queue entries are written by Cloud Functions and consumed by FCM; they are not auto-deleted today. Add a TTL if retention becomes a concern.

Where to look first

If you are new to the codebase, read these files in order:

  1. README.md — product positioning and feature scope.
  2. PROJECT.md — formal product spec.
  3. app/src/main/java/app/closer/crypto/EncryptionVersion.kt — the encryption version contract.
  4. firestore.rules — every client write goes through these.
  5. functions/src/index.ts — every Cloud Function the project exposes.
  6. functions/src/couples/acceptInviteCallable.ts — the most representative callable. Pair creation, rate limiting, E2EE fields, recovery phrase wipe, encryptionVersion derivation all in one file.
  7. functions/src/questions/assignDailyQuestion.ts — the daily question scheduled function with the DST-quirky date math.
  8. app/src/main/java/app/closer/crypto/SealedAnswerEncryptor.kt and SealedRevealManager.kt — sealed-answer wire format and reveal flow.
  9. app/src/main/java/app/closer/ui/answers/AnswerRevealViewModel.kt — the client-side reveal state machine.
  10. app/src/main/java/app/closer/core/billing/FirestoreEntitlementChecker.kt — server-verified entitlement flow.
  11. iphone/Closer/Services/FirestoreService.swift — the iOS side of cross-platform data contracts.
  12. iphone/ARCHITECTURE_AUDIT.md — generated iOS port blueprint.

If you are working on a specific area, the relevant section in this manual points to the key files for that area.