# 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 - Start with [System overview](#system-overview) and [Repository layout](#repository-layout). - Before touching Firestore paths, read [Firestore data model](#firestore-data-model) and [Firestore security rules](#firestore-security-rules). - Before touching crypto, read [End-to-end encryption model](#end-to-end-encryption-model) and the real files referenced there. - Before adding a Cloud Function, read [Cloud Functions](#cloud-functions) and match the existing module pattern. - Before changing the daily-question flow, read [Daily question lifecycle](#daily-question-lifecycle) and `app/src/main/java/app/closer/ui/answers/AnswerRevealViewModel.kt`. - Before changing notifications, read [Notifications](#notifications) — specifically [Game session push semantics](#game-session-push-semantics-idempotent-flag-claim), [Foreground game-alert banner](#foreground-game-alert-banner-r10), and [Notification deep-link routing](#notification-deep-link-routing). - Before gating a feature on premium, read [Premium-gated features and gate pattern](#premium-gated-features-and-gate-pattern). - Before debugging or changing any area, scan [Known landmines and recent fixes](#known-landmines-and-recent-fixes) — it lists bugs that already cost real debugging time and are easy to re-introduce. - [Where to look first](#where-to-look-first) points new engineers at the most important files. --- ## Table of Contents 1. [System overview](#system-overview) 2. [Repository layout](#repository-layout) 3. [Authentication and pairing flow](#authentication-and-pairing-flow) 4. [End-to-end encryption model](#end-to-end-encryption-model) 5. [Daily question lifecycle](#daily-question-lifecycle) 6. [Firestore data model](#firestore-data-model) 7. [Firestore security rules](#firestore-security-rules) 8. [Cloud Functions](#cloud-functions) 9. [Billing](#billing) 10. [Notifications](#notifications) - [Game session push semantics](#game-session-push-semantics-idempotent-flag-claim) - [Foreground game-alert banner](#foreground-game-alert-banner-r10) - [Notification deep-link routing](#notification-deep-link-routing) - [Premium-gated features and gate pattern](#premium-gated-features-and-gate-pattern) (under Billing) 11. [iOS-specific notes](#ios-specific-notes) 12. [Build and release](#build-and-release) 13. [Engineering conventions](#engineering-conventions) 14. [Known landmines and recent fixes](#known-landmines-and-recent-fixes) 15. [Where to look first](#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. ### Couple-shared premium A subscription is **per couple**, not per user. The Firestore rules for `users/{uid}/entitlements/premium` allow the **partner** to read the user's premium state (in addition to the owner), so premium-gated features unlock if either partner is subscribed. The check that the actual feature unlocks lives in `FirestoreEntitlementChecker` and `CouplePremiumChecker`. iOS does not currently observe Firestore entitlements — its premium state is RevenueCat-only (see [Server-verified entitlements](#server-verified-entitlements)). ### Key architectural decisions - **Clean architecture on Android** — `core/`, `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 iOS** — `AppState` 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 ```text app/src/main/java/app/closer/ ├── MainActivity.kt ├── analytics/ # RetentionEvent + RetentionAnalytics (separate from core/analytics; used for product funnel) ├── core/ │ ├── analytics/ # Firebase Analytics + Crashlytics wrappers │ ├── billing/ # EntitlementChecker + FirestoreEntitlementChecker │ ├── crash/ # CrashReporter abstraction │ ├── feature/ # (reserved for feature flags; currently empty — no feature-flag code in repo today) │ ├── navigation/ # AppRoute constants, NavHost, ExternalLinks │ └── notifications/ # AppMessagingService, NotificationHelper, NotificationPermissionHelper, QuietHours, TokenRegistrar ├── crypto/ # E2EE: Tink AEAD, BouncyCastle Argon2id, key stores ├── data/ │ ├── challenges/ # Connection Challenges data sources │ ├── local/ # Room DAOs, DataStore, EncryptedSharedPreferences (RecoveryPhraseStore, SecurePreferencesFactory, SettingsDataStore) │ ├── questions/ # Question pack data sources (QuestionDao + QuestionJsonParser) │ ├── remote/ # Firestore data sources, Cloud Functions callable wrappers │ ├── repository/ # Repository implementations │ └── security/ # PlayIntegrityChecker ├── di/ # Hilt modules ├── domain/ │ ├── model/ # Plain data classes │ ├── repository/ # Repository interfaces │ └── security/ # AuthRateLimiter, DeviceIntegrityChecker (interface for PlayIntegrityChecker) └── ui/ # Compose screens + ViewModels ├── activity/ # Together / Activity screen ├── answers/ # Answer write/reveal/history ├── auth/ # Login, signup ├── brand/ # Logo, splash, illustrated empty states ├── challenges/ # Connection Challenges ├── components/ # Shared Compose components (GamePromptBanner, EmptyState, BrandIllustration, etc.) ├── dates/ # Date builder, matches, bucket list ├── debug/ # Debug-only screens (QA tooling) ├── 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 ``` **Note on the manual's older description**: a `core/security/` package and `data/local/QuestionJsonParser.kt` were documented in earlier revisions of this manual but don't exist in the current source. The auth rate limiter is in `domain/security/AuthRateLimiter.kt`, and the question JSON parser is at `data/questions/QuestionJsonParser.kt`. 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 ```text 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 (struct CloserApp + AppDelegate adaptor), AppState, RevenueCat init ├── 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/ # ContentView.swift — Root NavigationStack + TabView ├── 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](#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 ```text 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 (PLACEHOLDER callable; not deployed) │ ├── dailyQuestionReminder.ts # sendDailyQuestionProactiveReminder (4 PM CT; deployed, idempotent) │ ├── reengagement.ts # sendReengagementReminder (12 PM CT; couples 3–10 days quiet) │ ├── sendGentleReminderCallable.ts # Manual gentle reminder │ └── gameRetention.ts # sendChallengeDayReminders + unlockDueMemoryCapsules ├── questions/ │ ├── assignDailyQuestion.ts # Pub/Sub schedule + manual callable │ ├── onAnswerWritten.ts # Trigger: notify partner on answer │ ├── onAnswerRevealed.ts # Trigger: notify partner when answers are opened │ └── 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. **Two reminders exist for the daily question** and they are NOT the same: - `sendDailyQuestionReminder` in `reminders.ts` is an `onCall` **placeholder** — it writes a `notification_queue` record but does NOT send FCM. Not deployed in the live reminder flow. - `sendDailyQuestionProactiveReminder` in `dailyQuestionReminder.ts` is the **actual deployed scheduler** — fires at `0 16 * * *` America/Chicago (4 PM, 2 hours before today's daily question expires at 6 PM). Queries `daily_question` docs whose `expiresAt` is within the next 3 hours via collection group, then for each: (a) skips if the auto-reminder was already claimed today (`daily_reminders/auto_{questionDate}` doc exists), (b) skips if a manual `gentle_reminders/{questionDate}` was sent today, (c) skips if anyone has answered. Otherwise it atomically claims the `daily_reminders` slot, sends FCM to every user in the couple, and writes a `notification_queue` entry per user. ### Shared configuration ```text 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. ```text 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](#firestore-security-rules) for the per-field immutability matrix. ### The `couples` document model ```text /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 ``` `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` | **Conceptual only — not creatable today.** The constant is referenced in iOS TODO comments and the Firestore rules helper `coupleEncryptionEnabled`, but `acceptInviteCallable` hardcodes `encryptionVersion = 2` and throws if E2EE fields are missing. No v0 couple exists in the live system. See [iOS E2EE gap](#ios-e2ee-gap-pairing-is-broken-from-ios-today). | | 1 | `MIGRATING` | Reserved for a hypothetical in-flight migration. **No couple exists at v1 in production today.** Documented in `EncryptionVersion.kt` for forward-compatibility; do not write v1 from new code without a concrete migration plan. | | 2 | `STRICT` | All answer-bearing paths require encryption. **The only version any couple has ever been created at.** Required by both `acceptInviteCallable` and the Firestore rules (which reject `request.resource.data.encryptionVersion != 2`). | **What the code actually does**: the Android `EncryptionVersion` object defines only `STRICT = 2` and `NEW_COUPLE_DEFAULT = STRICT`. `acceptInviteCallable` sets `const encryptionVersion = 2` unconditionally and `throw`s if any of `wrappedCoupleKey`/`kdfSalt`/`kdfParams` is null. v0 and v1 are conceptual states referenced by iOS TODOs and the rules helper `coupleEncryptionEnabled(>= 1)`; nothing in the production path writes them. **If you need to support a non-v2 couple** (e.g. iOS plaintext fallback, legacy migration): the change touches three places at minimum — `EncryptionVersion.kt` adds the constant, `acceptInviteCallable` derives it from caller data, and `firestore.rules` `match /couples/{coupleId}` `allow create` block removes the `encryptionVersion == 2` gate and adds the corresponding E2EE-field-optional helper. Plan all three together; do not ship one without the others. ### 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](#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 (schemaVersion 3 only) Sealed answers 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. **This is the schemaVersion 3 path** — thread messages and the legacy answer path. The current daily-answer default is schemaVersion 2 (couple-key, see [Reveal flow](#reveal-flow)), which uses a different gating model. Flow: ```text 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. ```text 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.kt` — `enc: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**: ```text 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. **Couple-key / company-proof** (`schemaVersion = 2`, default for daily answers): `enc:v1:` ciphertext fields, content is encrypted with the shared couple key. Both partners already hold the couple key after pairing, so reveal decrypts the moment both have answered. **Privacy ("not until both answer") is enforced by the Firestore read rule, not a per-answer key handshake.** This replaced an earlier fragile sealed-key exchange (commit `df32229`) that broke when one partner reinstalled. 2. **Sealed / partner-proof** (`schemaVersion = 3`): `sealed:v1:` payload + `sha256:` commitment; content key is released only after both partners have submitted, wrapped via ECIES P-256 to the recipient's per-user public key. Used for thread messages and the legacy answer path. A write must match exactly one of these shapes — the rules reject anything else. **Daily answers use schemaVersion 2 by default** (the couple-key reveal path was introduced in commit `df32229` to replace the fragile sealed-key handshake); thread messages use schemaVersion 3. ### 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 path differs by schema version: **SchemaVersion 2 (couple-key daily answers) — the current default.** The answer doc at `couples/{coupleId}/daily_question/{date}/answers/{userId}` is **metadata only** — no answer content. The encrypted payload lives in a **read-gated subdoc** at `answers/{userId}/secure/payload` with a single `encryptedPayload` field (`enc:v1:`). The Firestore rules for that subdoc grant read access to the owner unconditionally, AND to the partner **only if** the partner has also submitted their own answer to the same date (checked by `exists(.../answers/{request.auth.uid})` in `firestore.rules`). This is the cryptographic "private until both answer" gate — there is no per-answer key handshake, just a server-side read predicate. 1. Each app writes its own `answers/{userId}` metadata doc + its own `answers/{userId}/secure/payload` subdoc with `enc:v1:` content. 2. Once the partner's metadata doc exists, the rules unlock read access to your `secure/payload` for the partner (and vice versa). Both apps decrypt with the shared couple key. 3. The metadata `isRevealed` flag flips after both have read; that's what triggers the `partner_opened_answer` push (see `functions/src/questions/onAnswerRevealed.ts`). **SchemaVersion 3 (sealed / partner-proof — used for thread messages).** The original sealed-answer flow: 1. User composes message. 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 messages exist, each app reads partner's public key from `users/{partnerId}/devices/primary`, wraps its own one-time key with ECIES P-256, writes keybox to `releaseKeys/{partnerId}`. 8. Each app reads the keybox written for them, unwraps with private key, decrypts partner's sealed payload. 9. Each app verifies the decrypted payload's SHA-256 commitment matches `commitmentHash`. **Adding a new answer-bearing path**: decide upfront whether schemaVersion 2 (couple-key, simple, current default) or schemaVersion 3 (sealed, partner-proof, more code) is appropriate. New schemas require new `isXxxAnswerCreate` helpers in `firestore.rules` AND new write paths in the data source. **Read-path gotcha**: `FirestoreAnswerDataSource.toLocalAnswer()` (the `DocumentSnapshot.toLocalAnswer()` private helper) hardcodes `schemaVersion = 3` and reads `encryptedPayload` from the doc itself. For the v2 path this is wrong in isolation — v2 daily answers don't have `encryptedPayload` on the doc; that field lives in the `secure/payload` subdoc. In practice the v2 reveal still works because `computeSealedPhase()` in `AnswerRevealViewModel` reads the *user's own* answer's `schemaVersion` from the local SharedPreferences repository (which preserves the actual value), not the partner's. The partner's hardcoded `schemaVersion = 3` is dead data on this read path. The actual partner content is fetched via `decryptCoupleKeyAnswerFor()`, which reads the `secure/payload` subdoc directly. If you refactor the read path, preserve this invariant: the user's own answer schemaVersion must drive the reveal branch. ### 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 ```text /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; readable by owner + current partner (see Couple-shared premium) premium: bool expiresAt: Timestamp | null updatedAt: Timestamp # last entitlement change productId: string | null # RevenueCat product identifier (e.g. 'closer_monthly') eventType: string # RevenueCat event type ('INITIAL_PURCHASE', 'RENEWAL', 'CANCELLATION', etc.) /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} # in-app activity feed (Together screen) type: string # e.g. 'invite_created', 'partner_joined', or whatever the caller writes read: bool # only field the rules allow the client to update createdAt: Timestamp # Plus arbitrary caller-supplied fields (the writer spreads them onto the doc). # See functions/src/games/onGameSessionUpdate.ts and functions/src/couples/createInviteCallable.ts for examples. # NOTE: despite the comment in firestore.rules that says "the app reacts to FCM push, not this collection", # the Android `FirestoreActivityDataSource` DOES read this collection for the in-app "Together" # activity feed. Client reads + read-flag updates are permitted by the rules; server-side writes # are the only way to create records here. The rules comment is stale. /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 /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} /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} # SERVER-ONLY (no client rule grants access). Admin SDK reads via pickRandomQuestionId(); Android loads bundled seed JSON via Room (`seed/questions/*.json`). text, categoryId, active, isPremium # Client (Android) ships `seed/questions/*.json` files as Room assets via `seed/build_db.py`. # The Firestore copy exists as the source of truth for the daily-question picker; if it's empty # the picker falls back to id 'q_default_daily' (functions/src/questions/assignDailyQuestion.ts). # Do not rely on client-side reads of this collection; treat it as write-once admin data. /couples/{coupleId}/conversations/{conversationId} # E2E-encrypted chat type: 'couple' | 'thread' # 'thread' = per-question discussion questionId: string | null # only on threads createdAt: Timestamp lastMessageAt: Timestamp | null lastMessagePreview: enc:v1: | null # E2E-encrypted preview of the latest message lastMessageSenderId: uid | null reads: map # per-user last-read timestamp typing: map # per-user last-typing timestamp (TTL'd client-side) /messages/{messageId} authorUserId: uid text: enc:v1: # for type='text'; isCiphertext() enforced by rules type: 'text' | 'image' | 'voice' mediaUrl: string | null # for image/voice; points at encrypted Storage bytes durationMs: number | null # for voice messages reactions: map # reactions map (any member may flip their own) createdAt: Timestamp deleted: bool # tombstone; only author may set this (unsend) /couples/{coupleId}/date_swipes/{dateIdeaId} # E2E-encrypted per-date swipe state actions: map # One doc per date idea; both partners' entries live under .actions keyed by uid. # Server cannot read action (encrypted); only swipedAt is plaintext (used for ordering/audit). # Cross-reference: createDateMatch.ts fires when both partners' actions are 'love'. /couples/{coupleId}/date_matches/{matchId} userIds, dateIdeaId, createdAt /couples/{coupleId}/date_plans/{planId} title, dateTime, status: 'draft' | 'planned' | 'completed' # server-created by createDateMatch /couples/{coupleId}/date_plan_preferences/{prefId} categories: map # plaintext per-user weights /couples/{coupleId}/bucket_list/{itemId} title, category, addedByUserId, completedAt # NOTE: this list is not exhaustive. Game-session subcollections (this_or_that, desire_sync, # how_well, wheel) and challenge-day / memory-lane subcollections are documented in their own # sections. If you add a new subcollection, update both firestore.rules AND this data model. /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}.coupleId` → `couples/{coupleId}`. - `couples/{coupleId}.userIds` → `users/{uid}` (the two members). - `couples/{coupleId}/daily_question/{date}/answers/{userId}.userId` → `users/{uid}`. - `couples/{coupleId}/date_swipes/{dateIdeaId}.actions.{uid}` → `users/{uid}` (each swipe entry keyed by uid). - `entitlement_events/{eventId}.userId` → `users/{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. ```text 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}$') isNotAlreadyPaired() caller has no existing coupleId isImmutable(fields) diff(...).affectedKeys().hasOnly(fields) isValidSwipeAction(action) 'love' | 'maybe' | 'skip' isValidDatePlanStatus(status) 'draft' | 'planned' | 'completed' isValidBucketListCategory(category) whitelist of category strings isCiphertext(value) matches('^enc:v1:[A-Za-z0-9+/]+={0,2}$') cipherOrAbsent(data, key) data[key] is ciphertext or absent isDatePlanContentEncrypted(data) date-plan content fields are enc:v1: or absent coupleEncryptionEnabled(coupleId) couple.encryptionVersion >= 1 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: (schemaVersion 3, legacy) isSealedAnswerUpdate() only reveal-metadata fields (schemaVersion 3) isCoupleKeyAnswerCreate(data) metadata-only shape, schemaVersion 2 isCoupleKeyAnswerUpdate() only isRevealed/updatedAt flip (schemaVersion 2) isSealedThreadAnswerCreate(data) sealed shape, no answerDate/isRevealed (threads, schemaVersion 3) isSealedThreadAnswerUpdate() only answerKeyReleased/updatedAt flip (threads) 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; `entitlements/` is also readable by the user's current couple partner (couple-shared premium; see [Couple-shared premium](#couple-shared-premium)), and `notification_queue/` is readable by the owner for the in-app activity feed (the owner can flip the `read` flag). `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 metadata doc is content-free** (schemaVersion 2): it holds only `userId`, `questionId`, `answerType`, `schemaVersion`, `answerDate`, `createdAt`, `updatedAt`, `isRevealed` so the partner can see THAT you answered ("your turn" indicator) without leaking the content. The actual `encryptedPayload` lives in a **read-gated subdoc** at `answers/{userId}/secure/payload` and is only readable by the partner once the partner has also submitted (`exists(.../answers/{request.auth.uid})`). The owner always reads their own subdoc. The subdoc shape is `keys().hasOnly(['encryptedPayload'])` and `isCiphertext(encryptedPayload)`. See [Reveal flow](#reveal-flow) for the full path. Legacy schemaVersion 3 answers (sealed:v1:) follow the `isSealedAnswerCreate`/`isSealedAnswerUpdate` helpers and use the `releaseKeys/` subdoc for the ECIES-wrapped keybox. **`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 ```text assignDailyQuestion 0 23 * * * America/Chicago (11 PM UTC; 6 PM Chicago during CDT, 5 PM during CST — see Date math DST bug) scheduledOutcomesReminder every 24 hours America/Chicago sendDailyQuestionProactiveReminder 0 16 * * * America/Chicago (4 PM; 2h before expiry) sendReengagementReminder 0 12 * * * America/Chicago (noon; targets couples 3–10 days quiet) unlockDueMemoryCapsules every 1 hours (gameRetention.ts) sendChallengeDayReminders every 24 hours (gameRetention.ts) ``` `scheduledOutcomesReminder` currently scans all couples with no pagination. It will need to shard or paginate as the user base grows. `sendDailyQuestionProactiveReminder` is the only deployed reminder for unanswered daily questions — the placeholder `sendDailyQuestionReminder` callable in `reminders.ts` is not in the live reminder path. --- ## 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: ```kotlin interface EntitlementChecker { fun isPremium(): Flow 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. ### Premium-gated features and gate pattern The `EntitlementChecker.isPremium()` Flow is collected by feature-level ViewModels; features that gate render the paywall deep-link on a `false` value rather than throwing or showing empty state. **Gated features (current list):** - Premium question packs (`QuestionPackRepositoryImpl.isPremiumGate()`) - Full answer history (free shows last 7; premium shows all) - Custom questions - Private notes - Extra categories (beyond the free tier) - Full spin-wheel session history - Future AI-assisted question suggestions **How to gate a new feature:** 1. Inject `EntitlementChecker` into the relevant ViewModel or Repository. 2. On entry, collect `isPremium()`. If `false`, navigate to the paywall route (`paywallScreen()` in `AppNavigation`) with a returnTo argument. Do not silently fail or render empty state. 3. Server-side: any new premium-only Cloud Function must verify the entitlement via `users/{uid}/entitlements/premium` before doing work. Do not trust the client flag for server-side gating. **QA testing convention**: to test premium features without a real subscription, set the entitlement doc directly: ```text users/{testUserUid}/entitlements/premium premium: true expiresAt: ``` The `EntitlementChecker` Flow picks this up reactively. The QA report convention is `Sam premium = ON` (or `= OFF`) to track the test setup state per run. --- ## 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. ### Game session push semantics (idempotent flag-claim) The Cloud Function `functions/src/games/onGameSessionUpdate.ts` fires on every game session doc update. The original implementation diffed `status` fields to decide whether to send a push, which produced duplicate notifications when both partners updated the doc close together. The current implementation uses an **idempotent flag-claim** pattern: a single `notificationsSent` map on the session doc records each notification type the moment it's dispatched. The trigger checks the flag, sends if absent, writes the flag. Re-running the trigger is a no-op. This pattern is the rule for any partner-facing push derived from a game session — **never diff, always claim**. The flag keys are stable strings like `'start'`, `'finish'`, `'partner_answered'` (game-specific). ### Foreground game-alert banner (R10+) System-tray notifications are easy to miss when the app is open. R10 added an in-app banner surface (`GamePromptController`, `GamePromptBanner`) that mirrors the chat in-app banner pattern: when the app is foreground and a game-start push arrives, a prominent top banner slides in ("\ started \" + Join). The banner is **suppressed** when the user is already on that game's screen — `ActiveGameSessionMonitor.enter/leave` tracks the active route, and `GamePromptController` consults it before showing. Home's "Waiting for you" card was also redesigned as a bold purple-gradient hero that joins the specific game (not the Play-hub fallback). Verified live across all four session games (Spin the Wheel, This or That, How Well Do You Know Me, Desire Sync). Key Android files: `app/src/main/java/app/closer/notifications/GamePromptController.kt`, `app/src/main/java/app/closer/ui/components/GamePromptBanner.kt`, `app/src/main/java/app/closer/notifications/ActiveGameSessionMonitor.kt`, `app/src/main/java/app/closer/notifications/PartnerNotificationManager.kt`, `app/src/main/java/app/closer/core/notifications/AppMessagingService.kt`. Deep-link routes live in `app/src/main/java/app/closer/core/navigation/AppNavigation.kt`. ### Notification deep-link routing `PartnerNotificationManager` (Android) parses the FCM data payload and routes to the right screen via `PartnerNotificationType.routeFor(payload, coupleId)`. The full routing table (verified against `PartnerNotificationManager.kt`): | Payload key | Notification type | Routes to | | --- | --- | --- | | `partner_answered` | `PARTNER_ANSWERED` | `DAILY_QUESTION` (the partner's `answers/{userId}` metadata doc exists; reveal reads it on next visit) | | `partner_opened_answer` | `PARTNER_OPENED_ANSWER` | `answerReveal(questionId)` if `questionId` in payload, else `DAILY_QUESTION` | | `reveal_ready` | `REVEAL_READY` | `answerReveal(questionId)` if `questionId` in payload, else `ANSWER_HISTORY` | | `partner_started_game` | `PARTNER_STARTED_GAME` | `gameRouteForType(payload.gameType)` — for `WHEEL` → `SPIN_WHEEL_RANDOM`, `THIS_OR_THAT` → `THIS_OR_THAT`, `HOW_WELL` → `HOW_WELL`, `DESIRE_SYNC` → `DESIRE_SYNC`. The game screen auto-joins the couple's active session on open. Falls back to `PLAY` if `gameType` is missing. E-003. | | `partner_completed_part` | `PARTNER_COMPLETED_PART` | `gameRouteForType(payload.gameType)` — same routing as started_game | | `partner_finished_game`, `game_results_ready` | `GAME_RESULTS_READY` | `gameResultsRouteFor(gameType, sessionId)` — for `WHEEL` → `wheelComplete(sessionId)`, `THIS_OR_THAT` → `thisOrThatReplay(sessionId)`, `HOW_WELL` → `howWellReplay(sessionId)`, `DESIRE_SYNC` → `desireSyncReplay(sessionId)`. Falls back to `PLAY` if either arg is missing. E-003. | | `challenge_day_ready`, `challenge_waiting` | `CHALLENGE_WAITING` | `CONNECTION_CHALLENGES` | | `memory_capsule_unlocked` | `CAPSULE_UNLOCKED` | `MEMORY_LANE` | | `daily_question`, `daily_question_reminder` | `DAILY_QUESTION_REMINDER` | `DAILY_QUESTION` | | `chat_message` | `CHAT_MESSAGE` | `conversation(coupleId, conversationId)` if both ids present, else `MESSAGES` | | `outcome_reminder` | `OUTCOME_REMINDER` | `SETTINGS` (not Outcomes — Outcomes screen itself has no dedicated route yet) | | `gentle_reminder` | `GENTLE_REMINDER` | `DAILY_QUESTION` | | `partner_joined` | `PARTNER_JOINED` | `pairingSuccess(coupleId)` if `coupleId` known, else `HOME` | | `date_match` | `DATE_MATCH` | `DATE_MATCHES` | | `reengagement` | `REENGAGEMENT` | `DAILY_QUESTION` | | `partner_left`, `partner_deleted_account` | `PARTNER_UNPAIRED` | `HOME` (Home surfaces the "Invite partner" CTA for the now-solo user; E-002) | When the app is foreground, the banner takes precedence over deep-link (the user already saw it). When background, `MainActivity` `singleTop` launch mode + a server-first read in `PartnerNotificationManager` ensure the deep-link lands in the active game, not a stale state. **E-GAME-001** was a previous bug here (deep-link re-entered a finished session) — fixed by server-first read. **Adding a new notification type**: add the enum value in `PartnerNotificationType` with title/body/channelId/rateType, add a branch in `routeFor(...)` mapping to the appropriate `AppRoute`, and add a `when` branch in `fromRemoteType(...)` mapping the server's payload key. Server-side: emit the matching payload key from the Cloud Function. **All four edits must ship together**; mismatches silently drop the notification or route it to a wrong screen. --- ## 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 ```bash 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 (pairing is broken from iOS today) The iOS port does not implement E2EE. **More importantly, this means iOS cannot complete pairing today:** - `createInviteCallable` (iOS caller passes `[:]` as data) writes an invite with **no** `wrappedCoupleKey` / `kdfSalt` / `kdfParams` / `recoveryPhrase` fields. - `acceptInviteCallable` then **throws** `failed-precondition: "Invite is missing encryption material"` when an iOS user tries to accept any invite (Android or iOS) because those fields are required (`if (wrappedCoupleKey == null || kdfSalt == null || kdfParams == null) throw ...`). - The Firestore rules also reject any client-side attempt to write a couple with `encryptionVersion != 2` or without all four E2EE fields. So there is no fallback client-write path either. Concrete consequences if someone tries to pair from iOS: - An iOS user trying to create an invite gets a code that nobody can accept (server rejects on accept). - An iOS user trying to accept an Android invite gets `failed-precondition` from `acceptInviteCallable`. - There is no v0 / PLAINTEXT couple creation path in the codebase. The `EncryptionVersion.kt` constants file defines only `STRICT = 2`. `acceptInviteCallable` hardcodes `encryptionVersion = 2`. Rules require `encryptionVersion == 2`. **State of iOS today**: the iOS app builds, signs in, and renders UI, but pairing is non-functional. Do not ship iOS to users until one of these is true: 1. **E2EE parity ships on iOS** — CryptoKit keyset + Argon2id phrase cipher that produce byte-compatible ciphertexts with the Android Tink paths. See [iOS CryptoKit guidance](#ios-cryptokit-guidance-future). Then `createInviteCallable` (iOS) starts sending the four E2EE fields and pairing works. 2. **Server-side fallback is implemented** — `acceptInviteCallable` is changed to accept invites with null E2EE fields when the caller is iOS (platform detected from the user's `platform` field). This would create v0 couples. Rules must be updated to allow v0 creation; the encryption-versions table then becomes the live state space. 3. **Mixed-couple policy is communicated to users** — explicitly tell iOS users their answers are plaintext and the couple's Android answers are encrypted but not cross-decryptable. This requires shipping #2 and a UI flow that surfaces the gap. **As of the current manual revision (2026-06), none of (1)/(2)/(3) has shipped.** Pairing from iOS fails. ### 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"` in `app/build.gradle.kts` — but HISTORY.md describes versions up through `v0.2.1`. **The build config has drifted from HISTORY; do not ship a release until they're reconciled.** 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 ```bash ./gradlew :app:assembleDebug ./gradlew :app:installDebug ./gradlew :app:compileDebugKotlin # fast typecheck ./gradlew :app:assembleRelease # fails without RC_API_KEY ``` ### Firebase Functions ```bash 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 The authoritative list lives in `.gitignore` at the repo root. The current entries the agent workflow relies on are: ```text # Private project docs (agent-only, never commit) — see .gitignore FUTURE.md # uppercase; legacy name in gitignore HISTORY.md PROJECT.md STRUCTURE.md project-requirements.md DEVELOPMENT_LOG.md BUILD_SUMMARY.md SCRIPTS.md .learnings/ .kotlin/ ``` > **Note (2026-06): the gitignore list uses `FUTURE.md` (uppercase) but the tracked file is `Future.md` (mixed case).** Linux filesystems are case-sensitive so these are different paths; the gitignore does not actually block `Future.md`. The ClaudeQA docs (`ClaudeQAPlan.md`, `ClaudeReport.md`, `ClaudeQACoverage.md`, `ClaudeBrandingReview.md`, `ClaudeiOSPlan.md`) and `Future.md` are explicitly tracked. If you create new top-level docs and want them gitignored, either match the existing case in `.gitignore` (`Future.md`) or pick a case and use it consistently. Do not block `Future.md` and then create `future.md` thinking you're safe. ### 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.ts` → `acceptInviteCallable`). ### 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/questions/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` for fallible operations. ViewModels expose `StateFlow` 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. --- ## Known landmines and recent fixes These are bugs that cost real debugging time and are easy to re-introduce if you don't know they existed. Before changing the relevant area, re-read the linked fix commit and the QA report entry. Format: **ID** — what it was — where it lives now. ### N-001 / N-002 — VMs that wait for the screen to push an id silently no-op if nothing pushes it **Symptom (R15)**: the **Bucket List was entirely non-functional** — add/load/complete/delete all did nothing, no error, no logcat. **Root cause**: `BucketListViewModel` gated every operation on `if (coupleId.isEmpty()) return`, expecting the screen to call `setCoupleId(...)` — but `BucketListScreen` never did (the nav route passes no coupleId and there's no `LaunchedEffect`). So `coupleId` stayed `""` and every op returned early **silently**. Same class hit **Date Builder (N-002)**: `savePreference()` bailed on `dateIdeaId.isEmpty()` while **nothing ever calls `setDateIdeaId`**, the preference had an empty `coupleId`, and it wrote to `date_plan_preferences` — a collection **no screen reads**. So "Create Plan" silently saved nothing. **Fix (R15, N-001)**: `BucketListViewModel` resolves the couple **itself** in `init` via `CoupleRepository.getCoupleForUser(uid)` → `setCoupleId` → `loadItems` (mirrors `MemoryLaneViewModel`/`YourProgressViewModel`, the correct pattern). Bucket items encrypt at rest (`enc:v1:`) once a real coupleId flows. **Fix (R15, N-002)**: `DateBuilderViewModel.savePreference()` now resolves the couple and creates a real **PLANNED `DatePlan`** via `repository.savePlan()` (writing to `date_plans` — the collection Home surfaces via `getPlansByStatus(PLANNED)` within 7 days as "Date coming up"), instead of an unread `DatePlanPreference`; the dead `dateIdeaId` guard is gone. Verified live (plan persists `status=planned`, `enc:v1:` fields; Home shows the upcoming date). _(The model's older "generate a plan from BOTH partners' submitted preferences" vision is still unbuilt — `date_plan_preferences` has no reader; revisit if that two-sided flow is wanted.)_ **Re-introduction risk**: the safe pattern is a VM that **resolves its own required context** (couple/uid) in `init` via the injected repository — NOT one that depends on the screen remembering to call a `setX(...)`. Audit: `grep -rn "setCoupleId\|setDateIdeaId\|fun set[A-Z]" ui/**/ViewModel` and confirm a caller exists, or move the resolution into the VM. **A silent `if (x.isEmpty()) return` guard makes a dead feature look like an empty one** — QA must persist real data and confirm it via an admin Firestore read, never trust the empty-state render. (N-002 is a deeper *incomplete feature*: even fixing the save writes into a collection no screen displays — needs a product decision on what "Plan a Date" does + where the plan is shown.) ### M-001 — quiet hours must be enforced SERVER-SIDE (a `notification` block bypasses client code when backgrounded) **Symptom (R15)**: "Quiet hours — 10 PM–8 AM, no notifications" did nothing for the case it exists for. With quiet hours ON and the recipient backgrounded/killed, partner chats/answers still posted to the shade. **Root cause**: quiet hours was **local-only** (`SettingsDataStore`, never written to Firestore) and the only check, `PartnerNotificationManager.isInQuietHours`, runs inside `AppMessagingService.onMessageReceived` — which FCM invokes **only in the foreground**. Partner pushes carry a `notification` block, so when the app is backgrounded/killed the **OS renders it directly** and no app code runs → the window is never consulted. The Settings copy was therefore a false promise for the primary scenario. **Fix (R15)**: enforce server-side. (1) Client mirrors the window to the recipient's user doc — `FirestoreUserDataSource.updateQuietHours()` writes `quietHoursEnabled` + `quietHoursStartMinutes` + `quietHoursEndMinutes` + `timezone` (`TimeZone.getDefault().id`); `NotificationSettingsViewModel` syncs on toggle **and** on init (backfill). (2) `functions/src/notifications/quietHours.ts:recipientInQuietHours(userData)` computes the recipient's local now via `Intl.DateTimeFormat({timeZone})`, handles the midnight-crossing window, and is **FAIL-OPEN** (any missing/malformed field → returns false → deliver). (3) The four partner-action senders skip when it returns true: `onMessageWritten`, `onAnswerWritten`, `onAnswerRevealed`, `onGameSessionUpdate`. (4) `firestore.rules` user-doc update allowlist extended for the four new fields. **Re-introduction risk**: (a) Client-side suppression of a server `notification`-block push only ever works foreground — any "don't notify when X" rule (quiet hours, snooze, DND) must be enforced where the push is **sent** (Cloud Functions), or sent as data-only (unreliable when killed). (b) **The `users/{uid}` update rule is a field allowlist** (`firestore.rules` ~L198, `hasOnly([...])`) — a new client-written user-doc field is silently `PERMISSION_DENIED` until added there *and* to `FirestoreUserDataSource`. (c) Keep the helper fail-open so a bug can only under-suppress (deliver), never wrongly drop a notification. (d) Scheduled/promotional senders (`reengagement`) already had their own quiet-hours check — the gap was the real-time partner-action path. ### C-DARK-UI-001 — game surfaces must use theme tokens, not fixed palette darks **Symptom**: This-or-That active gameplay was off-brand and weakly legible in dark mode — option body text and mood/duration chips used fixed `CloserPalette.PurpleDeep`/`PinkAccentDeep` (dark values) on a dark surface, and `ChoicePromptBackdrop` drew a diagonal line + two circles that read like a technical diagram crossing the prompt. **Fix (R13)**: `ThisOrThatScreen.kt` — A/B options map to `MaterialTheme.colorScheme.primary`/`secondary` (light lavender/pink in dark, rich purple/magenta in light) with high-contrast `onSurface` body text + visible accent `BorderStroke` + a filled `onPrimary`/`onSecondary` selected state; `VersusBadge`, progress, the `N/total`+title pills, the mood number-circle, and `TotLengthChips` all read from `colorScheme`; `ChoicePromptBackdrop` redrawn as a soft glow + two faint paired-card silhouettes (low alpha, never crossing the prompt). Light+dark previews added. **Re-introduction risk**: any in-game surface that hardcodes `CloserPalette.*` dark values instead of `MaterialTheme.colorScheme` will go dim/muddy in one theme. Use the theme tokens (or the `closerSoft*`/`isCloserDarkTheme()` helpers); verify BOTH themes. ### C-ART-EDGE-002 — opaque hero illustrations need feathering; transparent ones don't **Symptom**: hero illustrations rendered via direct `painterResource(R.drawable.illustration_*)` showed a hard bright rounded-rect block on dark (e.g. Today "Weekend Side Quest"). The R11 feather (C-ART-EDGE-001) only covered `BrandIllustration`/`EmptyState`. **Fix (R13)**: route **opaque** (RGB / no-alpha) hero tiles through `BrandIllustration(tile = true)` (gains `featherEdges()` + the `-night` theme variant). Confirmed opacity with `identify -format '%[opaque]'`: `illustration_daily_question`, `couple_paywall`, `couple_subscription`, `couple_onboarding`, `partner_activation`, `tonight_partner_prompt`, `couple_invite`, `together_empty` are opaque → feathered. `spin_wheel`, `streak_milestone`, `reveal_celebration` are transparent/celebration → left as direct `Image`/`painterResource` (they float; feathering them is wrong). **Re-introduction risk**: adding a new opaque rounded-rect illustration via raw `Image(painterResource(...))` will show a hard edge on dark. Check the PNG's alpha; if opaque, use `BrandIllustration(tile=true)`. ### Premium-unlock modal — one-time-gate pattern (driven off CouplePremiumChecker, not the push) **What**: `ui/components/PremiumUnlockOverlay.kt` shows the one-time "Premium unlocked" celebration to BOTH partners when couple-shared Premium activates. It is driven by `CouplePremiumChecker.isPremium()` (which OR-combines both partners) — NOT by the `subscription_entitlement_changed` push — so it fires for the purchaser and the partner wherever they are. Hosted at the `AppNavigation` root next to `MessageBubbleOverlay`. **Gate**: persisted `premiumUnlockCelebrated` flag on `SettingsRepository`/`SettingsDataStore`; set on dismiss, auto-reset when Premium lapses (so a re-activation celebrates again). Mirrors `lastCelebratedStreakMilestone`. **Re-introduction risk**: gating the modal on the push route alone would miss the purchaser (and the partner if FCM is flaky on emulators). Keep it observing `CouplePremiumChecker`. Don't forget to reset the flag on lapse, or a second subscription period never re-celebrates. ### F-RACE-001 — duplicate game-start push on rapid partner update **Symptom**: both partners got a "game started" push when only one started it. Caused by diffing `status` field on game session update trigger; two near-simultaneous updates both saw the diff. **Fix**: replaced status-diff with idempotent flag-claim on a `notificationsSent` map (commit `6e79cd9`). See [Game session push semantics](#game-session-push-semantics-idempotent-flag-claim). **Re-introduction risk**: any new game event that wants to push MUST use the flag-claim pattern, not status diff. ### E-GAME-001 — notification deep-link landed in stale/finished game **Symptom**: tapping a game-start notification re-entered a finished play screen instead of the active one. **Fix**: `MainActivity` `singleTop` launch mode + server-first read in `PartnerNotificationManager` (commit `b9b1560`). **Re-introduction risk**: changing `MainActivity` launch mode, or making the navigation read from local state before fetching server state. ### E-GAME-002 — game-start push easy to miss when app is foreground **Symptom**: system-tray notification was buried; partner missed game starts. **Fix**: foreground in-app banner via `GamePromptController` + bold Home "Game waiting" hero (commit `38fdc6d`). See [Foreground game-alert banner](#foreground-game-alert-banner-r10). **Re-introduction risk**: removing `GamePromptController` from the FCM message handler path, or breaking `ActiveGameSessionMonitor.enter/leave` in any game's ViewModel. ### C-NAV-001 — back from Home resurfaces onboarding/auth **Symptom**: after login + Home + BACK, app returned to onboarding instead of exiting. **Fix**: login flow must `popUpTo` the auth destination after successful navigation (commit `ebd3b2e`). **Re-introduction risk**: adding a new auth flow without `popUpTo`. The pattern is in `AppNavigation.kt`. ### C-SEC-001 — recovery phrase reading wrong store on accepter **Symptom**: after accepting an invite, the recovery-phrase reveal in Settings showed a disabled state with wrong "invite your partner" copy. The accepter's phrase was being read from the inviter's path. **Fix**: `SecurityViewModel` now reads via `encryptionManager.recoveryPhrase(coupleId)` from `CoupleKeyStore` (R10 fix phase, commit `9c84c36`). **Re-introduction risk**: any future change to `CoupleKeyStore` access that bypasses `EncryptionManager`. Always go through `EncryptionManager`. ### Back-stack gotchas (C-NAV-002, C-NAV-003) **Symptom**: Wheel results → BACK re-entered finished play screen (C-NAV-002); Wheel History / Past Games / Partner Home showed double app bars because the route was in both `shellBackRoutes` and the screen's own `TopAppBar` (C-NAV-003). **Fix**: `popUpTo(WHEEL_SESSION){inclusive=true}` on session→complete navigation; removed `WHEEL_HISTORY`/`GAME_HISTORY`/`PARTNER_HOME` from `shellBackRoutes` in `AppNavigation.kt`. **Re-introduction risk**: adding new screens with their own `TopAppBar` to `shellBackRoutes` — check the route list before adding. ### Home duplicate pending-action card (C-HOME-001) **Symptom**: Home showed the same pending action twice — once in the `primaryAction` hero, once in the `buildPendingActions` row. **Fix**: `buildPendingActions().filterNot { it.target == primary?.target }` to dedupe (R10). **Re-introduction risk**: adding a new pending-action type without checking it isn't already promoted to hero. ### Splash-exit crash — "ALL notifications open and immediately close" (cold-start) **Symptom**: tapping ANY notification (chat, every game push) cold-started the app and it opened-and-closed instantly — looked like "all notifications broke at once". Normal launcher cold-start and `adb am start` were both fine, which masked it. **Root cause**: `MainActivity.onCreate` splash-exit listener (added in the branding "loading state" commit `95cad84`) called `provider.iconView.animate()`. On a **notification / PendingIntent cold-start** the OS hands the splash over **without an icon** (`SplashScreenView: Icon: view: null`), so `provider.iconView` throws an internal NPE (`SplashScreenViewProvider$ViewImpl31.getIconView`) → `onCreate` crashes → "Force finishing activity". `am start` uses a different splash transfer, so it never hit the null-icon handover. **Fix**: wrap the icon scale in `runCatching` and the view fade in `runCatching{…}.onFailure{ provider.remove() }` so the splash is ALWAYS removed and `onCreate` never crashes (`MainActivity.kt`). **Re-introduction risk**: ANY touch to `MainActivity` splash/`onCreate`, launch mode, theme, manifest, or a "loading/branding" commit can re-break the shared cold-start path. **Re-run `qa/entrypoint_smoke.sh` (real push → `am kill`'d app → tap the shade) after such changes — `am start` is NOT a valid test for this class.** "Opens-and-closes / flashes" ⇒ assume a crash and pull the FATAL stack first; don't theorize routing. Many features broken at once ⇒ suspect the SHARED entry path, not each handler. ### Notification deep-link — ONE mechanism only **Symptom**: notifications "broke again" intermittently (race / duplicate navigation). **Root cause**: routing was built on BOTH an `ACTION_VIEW` + `closer://` **data Uri** (auto-handled by NavController for routes with a `navDeepLink`) AND an `app_route` **extra** (`pendingDeepLink`) → two mechanisms for one tap. **Fix/invariant**: app-posted notifications carry the resolved route in the **`app_route` extra ONLY**; routing is `MainActivity.deepLinkRouteFromIntent` → `pendingDeepLink` → `AppNavigation.navigateRoute`. **Never also set a data Uri.** The `pendingDeepLink` consumer must fire on any main screen (`currentRoute !in entryRoutes`), not only HOME. See [Notification deep-link routing](#notification-deep-link-routing). ### E-GAME-003 — async first-finisher left the waiting partner un-notified **Symptom**: one partner finished an async game (this_or_that/wheel/how_well/desire_sync) and the OTHER (idle/away) got nothing — the session only flips to `completed` (→ `partner_finished_game`) when BOTH answer, so `onGameSessionUpdate` (watches the session doc) never fired on a single finish. **Fix**: new Cloud Function `onGamePartFinished` on `couples/{coupleId}/{gameType}/{sessionId}` — when `answers` has exactly 1 key, idempotently claims `partFinishNotifiedAt` on the session doc and sends `partner_completed_part` ("X finished their part — your turn to play!") to the other member (deployed; `functions/src/games/onGameSessionUpdate.ts`). See [Game session push semantics](#game-session-push-semantics-idempotent-flag-claim). **Re-introduction risk**: changing the async-answer doc path or the both-answered→completed transition; QA must always test the asymmetric "one finishes, the other never played" state, not just both-sides-through. ### A-201 — Date Match premium ideas ungated (a badge is NOT a gate) **Symptom**: free users could view, like and match `isPremium` date ideas with no paywall. `DateMatchRepositoryImpl.getDateIdeas()` returned `DateIdeaSeed.all` with no entitlement filter; `DateMatchViewModel` had no `CouplePremiumChecker`; `DateMatchScreen` rendered only a cosmetic `PremiumBadge()`. (Server still blocked real premium self-grant, so only premium *content* leaked, not the entitlement.) **Fix (R12)**: `DateMatchViewModel` injects `CouplePremiumChecker`; `swipeCurrent` intercepts LOVE/MAYBE on a premium idea when neither partner is premium → emits `paywallRequired` → `DateMatchScreen` navigates to the Paywall; SKIP still passes; the deck stays on the card. Mirrors the established [gate pattern](#premium-gated-features-and-gate-pattern). **Re-introduction risk / lesson**: a feature can ship an `isPremium` content flag + a `PremiumBadge` with **no enforcement at all**. When adding premium content, wire a real `CouplePremiumChecker` gate (filter OR paywall-on-interaction) — a badge is a label, not a lock. Audit by **trying to USE premium content as a free user**, not by grepping for checker usages (which only finds the features that already have one). ### Theme-variant + soft-edge art (C-DARKART-001, C-ART-EDGE-001; C-ART-EDGE-002 open) **Symptom**: (1) art didn't follow the IN-APP theme — `CloserTheme(darkTheme)` only swaps Compose colors, while `painterResource`/`-night` drawables resolve off the **system** `uiMode`, so app-Dark on a light-mode phone showed light illustrations on a dark screen (C-DARKART-001). (2) Tiled illustrations showed a hard rounded-rect edge instead of blending (C-ART-EDGE-001). **Fix (R11)**: `CloserTheme` provides `LocalAppInDarkTheme`; `BrandIllustration` loads each drawable through `context.createConfigurationContext(cfg)` with `UI_MODE_NIGHT_*` from `LocalAppInDarkTheme` (theme-correct `-night`), and feathers its 4 edges to transparent via `graphicsLayer{compositingStrategy=Offscreen}` + `drawWithContent` `BlendMode.DstIn` gradients; `EmptyState` routes its image through `BrandIllustration`. Files: `ui/theme/Theme.kt`, `ui/components/BrandIllustration.kt`, `ui/components/EmptyState.kt`. **Re-introduction risk / still-open**: any art rendered via a **direct** `painterResource(R.drawable.illustration_*)` (NOT `BrandIllustration`) bypasses both fixes — hero images (daily-question, couple_subscription/paywall/onboarding, home prompts, spin-wheel) still hard-edge on dark (**C-ART-EDGE-002, open P3**). Prefer routing illustrations through `BrandIllustration`. --- ## 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. Admin SDK (Cloud Functions using `firebase-admin`) bypasses rules entirely. Anything that must be server-only is denied at the client rules level for defense in depth, but the real enforcement is "the client never gets the Admin SDK credentials." 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 field presence check, recovery phrase wipe, and unconditional `encryptionVersion = 2` assignment all in one file. This is also the function that breaks iOS pairing today (see [iOS E2EE gap](#ios-e2ee-gap-pairing-is-broken-from-ios-today)). 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. **`app/src/main/java/app/closer/notifications/GamePromptController.kt`** — foreground game-alert banner; `PartnerNotificationManager.kt` — deep-link routing. 12. **`app/src/main/java/app/closer/core/navigation/AppNavigation.kt`** — all routes, the back-stack invariants (C-NAV-001/002/003 fixes), and the `shellBackRoutes` list. 13. **`iphone/Closer/Services/FirestoreService.swift`** — the iOS side of cross-platform data contracts. 14. **`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. See also [Known landmines and recent fixes](#known-landmines-and-recent-fixes) before changing anything in the listed areas.