1254 lines
98 KiB
Markdown
1254 lines
98 KiB
Markdown
# 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; no directory exists today — no feature-flag code in repo)
|
||
│ ├── 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 (`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 was documented in earlier revisions of this manual but doesn't exist in the current source. The auth rate limiter is in `domain/security/AuthRateLimiter.kt`, and `QuestionJsonParser` is at `data/questions/QuestionJsonParser.kt` while `QuestionDao` is in `data/local/QuestionDao.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 two sign-in paths:
|
||
|
||
1. **Email/password** — standard sign-up and login.
|
||
2. **Google Sign-In** — via the legacy Google Sign-In SDK on Android (the app receives an `idToken` and calls `GoogleAuthProvider.getCredential(idToken, null)`), and the Google Sign-In SDK on iOS.
|
||
|
||
The Android `FirebaseAuthDataSource` exposes the standard Firebase paths for email/password and Google credential sign-in; iOS uses the same Firebase Auth APIs through `AuthService.swift`. There is **no anonymous sign-in or account-linking flow** in the current Android or iOS source. Users sign in directly with email/password or Google.
|
||
|
||
### 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 # 2 strict (all current couples)
|
||
wrappedCoupleKey: string | null
|
||
kdfSalt: string | null
|
||
kdfParams: string | null
|
||
```
|
||
|
||
`encryptionVersion` is stamped at `2` (`EncryptionVersion.STRICT`) on creation; there is no migration state in the current source. `encryptionMigrationUsers` is **not** a current field.
|
||
|
||
### 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<uid, bool>
|
||
/daily_question/{YYYY-MM-DD}
|
||
questionId, date, assignedAt, expiresAt
|
||
/answers/{userId} # schemaVersion 2 (enc:v1:) or 3 (sealed:v1:)
|
||
/releaseKeys/{recipientId} # keybox:v1:
|
||
/threads/{threadId}
|
||
questionId, createdAt, createdByUserId
|
||
/messages/{userId} # schemaVersion 3 sealed
|
||
/this_or_that/{sessionId} # games: enc:v1: shared couple key
|
||
/desire_sync/{sessionId}
|
||
/how_well/{sessionId}
|
||
/wheel/{sessionId}
|
||
/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<uid, Timestamp> # per-user last-read timestamp
|
||
typing: map<uid, Timestamp> # 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<uid, emoji> # 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<uid, { action: enc:v1:, swipedAt: number }>
|
||
# 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<category, weight> # 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<Boolean>
|
||
suspend fun hasPremium(): Boolean
|
||
fun onCustomerInfoUpdated(customerInfo: CustomerInfo)
|
||
}
|
||
```
|
||
|
||
Callers collect `isPremium()` reactively rather than caching a one-time snapshot. After a successful purchase, the Android repository calls `onCustomerInfoUpdated(...)` to push the new `CustomerInfo` into the fallback so the next emit reflects it.
|
||
|
||
### Webhook
|
||
|
||
`functions/src/billing/revenueCatWebhook.ts`:
|
||
|
||
- **Path**: HTTPS, POST only. GETs return 405.
|
||
- **Auth**: Ed25519 signature verification. `REVENUECAT_SIGNING_KEY` env var holds the base64-encoded DER/SPKI public key. Missing key → 500 (config error). Invalid/missing signature → 401.
|
||
- **Body**: RevenueCat event payload. Malformed payload → 400.
|
||
- **Flow**: acknowledge 200 immediately, then call `applyEntitlementEvent(event)`.
|
||
- **Idempotency**: `entitlement_events/{eventId}` records processed events. Re-delivered events are dropped.
|
||
|
||
### 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: <far-future Timestamp>
|
||
```
|
||
|
||
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 ("\<partner\> started \<Game\>" + 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<T>` for fallible operations. ViewModels expose `StateFlow<UiState>` with a sealed `Error` variant.
|
||
- iOS: `throws` for fallible paths; `AsyncStream` for reactive state.
|
||
- Offline behavior: question packs are bundled so the app is fully usable offline. Daily question assignment, partner reveal, and notifications all require network.
|
||
|
||
### Testing
|
||
|
||
- Android unit tests live in `app/src/test/`. JVM only; no device/emulator.
|
||
- Cloud Functions: `entitlementLogic.test.ts` uses Vitest. Run with `npm test` in `functions/`.
|
||
- Manual QA: see `docs/qa/private-mvp-checklist.md` and `docs/qa/ui-review.md`.
|
||
|
||
### Privacy and data retention
|
||
|
||
- Couples are deleted on user account deletion (cascade in `onUserDelete`).
|
||
- Invite attempts auto-expire via Firestore TTL (25h).
|
||
- Invites auto-expire after 24h.
|
||
- Notification queue entries are written by Cloud Functions and consumed by FCM; they are not auto-deleted today. Add a TTL if retention becomes a concern.
|
||
|
||
---
|
||
|
||
## 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.
|