955 lines
53 KiB
Markdown
955 lines
53 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`.
|
||
|
|
- [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)
|
||
|
|
11. [iOS-specific notes](#ios-specific-notes)
|
||
|
|
12. [Build and release](#build-and-release)
|
||
|
|
13. [Engineering conventions](#engineering-conventions)
|
||
|
|
14. [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.
|
||
|
|
|
||
|
|
### 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
|
||
|
|
├── core/
|
||
|
|
│ ├── analytics/ # Firebase Analytics + Crashlytics wrappers
|
||
|
|
│ ├── billing/ # EntitlementChecker + FirestoreEntitlementChecker
|
||
|
|
│ ├── crash/ # CrashReporter abstraction
|
||
|
|
│ ├── feature/ # Feature flags
|
||
|
|
│ ├── navigation/ # AppRoute constants, NavHost, ExternalLinks
|
||
|
|
│ ├── notifications/ # FCM service, TokenRegistrar, quiet hours
|
||
|
|
│ └── security/ # Auth rate limiter
|
||
|
|
├── crypto/ # E2EE: Tink AEAD, BouncyCastle Argon2id, key stores
|
||
|
|
├── data/
|
||
|
|
│ ├── local/ # Room DAOs, DataStore, EncryptedSharedPreferences
|
||
|
|
│ ├── remote/ # Firestore data sources, Cloud Functions callable wrappers
|
||
|
|
│ └── repository/ # Repository implementations
|
||
|
|
├── domain/
|
||
|
|
│ ├── model/ # Plain data classes
|
||
|
|
│ └── repository/ # Repository interfaces
|
||
|
|
├── di/ # Hilt modules
|
||
|
|
└── ui/ # Compose screens + ViewModels
|
||
|
|
├── answers/ # Answer write/reveal/history
|
||
|
|
├── brand/ # Logo, splash, illustrated empty states
|
||
|
|
├── challenges/ # Connection Challenges
|
||
|
|
├── dates/ # Date builder, matches, bucket list
|
||
|
|
├── desiresync/ # Preferences alignment exercise
|
||
|
|
├── games/ # Game scaffolding
|
||
|
|
├── home/ # Home dashboard + partner state
|
||
|
|
├── howwell/ # How Well Do You Know Me game
|
||
|
|
├── memorylane/ # Time capsules
|
||
|
|
├── onboarding/ # Onboarding screens
|
||
|
|
├── outcomes/ # 30/60/90 day check-ins
|
||
|
|
├── pairing/ # Invite create/accept/confirm/recovery
|
||
|
|
├── paywall/ # Subscription paywall
|
||
|
|
├── play/ # Play hub
|
||
|
|
├── questions/ # Daily question, packs, history
|
||
|
|
├── settings/ # All settings screens (account, privacy, subscription, security…)
|
||
|
|
├── thisorthat/ # This or That game
|
||
|
|
├── theme/ # CloserTheme
|
||
|
|
├── wheel/ # Spin the wheel
|
||
|
|
├── components/ # Shared Compose components
|
||
|
|
└── auth/ # Auth screens
|
||
|
|
```
|
||
|
|
|
||
|
|
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 (template; not committed)
|
||
|
|
└── Closer/
|
||
|
|
├── CloserApp.swift # @main, AppState, AppDelegate, RevenueCat init
|
||
|
|
├── ContentView.swift # Root NavigationStack + TabView
|
||
|
|
├── Core/
|
||
|
|
│ ├── Auth/AuthService.swift
|
||
|
|
│ ├── Billing/BillingService.swift
|
||
|
|
│ └── Notifications/NotificationService.swift
|
||
|
|
├── Crypto/ # Intended for CryptoKit E2EE parity — currently empty
|
||
|
|
├── Models/ # Codable Firestore + domain types
|
||
|
|
├── Services/FirestoreService.swift # Firestore + callable wrappers
|
||
|
|
├── Theme/CloserTheme.swift # Colors, typography, spacing
|
||
|
|
├── Components/ # Shared SwiftUI components
|
||
|
|
├── Navigation/ # Root routing (paired with ContentView)
|
||
|
|
├── Onboarding/ # Onboarding, login, signup
|
||
|
|
├── Pairing/ # Invite code, partner confirm, recovery
|
||
|
|
├── Home/ # Home dashboard, partner mirror
|
||
|
|
├── Questions/ # Daily question, answer reveal, history, packs
|
||
|
|
├── Play/ # Play hub + games
|
||
|
|
├── Wheel/ # Spin wheel
|
||
|
|
├── Dates/ # Date swipe, matches, builder, bucket list
|
||
|
|
├── Settings/ # Settings, paywall, subscription, help, data export
|
||
|
|
├── Paywall/ # (placeholder; paywall lives in Settings for now)
|
||
|
|
└── 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.
|
||
|
|
|
||
|
|
### 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
|
||
|
|
│ ├── sendGentleReminderCallable.ts # Manual gentle reminder
|
||
|
|
│ └── gameRetention.ts # Challenge day reminders, capsule unlocks
|
||
|
|
├── questions/
|
||
|
|
│ ├── assignDailyQuestion.ts # Pub/Sub schedule + manual callable
|
||
|
|
│ ├── onAnswerWritten.ts # Trigger: notify partner on answer
|
||
|
|
│ └── onMessageWritten.ts # Trigger: thread messages
|
||
|
|
├── security/
|
||
|
|
│ └── checkDeviceIntegrity.ts # Play Integrity verdict verification
|
||
|
|
└── users/
|
||
|
|
└── onUserDelete.ts # Auth user deletion cascade
|
||
|
|
```
|
||
|
|
|
||
|
|
There is no `auth/` module. Authentication is handled entirely by the Firebase Auth client SDK; the Admin SDK is used in the `users/onUserDelete.ts` trigger and in `couples/acceptInviteCallable.ts` to read user docs.
|
||
|
|
|
||
|
|
### Shared configuration
|
||
|
|
|
||
|
|
```text
|
||
|
|
firestore.rules # Security rules (single source of truth)
|
||
|
|
firestore.indexes.json # Composite indexes and TTL field overrides
|
||
|
|
seed/ # Question pack JSON and local DB generation
|
||
|
|
server/ # Optional Express webhook/health service (not client-facing)
|
||
|
|
docs/ # This manual, QA notes, release prep, store assets
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Authentication and pairing flow
|
||
|
|
|
||
|
|
### Auth providers
|
||
|
|
|
||
|
|
Firebase Auth supports three sign-in paths:
|
||
|
|
|
||
|
|
1. **Anonymous** — used for the trial onboarding flow. The user can use the app without an account and is prompted to upgrade before any irreversible action.
|
||
|
|
2. **Email/password** — standard sign-up and login.
|
||
|
|
3. **Google Sign-In** — via Credential Manager on Android, the Google Sign-In SDK on iOS.
|
||
|
|
|
||
|
|
The Android `FirebaseAuthDataSource` exposes the standard Firebase upgrade paths; iOS uses the same Firebase Auth APIs through `AuthService.swift`. Anonymous accounts are linked to email/Google credentials when the user upgrades. If linking fails because the credential already exists, the app signs into the existing account.
|
||
|
|
|
||
|
|
### Pairing flow
|
||
|
|
|
||
|
|
The pairing flow is server-mediated because 6-character codes are enumerable. The flow is identical on both platforms.
|
||
|
|
|
||
|
|
```text
|
||
|
|
Inviter (Android or iOS)
|
||
|
|
1. Generate couple keyset + recovery phrase (CoupleEncryptionManager on Android;
|
||
|
|
iOS skips step 1 — see iOS E2EE gap).
|
||
|
|
2. Generate 6-char code.
|
||
|
|
3. Encrypt phrase with code (RecoveryKeyManager.encryptPhraseWithCode) — Android only.
|
||
|
|
4. Call createInviteCallable(code, wrappedKey, salt, params, encryptedPhrase).
|
||
|
|
5. Server writes /invites/{code} with 24h TTL and a `notification_queue` entry.
|
||
|
|
6. Inviter shows code, copies/shares it.
|
||
|
|
|
||
|
|
Acceptor (any platform)
|
||
|
|
7. Enter code.
|
||
|
|
8. Call acceptInviteCallable({ code }).
|
||
|
|
9. Server validates code, creates /couples/{coupleId}, links both user docs,
|
||
|
|
returns wrappedKey + encryptedRecoveryPhrase.
|
||
|
|
10. Acceptor decrypts phrase with code (decryptPhraseWithCode) — Android only.
|
||
|
|
11. Acceptor unwraps keyset with phrase (CoupleEncryptionManager.unwrapAndStore) — Android only.
|
||
|
|
12. Both users now share the same couple key (or plaintext for iOS couples).
|
||
|
|
```
|
||
|
|
|
||
|
|
The `couples` document is **never** written by clients. Even legitimate field updates like `streakCount` go through Cloud Functions or are blocked by rules. See [Firestore security rules](#firestore-security-rules) for the per-field immutability matrix.
|
||
|
|
|
||
|
|
### The `couples` document model
|
||
|
|
|
||
|
|
```text
|
||
|
|
/couples/{coupleId}
|
||
|
|
id: string
|
||
|
|
userIds: [string, string]
|
||
|
|
inviteCode: string
|
||
|
|
createdAt: timestamp (server-side)
|
||
|
|
streakCount: int
|
||
|
|
lastAnsweredAt: timestamp | null
|
||
|
|
currentQuestionId: string | null # server-controlled, read by clients
|
||
|
|
activePackId: string | null # server-controlled, read by clients
|
||
|
|
encryptionVersion: int # 0 plaintext, 1 migrating, 2 strict
|
||
|
|
wrappedCoupleKey: string | null
|
||
|
|
kdfSalt: string | null
|
||
|
|
kdfParams: string | null
|
||
|
|
encryptionMigrationUsers: map<string, bool>
|
||
|
|
```
|
||
|
|
|
||
|
|
`currentQuestionId` and `activePackId` exist as fields and are read by clients to display "today's question" state, but they are server-controlled — clients cannot write them.
|
||
|
|
|
||
|
|
### Rate limiting on accept
|
||
|
|
|
||
|
|
`functions/src/couples/acceptInviteCallable.ts` enforces a rolling-window rate limit:
|
||
|
|
|
||
|
|
- **Window**: 1 hour.
|
||
|
|
- **Max attempts per caller**: 10.
|
||
|
|
- **Storage**: `users/{uid}/invite_attempts` with a Firestore TTL field (`expiresAt`, 25 hours) so old attempts age out automatically.
|
||
|
|
- **Index**: TTL field override is declared in `firestore.indexes.json` under `fieldOverrides` for the `invite_attempts` collection group.
|
||
|
|
|
||
|
|
This prevents brute-forcing the 6-character invite code space.
|
||
|
|
|
||
|
|
### Recovery phrase flow
|
||
|
|
|
||
|
|
The recovery phrase is the only human-readable secret in the system. It is never sent to the server in plaintext.
|
||
|
|
|
||
|
|
1. When an Android inviter creates a couple, `RecoveryKeyManager.generateRecoveryPhrase()` produces a 10-word phrase from a 256-word list. The phrase has roughly 80 bits of raw entropy; Argon2id makes brute-force infeasible.
|
||
|
|
2. The inviter encrypts the phrase with the invite code using `encryptPhraseWithCode` and stores the blob on the invite document.
|
||
|
|
3. The acceptor receives the encrypted blob, decrypts it with the same code, and stores the phrase locally.
|
||
|
|
4. The phrase is used to unwrap the couple keyset from `wrappedCoupleKey`.
|
||
|
|
5. The recovery phrase can be shown in settings and used to recover the couple key on a new device. Changing the recovery phrase re-wraps the locally-held keyset and uploads a new `wrappedCoupleKey` to Firestore.
|
||
|
|
|
||
|
|
iOS does not generate or store a recovery phrase in the current build. iOS couples have no recovery path; the couple key (when iOS E2EE ships) will need a different recovery story or the gap will need to be communicated to users.
|
||
|
|
|
||
|
|
### Key Android files
|
||
|
|
|
||
|
|
- `app/src/main/java/app/closer/data/remote/FirebaseAuthDataSource.kt` — Firebase Auth wrapper.
|
||
|
|
- `app/src/main/java/app/closer/data/remote/FirestoreInviteDataSource.kt` — callable wrappers for invite create/accept.
|
||
|
|
- `app/src/main/java/app/closer/data/repository/InviteRepositoryImpl.kt` — invite business logic, code retry, phrase encryption/decryption.
|
||
|
|
- `app/src/main/java/app/closer/crypto/CoupleEncryptionManager.kt` — keyset orchestration.
|
||
|
|
- `app/src/main/java/app/closer/crypto/RecoveryKeyManager.kt` — Argon2id KDF, phrase generation, wrap/unwrap.
|
||
|
|
- `app/src/main/java/app/closer/ui/pairing/CreateInviteViewModel.kt` and `AcceptInviteViewModel.kt` — UI layer.
|
||
|
|
|
||
|
|
### Key Cloud Functions
|
||
|
|
|
||
|
|
- `functions/src/couples/createInviteCallable.ts`
|
||
|
|
- `functions/src/couples/acceptInviteCallable.ts`
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## End-to-end encryption model
|
||
|
|
|
||
|
|
### Encryption versions
|
||
|
|
|
||
|
|
`couples/{coupleId}.encryptionVersion` is the single source of truth for a couple's encryption state. The mapping is canonical in `app/src/main/java/app/closer/crypto/EncryptionVersion.kt` and mirrored in Cloud Functions.
|
||
|
|
|
||
|
|
| Version | Name | Meaning |
|
||
|
|
| --- | --- | --- |
|
||
|
|
| 0 | `PLAINTEXT` | No couple key; answers may be plaintext. Used by iOS couples until E2EE parity ships. |
|
||
|
|
| 1 | `MIGRATING` | A couple key exists but historical content is still being rewritten by both partners. Kept for backwards compatibility with older couples; no new couples should be created at v1. |
|
||
|
|
| 2 | `STRICT` | All answer-bearing paths require encryption. Default for all new Android couples. |
|
||
|
|
|
||
|
|
The Cloud Function `acceptInviteCallable.ts` derives `encryptionVersion` from whether E2EE fields are present: if `wrappedCoupleKey`, `kdfSalt`, and `kdfParams` are all non-null, the couple is created at v2; otherwise v0. This keeps the iOS-and-Android-different-defaults case from breaking.
|
||
|
|
|
||
|
|
### Couple key wrapping with Argon2id
|
||
|
|
|
||
|
|
The couple keyset is a Tink AES-256-GCM keyset generated once per couple.
|
||
|
|
|
||
|
|
- `RecoveryKeyManager.newCoupleKeyset()` creates the keyset.
|
||
|
|
- `RecoveryKeyManager.wrap(keyset, phrase)` derives a 32-byte key with Argon2id:
|
||
|
|
- **memory**: 46 MiB (`46080` KiB)
|
||
|
|
- **iterations**: 3
|
||
|
|
- **parallelism**: 1
|
||
|
|
- **salt**: 16 random bytes
|
||
|
|
- The keyset plaintext is encrypted with AES-256-GCM using the derived key. AAD is the fixed string `"closer_couple_key"` so the blob is portable across invite-code reconciliation.
|
||
|
|
- The wrapped result is stored on the couple document as `wrappedCoupleKey`, `kdfSalt`, `kdfParams`.
|
||
|
|
|
||
|
|
The Argon2id parameters are deliberately chosen to take ~2-3 seconds on a mid-range phone — slow enough to make offline brute-force infeasible, fast enough that recovery on a new device is bearable. Do not change these parameters without auditing cross-platform compatibility.
|
||
|
|
|
||
|
|
### Tink AEAD
|
||
|
|
|
||
|
|
- `FieldEncryptor.kt` encrypts individual Firestore fields. Wire format: `enc:v1:{base64(tinkCiphertext)}`. AAD is the `coupleId`.
|
||
|
|
- `SealedAnswerEncryptor.kt` encrypts sealed answer payloads. Wire format: `sealed:v1:{urlsafe-base64-no-padding}`. AAD is `coupleId|questionId|userId`.
|
||
|
|
- `CoupleKeyStore.kt` persists Tink keyset handles in Keystore-backed EncryptedSharedPreferences.
|
||
|
|
|
||
|
|
### Recovery phrase
|
||
|
|
|
||
|
|
See [Recovery phrase flow](#recovery-phrase-flow). The recovery phrase is the only human-readable secret. It is never sent to the server in plaintext.
|
||
|
|
|
||
|
|
### Sealed-answer partner-proof mode
|
||
|
|
|
||
|
|
Sealed answers (`schemaVersion = 3`) provide partner-proof privacy: even a malicious or compromised device cannot read the partner's answer until both partners have submitted and released their one-time keys.
|
||
|
|
|
||
|
|
Flow:
|
||
|
|
|
||
|
|
```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. **Legacy / company-proof** (any `schemaVersion` ≠ 3): `enc:v1:` ciphertext fields, content is encrypted with the couple key.
|
||
|
|
2. **Sealed / partner-proof** (`schemaVersion = 3`): `sealed:v1:` payload + `sha256:` commitment; content key is released only after both partners have submitted.
|
||
|
|
|
||
|
|
A write must match exactly one of these shapes — the rules reject anything else.
|
||
|
|
|
||
|
|
### Partner notification
|
||
|
|
|
||
|
|
`functions/src/questions/onAnswerWritten.ts` is a Firestore trigger on `couples/{coupleId}/daily_question/{date}/answers/{userId}` (onCreate). It looks up the partner's FCM tokens and sends:
|
||
|
|
|
||
|
|
- A data message so the client can route directly to the reveal screen.
|
||
|
|
- A notification block for system-tray display when the app is in the background.
|
||
|
|
|
||
|
|
Token lookup reads both a legacy `fcmToken` field on the user doc and a dedicated `fcmTokens` subcollection for multi-device.
|
||
|
|
|
||
|
|
### Reveal flow
|
||
|
|
|
||
|
|
The reveal happens client-side after both partners have submitted:
|
||
|
|
|
||
|
|
1. Each app checks for the partner's `answers/{partnerId}` doc.
|
||
|
|
2. Each app writes a `releaseKeys/{partnerId}.encryptedAnswerKey` containing the sender's one-time key wrapped to the partner's ECIES public key.
|
||
|
|
3. Each app reads the `releaseKeys/{selfUserId}.encryptedAnswerKey` written by the partner.
|
||
|
|
4. Each app unwraps the key with its private key and decrypts the partner's `encryptedPayload`.
|
||
|
|
5. Each app verifies the decrypted payload's SHA-256 commitment matches `commitmentHash`.
|
||
|
|
6. The reveal screen shows both answers side-by-side.
|
||
|
|
|
||
|
|
The reveal state is gated by Firestore rules: only the sender writes the keybox, only the recipient reads it. The sealed payload is created with `answerKeyReleased: false`; the rules only allow the reveal-metadata fields (`isRevealed`, `answerKeyReleased`, `updatedAt`) to change after creation.
|
||
|
|
|
||
|
|
### Thread questions
|
||
|
|
|
||
|
|
Thread questions follow the same sealed flow but use a different path:
|
||
|
|
|
||
|
|
- `couples/{coupleId}/threads/{threadId}/messages/{userId}` — the thread message, not the daily answer.
|
||
|
|
- The Firestore rules use `isSealedThreadAnswerCreate` / `isSealedThreadAnswerUpdate` helpers, which are identical to the answer helpers except there is no `answerDate` and no `isRevealed` field (reveal state is tracked by the thread VM, not the rules).
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Firestore data model
|
||
|
|
|
||
|
|
```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
|
||
|
|
premium: bool
|
||
|
|
expiresAt: Timestamp | null
|
||
|
|
/fcmTokens/{tokenId} # owned by the user
|
||
|
|
token: string
|
||
|
|
platform: 'android' | 'ios'
|
||
|
|
updatedAt: Timestamp
|
||
|
|
/devices/{deviceId} # ECIES public keys for sealed answers
|
||
|
|
publicKey: string # 'pub:v1:...'
|
||
|
|
platform: 'android' | 'ios'
|
||
|
|
updatedAt: Timestamp
|
||
|
|
/outcomes/{dayKey} # day_0, day_30, day_60, day_90; server-only
|
||
|
|
submittedAt: Timestamp
|
||
|
|
answers: map
|
||
|
|
/notification_queue/{id} # server-only; partner-pending notifications
|
||
|
|
type: string
|
||
|
|
payload: map
|
||
|
|
createdAt: Timestamp
|
||
|
|
delivered: bool
|
||
|
|
/invite_attempts/{id} # rate-limit; Firestore TTL
|
||
|
|
code: string
|
||
|
|
attemptedAt: Timestamp
|
||
|
|
expiresAt: Timestamp # TTL field
|
||
|
|
|
||
|
|
/couples/{coupleId}
|
||
|
|
id, userIds[2], inviteCode, createdAt
|
||
|
|
streakCount, lastAnsweredAt
|
||
|
|
currentQuestionId, activePackId
|
||
|
|
encryptionVersion, wrappedCoupleKey, kdfSalt, kdfParams
|
||
|
|
encryptionMigrationUsers: map<uid, bool>
|
||
|
|
/daily_question/{YYYY-MM-DD}
|
||
|
|
questionId, date, assignedAt, expiresAt
|
||
|
|
/answers/{userId} # schemaVersion 2 (enc:v1:) or 3 (sealed:v1:)
|
||
|
|
/releaseKeys/{recipientId} # keybox:v1:
|
||
|
|
/threads/{threadId}
|
||
|
|
questionId, createdAt, createdByUserId
|
||
|
|
/messages/{userId} # schemaVersion 3 sealed
|
||
|
|
/this_or_that/{sessionId} # games: enc:v1: shared couple key
|
||
|
|
/desire_sync/{sessionId}
|
||
|
|
/how_well/{sessionId}
|
||
|
|
/wheel/{sessionId}
|
||
|
|
/date_swipes/{swipeId}
|
||
|
|
userId, dateIdeaId, action: 'love' | 'maybe' | 'skip'
|
||
|
|
/date_matches/{matchId}
|
||
|
|
userIds, dateIdeaId, createdAt
|
||
|
|
/date_plans/{planId}
|
||
|
|
title, dateTime, status: 'draft' | 'planned' | 'completed'
|
||
|
|
/date_plan_preferences/{uid}
|
||
|
|
categories: map
|
||
|
|
/bucket_list/{itemId}
|
||
|
|
title, category, addedByUserId, completedAt
|
||
|
|
|
||
|
|
/invites/{code} # server-only writes; 24h TTL
|
||
|
|
code, inviterUserId, status: 'pending' | 'accepted' | 'expired'
|
||
|
|
createdAt, expiresAt
|
||
|
|
wrappedCoupleKey, kdfSalt, kdfParams, recoveryPhrase
|
||
|
|
|
||
|
|
/questions/{questionId} # read-only catalog (admin seeded)
|
||
|
|
text, categoryId, active, isPremium
|
||
|
|
|
||
|
|
/date_ideas/{dateIdeaId} # read-only catalog
|
||
|
|
title, description, category, imageUrl
|
||
|
|
|
||
|
|
/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/{swipeId}.userId` → `users/{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}$')
|
||
|
|
isImmutable(fields) diff(...).affectedKeys().hasOnly(fields)
|
||
|
|
isCiphertext(value) matches('^enc:v1:[A-Za-z0-9+/]+={0,2}$')
|
||
|
|
isSealedPayload(value) matches('^sealed:v1:[A-Za-z0-9_-]{80,}$')
|
||
|
|
isKeybox(value) matches('^keybox:v1:[A-Za-z0-9_-]{120,}$')
|
||
|
|
isCommitmentHash(value) matches('^sha256:[A-Za-z0-9_-]{43}$')
|
||
|
|
isSealedAnswerCreate(data) sealed-answer shape + sealed:v1 + sha256:
|
||
|
|
isSealedAnswerUpdate() only reveal-metadata fields
|
||
|
|
isStartingEncryptionMigration() v0/v1 → v1 with empty migration map
|
||
|
|
isCompletingOwnEncryptionMigration() v1 → v1/v2 with self in migration map
|
||
|
|
isUpdatingRecoveryWrap() only wrappedCoupleKey/kdfSalt/kdfParams
|
||
|
|
isUpdatingCoupleRhythm() only streakCount/lastAnsweredAt
|
||
|
|
```
|
||
|
|
|
||
|
|
### Per-collection enforcement
|
||
|
|
|
||
|
|
**`users/{uid}`** — owner can read/create/update their own doc but `hasPremium` is server-only. `entitlements/`, `notification_queue/`, and `outcomes/` are server-only writes. `fcmTokens/` and `devices/` are owner-writable. The `devices/` public key is readable by the user's current couple partner only (to wrap release keys) — restricting it prevents speculative pre-encryption by non-partners.
|
||
|
|
|
||
|
|
**`date_ideas/`** — read-only for any signed-in user; writes are admin-only.
|
||
|
|
|
||
|
|
**`invites/{code}`** — reads are restricted to the inviter (`request.auth.uid == resource.data.inviterUserId`). All writes are denied for clients. This is the core defense against 6-character code enumeration: even legitimate create/update/delete must go through a Cloud Function, which can enforce rate limits, uniqueness, and key-material checks.
|
||
|
|
|
||
|
|
**`couples/{coupleId}`** — only the two members may read. Writes are denied for clients entirely; the rules restrict the shape of the doc and let Cloud Functions do all updates. Field-level immutability helpers (`isUpdatingCoupleRhythm`, `isUpdatingRecoveryWrap`, `isStartingEncryptionMigration`, `isCompletingOwnEncryptionMigration`) define what each update path is allowed to touch.
|
||
|
|
|
||
|
|
**`couples/{coupleId}/daily_question/...`** — server-only writes. Daily-question assignment and answer-related subcollections are tightly constrained.
|
||
|
|
|
||
|
|
**`couples/{coupleId}/daily_question/{date}/answers/{userId}`** — the answer is private to its author until reveal. The `isSealedAnswerCreate` / `isSealedAnswerUpdate` helpers enforce the sealed-answer shape. Legacy answers (`schemaVersion` ≠ 3) must use `enc:v1:` ciphertext.
|
||
|
|
|
||
|
|
**`couples/{coupleId}/daily_question/{date}/answers/{userId}/releaseKeys/{recipientId}`** — create-only by the answer owner, readable only by the named recipient. `keybox:v1:` shape is enforced.
|
||
|
|
|
||
|
|
**`couples/{coupleId}/{this_or_that|desire_sync|how_well|wheel}/{sessionId}`** — `enc:v1:` ciphertext per user. Games are company-proof (server can't read), but not partner-proof (a modified client could read the partner's encrypted slot before the reveal). Sealed per-answer keys are not used here because games are real-time simultaneous — both players submit and see results together.
|
||
|
|
|
||
|
|
**`entitlement_events/`** — no client access.
|
||
|
|
|
||
|
|
### Why invariants matter
|
||
|
|
|
||
|
|
The rules are not just access control — they are a wire-format contract. A client that writes a malformed sealed payload is denied at write time, which prevents bad data from propagating. Bumping a wire format version (e.g. `sealed:v1:` → `sealed:v2:`) is a rules change AND a client change; do them together.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Cloud Functions
|
||
|
|
|
||
|
|
### Module pattern
|
||
|
|
|
||
|
|
Every function module follows the same shape:
|
||
|
|
|
||
|
|
- One or more exported handlers (callable, onRequest, onCall trigger, onCreate trigger, Pub/Sub schedule).
|
||
|
|
- Lazy `admin.firestore()` / `admin.messaging()` access at invocation time. The Admin SDK is initialized once in `functions/src/index.ts`.
|
||
|
|
- The function name is the export name. `index.ts` re-exports each handler explicitly — no glob imports.
|
||
|
|
- Cloud Function logs are prefixed with the function name (e.g. `[acceptInviteCallable]`) for grep-ability.
|
||
|
|
|
||
|
|
### Handler types
|
||
|
|
|
||
|
|
| Type | Example | Notes |
|
||
|
|
| --- | --- | --- |
|
||
|
|
| HTTPS onRequest | `revenueCatWebhook`, `health` | Path-based; bypass callable auth. Webhook requires Ed25519 signature verification. |
|
||
|
|
| HTTPS onCall | `createInviteCallable`, `acceptInviteCallable`, `syncEntitlement`, `sendDailyQuestionReminder`, `sendPartnerAnsweredNotification`, `sendGentleReminderCallable`, `submitOutcomeCallable`, `leaveCoupleCallable`, `checkDeviceIntegrity`, `assignDailyQuestionCallable` | Caller must be authenticated. Errors throw `HttpsError`. |
|
||
|
|
| Firestore onCreate | `onAnswerWritten`, `onMessageWritten`, `onCoupleLeave`, `onUserDelete`, `onGameSessionUpdate`, `createDateMatchOnMutualLove` | Event-driven; best-effort. |
|
||
|
|
| Auth onDelete | `onUserDelete` | Auth user deletion cascade. |
|
||
|
|
| Pub/Sub schedule | `assignDailyQuestion`, `scheduledOutcomesReminder` | Cron expression in `America/Chicago`. |
|
||
|
|
|
||
|
|
### Per-module responsibilities
|
||
|
|
|
||
|
|
- **billing** — RevenueCat webhook, entitlement event handlers, forced re-sync callable. Entitlement writes are idempotent (write the same Firestore doc and use `entitlement_events/` as a dedup marker).
|
||
|
|
- **couples** — invite create/accept/leave, outcome submission, scheduled 30/60/90 reminders.
|
||
|
|
- **dates** — mutual-love trigger creates a date match document.
|
||
|
|
- **games** — game session updates notify the partner and append to `notification_queue`.
|
||
|
|
- **notifications** — daily question reminders, partner-answered notifications, gentle reminders, challenge day reminders, capsule unlock schedule.
|
||
|
|
- **questions** — daily question assignment, answer write trigger, thread message trigger.
|
||
|
|
- **security** — Play Integrity verdict verification.
|
||
|
|
- **users** — Auth user deletion cascade.
|
||
|
|
|
||
|
|
### Webhook reliability
|
||
|
|
|
||
|
|
`revenueCatWebhook` acknowledges with HTTP 200 immediately after signature verification and parses the event, then **before** applying the entitlement write. This is intentional to prevent RevenueCat retries. If `applyEntitlementEvent` fails after the 200, the failure is logged but the event is not retried. The webhook handler does not currently use a dead-letter queue. **Risk**: a transient Firestore outage could lose entitlement events. The mitigation today is `entitlement_events/{eventId}` as an idempotency marker — re-running the webhook for a missing event would dedup on event ID. A future fix should add a Cloud Tasks-based retry or a dead-letter `entitlement_events_failed/` collection.
|
||
|
|
|
||
|
|
### Schedule
|
||
|
|
|
||
|
|
```text
|
||
|
|
assignDailyQuestion 0 23 * * * America/Chicago
|
||
|
|
scheduledOutcomesReminder * * * * * America/Chicago (per-minute, scans couples)
|
||
|
|
unlockDueMemoryCapsules (in gameRetention; check source for cron)
|
||
|
|
sendChallengeDayReminders (in gameRetention; check source for cron)
|
||
|
|
```
|
||
|
|
|
||
|
|
`scheduledOutcomesReminder` currently scans all couples with no pagination. It will need to shard or paginate as the user base grows.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Billing
|
||
|
|
|
||
|
|
### RevenueCat integration
|
||
|
|
|
||
|
|
Both clients use the RevenueCat native SDK (`purchases:8.20.0` on Android, `purchases-ios` via SPM). The SDK handles the platform billing surface (Google Play Billing on Android, StoreKit on iOS) and exposes a normalized `CustomerInfo` API.
|
||
|
|
|
||
|
|
The Android app reads the API key from `BuildConfig.RC_API_KEY`, which is sourced from `local.properties` or the `RC_API_KEY` env var. A release build with a missing or placeholder key **fails fast** with a `GradleException` from `app/build.gradle.kts` — there is a doFirst guard on every release assemble/bundle task.
|
||
|
|
|
||
|
|
iOS reads `RC_API_KEY` from `Info.plist` via the `Secrets` enum in `CloserApp.swift`. A missing or empty key logs a warning and the app continues, but the paywall will not be functional.
|
||
|
|
|
||
|
|
### Server-verified entitlements
|
||
|
|
|
||
|
|
The Android `FirestoreEntitlementChecker` is the source of truth for premium state. It:
|
||
|
|
|
||
|
|
1. Observes `users/{userId}/entitlements/premium` for real-time changes.
|
||
|
|
2. If the server document does not exist, falls back to the local RevenueCat `CustomerInfo`.
|
||
|
|
3. Treats an `expiresAt` in the past as `premium = false`.
|
||
|
|
4. **Fails closed**: on Firestore listener error, `isPremium()` emits `false` (i.e. "not premium") rather than guessing.
|
||
|
|
|
||
|
|
The iOS `DefaultEntitlementChecker` actor does **not** observe Firestore entitlements. It reads RevenueCat `CustomerInfo` only, via `Purchases.shared.customerInfoStream`. **iOS premium state is therefore client-verified, not server-verified** — this is a known gap that should be closed before production.
|
||
|
|
|
||
|
|
The `EntitlementChecker` interface (Android) is intentionally narrow:
|
||
|
|
|
||
|
|
```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.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 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` (callable) and `assignDailyQuestion` (scheduled) handle the two reminder paths. The scheduled function writes a new `daily_question/{date}` doc; the callable is used for manual re-triggering.
|
||
|
|
|
||
|
|
### 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` and `sendChallengeDayReminders` (in `gameRetention.ts`) handle ad-hoc nudges. `unlockDueMemoryCapsules` opens time capsules whose lock date has passed.
|
||
|
|
|
||
|
|
### Per-user notification_queue
|
||
|
|
|
||
|
|
`users/{uid}/notification_queue/` is a server-only collection that stores pending partner notifications. The FCM push is the user-visible surface; the queue is for in-app polling and for tracking delivery. Reads are denied for clients; the app reacts to FCM, not the queue.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## iOS-specific notes
|
||
|
|
|
||
|
|
### Architecture
|
||
|
|
|
||
|
|
- `@main` is in `CloserApp.swift`. `AppState` is the root `@StateObject`; views receive it via `@EnvironmentObject`.
|
||
|
|
- `AppState` owns `authState`, `currentUser`, `currentCouple`, `currentPartner`, and `isPremium`. It listens to auth state and partner changes.
|
||
|
|
- The app uses `NavigationStack` + `.navigationDestination` for routing. There is no DI framework; dependencies are passed through `shared` singletons (`AuthService.shared`, `FirestoreService.shared`, `BillingService.shared`).
|
||
|
|
- iOS 17+ is the deployment target.
|
||
|
|
|
||
|
|
### CloserTheme
|
||
|
|
|
||
|
|
`CloserTheme.swift` defines color tokens, typography, spacing, and radius. The brand is documented in `docs/brand/visual-identity.md` and copy is in `docs/copy-guide.md`. Brand color references use `Color.closerPrimary`, `Color.closerBackground`, etc. (not raw hex).
|
||
|
|
|
||
|
|
### XcodeGen
|
||
|
|
|
||
|
|
The iOS project is generated by XcodeGen from `iphone/project.yml`. After editing `project.yml`, run `xcodegen generate` and reopen `Closer.xcodeproj`. Do not commit changes to `Closer.xcodeproj` — it is regenerated.
|
||
|
|
|
||
|
|
### Build
|
||
|
|
|
||
|
|
```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
|
||
|
|
|
||
|
|
The iOS port does not implement E2EE today. Concrete consequences:
|
||
|
|
|
||
|
|
- iOS couples are created with `encryptionVersion = 0`. Their `couples` doc has no `wrappedCoupleKey` / `kdfSalt` / `kdfParams`.
|
||
|
|
- iOS answer writes use the plaintext path. Firestore rules allow plaintext only when `encryptionVersion < 1`.
|
||
|
|
- iOS does not generate or store a recovery phrase.
|
||
|
|
- iOS does not have a CryptoKit implementation of Tink keyset serialization, Argon2id KDF, or sealed-answer ECIES.
|
||
|
|
- iOS premium state is RevenueCat-only, not server-verified.
|
||
|
|
|
||
|
|
A user who pairs an Android device with an iOS device today creates a mixed-couple (`encryptionVersion = 2` from Android, but the iOS partner's flow is still plaintext). The Android-side rules tolerate this, but the Android user is the only one whose answers are actually encrypted — the iOS user's answers are plaintext. **Do not ship this combination to users** without:
|
||
|
|
|
||
|
|
1. A CryptoKit E2EE implementation that produces byte-compatible ciphertexts with the Android Tink paths.
|
||
|
|
2. An Argon2id implementation that produces identical bytes for the recovery phrase KDF (e.g. `SwiftArgon2` with the exact same parameters: `m=46080 KiB, t=3, p=1, salt=16 bytes`).
|
||
|
|
3. Server-side gating that refuses to pair cross-platform couples until iOS has parity, OR an explicit user-facing "iOS answers are not yet encrypted" notice.
|
||
|
|
|
||
|
|
### iOS CryptoKit guidance (future)
|
||
|
|
|
||
|
|
When implementing iOS E2EE parity:
|
||
|
|
|
||
|
|
- Use CryptoKit's `AES.GCM` for symmetric encryption. AAD binding must match Android exactly.
|
||
|
|
- Use `P256.KeyAgreement` + `HKDF` + `AES.GCM` for ECIES equivalent. Tink's `ECIES_P256_HKDF_HMAC_SHA256_AES128_GCM` is not bit-compatible with raw CryptoKit, so this is a non-trivial port. Consider keeping Tink via `BoringSSL-TLC` or porting the exact KDF/AEAD composition.
|
||
|
|
- Use `SecItemAdd` / `SecItemCopyMatching` for keychain storage. Replace EncryptedSharedPreferences with Keychain in a way that survives app reinstalls on the same device.
|
||
|
|
- For Argon2id, the open-source `SwiftArgon2` package is the only reasonable option. Verify byte output against the Android BouncyCastle reference before shipping.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Build and release
|
||
|
|
|
||
|
|
### Android
|
||
|
|
|
||
|
|
- **Module**: `app/`
|
||
|
|
- **Package**: `app.closer`
|
||
|
|
- **compileSdk**: 35, **minSdk**: 26, **targetSdk**: 35
|
||
|
|
- **Java/Kotlin**: 17
|
||
|
|
- **Versioning**: `versionCode` is integer; `versionName` is a string. **Current state**: `versionCode = 1`, `versionName = "0.1.0"`. HISTORY.md describes versions up to `0.2.x`. Bump `versionName` in `app/build.gradle.kts` when cutting a release.
|
||
|
|
|
||
|
|
### 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 config. The repo template does not include a real one; copy from your Firebase project.
|
||
|
|
- `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
|
||
|
|
|
||
|
|
Add to every clone's `.gitignore`:
|
||
|
|
|
||
|
|
```text
|
||
|
|
FUTURE.md
|
||
|
|
HISTORY.md
|
||
|
|
PROJECT.md
|
||
|
|
STRUCTURE.md
|
||
|
|
project-requirements.md
|
||
|
|
DEVELOPMENT_LOG.md
|
||
|
|
BUILD_SUMMARY.md
|
||
|
|
SCRIPTS.md
|
||
|
|
.learnings/
|
||
|
|
.kotlin/
|
||
|
|
```
|
||
|
|
|
||
|
|
These are agent-only or workspace-only docs and have no place in the public repo.
|
||
|
|
|
||
|
|
### Versioning
|
||
|
|
|
||
|
|
- **Major**: 0 → 1 is the MVP-to-public cut. Today we are 0.x.
|
||
|
|
- **Minor**: a complete feature batch (e.g. E2EE parity, payment integration, full iOS port).
|
||
|
|
- **Patch**: bug fixes, polish, internal refactors.
|
||
|
|
- **Source of truth**: `app/build.gradle.kts` for Android `versionName`. HISTORY.md is the changelog. Keep them in sync.
|
||
|
|
|
||
|
|
### Naming
|
||
|
|
|
||
|
|
- Android: Kotlin package `app.closer.*` (do not resurrect the old `com.couplesconnect` package).
|
||
|
|
- iOS: Swift module `Closer`. Folder names match Android's screen names where they exist.
|
||
|
|
- Cloud Functions: one module per domain (`billing/`, `couples/`, ...). Function names match the file name (`acceptInviteCallable.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/local/QuestionJsonParser.kt` for an example).
|
||
|
|
- Crashlytics is the production observability path. Do not log to both Crashlytics and console in production.
|
||
|
|
|
||
|
|
### Error handling
|
||
|
|
|
||
|
|
- Cloud Functions: throw `HttpsError(code, message)` with the closest matching code. Never throw a plain `Error`.
|
||
|
|
- Android: repositories return `Result<T>` for fallible operations. ViewModels expose `StateFlow<UiState>` with a sealed `Error` variant.
|
||
|
|
- iOS: `throws` for fallible paths; `AsyncStream` for reactive state.
|
||
|
|
- Offline behavior: question packs are bundled so the app is fully usable offline. Daily question assignment, partner reveal, and notifications all require network.
|
||
|
|
|
||
|
|
### Testing
|
||
|
|
|
||
|
|
- Android unit tests live in `app/src/test/`. JVM only; no device/emulator.
|
||
|
|
- Cloud Functions: `entitlementLogic.test.ts` uses Vitest. Run with `npm test` in `functions/`.
|
||
|
|
- Manual QA: see `docs/qa/private-mvp-checklist.md` and `docs/qa/ui-review.md`.
|
||
|
|
|
||
|
|
### Privacy and data retention
|
||
|
|
|
||
|
|
- Couples are deleted on user account deletion (cascade in `onUserDelete`).
|
||
|
|
- Invite attempts auto-expire via Firestore TTL (25h).
|
||
|
|
- Invites auto-expire after 24h.
|
||
|
|
- Notification queue entries are written by Cloud Functions and consumed by FCM; they are not auto-deleted today. Add a TTL if retention becomes a concern.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Where to look first
|
||
|
|
|
||
|
|
If you are new to the codebase, read these files in order:
|
||
|
|
|
||
|
|
1. **`README.md`** — product positioning and feature scope.
|
||
|
|
2. **`PROJECT.md`** — formal product spec.
|
||
|
|
3. **`app/src/main/java/app/closer/crypto/EncryptionVersion.kt`** — the encryption version contract.
|
||
|
|
4. **`firestore.rules`** — every client write goes through these.
|
||
|
|
5. **`functions/src/index.ts`** — every Cloud Function the project exposes.
|
||
|
|
6. **`functions/src/couples/acceptInviteCallable.ts`** — the most representative callable. Pair creation, rate limiting, E2EE fields, recovery phrase wipe, encryptionVersion derivation all in one file.
|
||
|
|
7. **`functions/src/questions/assignDailyQuestion.ts`** — the daily question scheduled function with the DST-quirky date math.
|
||
|
|
8. **`app/src/main/java/app/closer/crypto/SealedAnswerEncryptor.kt`** and **`SealedRevealManager.kt`** — sealed-answer wire format and reveal flow.
|
||
|
|
9. **`app/src/main/java/app/closer/ui/answers/AnswerRevealViewModel.kt`** — the client-side reveal state machine.
|
||
|
|
10. **`app/src/main/java/app/closer/core/billing/FirestoreEntitlementChecker.kt`** — server-verified entitlement flow.
|
||
|
|
11. **`iphone/Closer/Services/FirestoreService.swift`** — the iOS side of cross-platform data contracts.
|
||
|
|
12. **`iphone/ARCHITECTURE_AUDIT.md`** — generated iOS port blueprint.
|
||
|
|
|
||
|
|
If you are working on a specific area, the relevant section in this manual points to the key files for that area.
|