docs(manual): sync Engineering Reference Manual with post-R30 / v2-functions codebase
- Cloud Functions: rewrite the module tree for the v2 migration (B0-B6d,
2026-07-08); add options.ts (global v2 setGlobalOptions, Cloud Run CPU
quota workaround), log.ts, the shared push/quietHours/idempotency/
pruneTokens/time infra under notifications/, releaseKey/, backup/,
the dates/onDate* triggers, couples/aggregateOutcomes, and the new
sendStreakReminder + sendThinkingOfYouCallable. Replace the single
onGamePartFinished with the four per-game part-finished triggers
shipped in B6c. Note the webhook is not deployed (RevenueCat project
not yet created; export commented out in functions/src/index.ts).
- Daily question lifecycle: replace the 'picks a random' description
with the new server-authoritative, mode-aware, deterministic picker
that mirrors the client's DailyModeResolver; add a Server-authoritative
sub-section with the frozen DOW -> mode map and the daily_fun_mc
exclusion. Note the couple-scan pagination + unseeded-pool skip.
- Billing: add the Purchases.logIn(firebaseAuth.currentUser!!.uid)
identity link (commit b99a8338) and the typed BillingException mapping.
Cross-link to the Webhook reliability section for the not-deployed state.
- iOS: fix the Repository layout iOS Crypto/ block (R24 E2EE code ships
in it; no longer 'intentionally empty'); correct the 'pairing from iOS
fails' claim (works for schemaVersion 2 path; schemaVersion 3 is
infrastructure-gated per IOS_E2EE_STATUS.md); correct the 'iOS couples
have no recovery path' claim (R24 batch 2 added iOS recovery phrase).
- TOC + anchors: add the new sub-anchors; fix three pre-existing broken
anchors (r10, ios-android-sealed-answer-bridge, recovery-phrase-change
desync). 30/30 anchors verified clean with a GFM slug checker.
- New landmines: BANNER-LIFE-001 (R30 game banner lifecycle; the B6c
per-game split means a new game also needs a per-game monitor hook)
and FUNCTIONS-V2-DEPLOY (2nd-gen deploy / CPU-quota / Eventarc
propagation pattern, the 'Changing from an HTTPS function to a
background triggered function' error and the launch-time quota
increase to restore 1 vCPU + concurrency 80).
- Engineering_Reference_Manual_Plan.md: add Phase 2 status entry.
This commit is contained in:
parent
b47302d6b7
commit
213cfddb16
|
|
@ -1,11 +1,30 @@
|
|||
# Engineering Reference Manual Update Plan
|
||||
|
||||
**Status: ✅ COMPLETE** — all 9 batches reviewed, corrected, committed, and pushed to `dev`.
|
||||
**Status: ✅ COMPLETE — v0.2.1 phase (2026-06-28, 9 batches) + ✅ v0.2.x phase (2026-07-08, single comprehensive pass)**
|
||||
|
||||
Goal: bring `docs/Engineering_Reference_Manual.md` into alignment with the current
|
||||
relationship-app codebase. Verify every claim, file path, function name,
|
||||
collection name, and architectural fact. Never assume.
|
||||
|
||||
## Phase 2 (2026-07-08) — single comprehensive pass
|
||||
|
||||
Triggered by the 2026-07-08 Cloud Functions v2 deploy (B0–B6d, Node 22 bump, 5 per-game part-finished triggers) + the R30 games/UX/seed round + the `b99a8338` RevenueCat↔Firebase-Auth identity link. Single comprehensive pass rather than the original 9-batch plan because the changes were concentrated in three areas (Cloud Functions, Billing, Daily question) and the manual had drifted in known directions.
|
||||
|
||||
### What changed (Phase 2)
|
||||
|
||||
- **Cloud Functions** — replaced the stale functions tree with the v2 layout (added `options.ts`, `log.ts`, the `push.ts`/`quietHours.ts`/`idempotency.ts`/`pruneTokens.ts`/`time.ts` shared infra, `releaseKey/`, `backup/`, `dates/onDate*`, `couples/aggregateOutcomes`, `notifications/sendStreakReminder` + `sendThinkingOfYouCallable`). Replaced `onGamePartFinished` (single) with the four per-game part-finished triggers (`onThisOrThatPartFinished`, `onWheelPartFinished`, `onHowWellPartFinished`, `onDesireSyncPartFinished`). Added the v2-migration deploy-failure pattern landmine (`FUNCTIONS-V2-DEPLOY`). Webhook reliability section now states the webhook is **not deployed** (RevenueCat project not yet created).
|
||||
- **Daily question lifecycle** — replaced the "picks a random active free question" description with the server-authoritative, mode-aware, deterministic picker that mirrors the client's `DailyModeResolver`; added a new sub-section `### Server-authoritative mode-aware deterministic selection` with the frozen DOW → mode map; noted the picker is FROZEN against `daily_fun_mc` for HowWell/Desire Sync pool hygiene.
|
||||
- **Billing** — added the `Purchases.logIn(firebaseAuth.currentUser!!.uid)` identity-link flow (commit `b99a8338`) and the `BillingException` typed error mapping; added a "Currently NOT deployed" callout on the webhook section that points to the Webhook reliability deploy steps.
|
||||
- **iOS section** — corrected the Repository layout iOS `Crypto/` block (it is no longer empty; ships the R24 E2EE code) and the "pairing from iOS currently fails" claim (it does not, for the schemaVersion 2 path; schemaVersion 3 is still infrastructure-gated per `IOS_E2EE_STATUS.md`).
|
||||
- **TOC + anchors** — added the new sub-anchors (`server-authoritative-mode-aware-deterministic-selection`, `webhook-reliability`, `schedule`, `server-verified-entitlements`, `webhook`, `premium-gated-features-and-gate-pattern`); fixed three pre-existing broken anchors (`#foreground-game-alert-banner-r10`, `#ios--android-sealed-answer-bridge-wrapreleasekeycallable`, `#recovery-phrase-change-desync--...`).
|
||||
- **New landmines** — `BANNER-LIFE-001` (R30 game banner lifecycle) + `FUNCTIONS-V2-DEPLOY` (2nd-gen deploy / CPU-quota / Eventarc propagation pattern).
|
||||
|
||||
### What was NOT changed (Phase 2 — deliberately)
|
||||
|
||||
- The Android Gradle config drift (`versionCode = 1`, `versionName = "0.1.0"` in `app/build.gradle.kts` while HISTORY.md describes v0.2.1) is already flagged in the manual ("build config has drifted from HISTORY; do not ship a release until they're reconciled"). Bumping the version is a release-readiness task, not a docs task.
|
||||
- The iOS E2EE section (sub-section "iOS E2EE gap (status as of current batch)") was already accurate after the R24 work; left unchanged.
|
||||
- The "Files that must never be committed" gitignore + the iOS CryptoKit guidance section were both already correct.
|
||||
|
||||
## Approach
|
||||
|
||||
- **Batch-based.** Each batch covers one major section or a coherent set of
|
||||
|
|
|
|||
|
|
@ -11,9 +11,9 @@ This is the source of truth for Closer's architecture, security model, data mode
|
|||
- 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 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.
|
||||
- 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.
|
||||
|
||||
---
|
||||
|
|
@ -25,10 +25,16 @@ This is the source of truth for Closer's architecture, security model, data mode
|
|||
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)
|
||||
- [Server-authoritative mode-aware deterministic selection](#server-authoritative-mode-aware-deterministic-selection)
|
||||
6. [Firestore data model](#firestore-data-model)
|
||||
7. [Firestore security rules](#firestore-security-rules)
|
||||
8. [Cloud Functions](#cloud-functions)
|
||||
- [Webhook reliability](#webhook-reliability)
|
||||
- [Schedule](#schedule)
|
||||
9. [Billing](#billing)
|
||||
- [Server-verified entitlements](#server-verified-entitlements)
|
||||
- [Webhook](#webhook)
|
||||
- [Premium-gated features and gate pattern](#premium-gated-features-and-gate-pattern)
|
||||
10. [Notifications](#notifications)
|
||||
- [Game session push semantics](#game-session-push-semantics-idempotent-flag-claim)
|
||||
- [Foreground game-alert banner](#foreground-game-alert-banner-r10)
|
||||
|
|
@ -63,16 +69,16 @@ Closer is a couples relationship app. The product goal is **private, mutual-reve
|
|||
|
||||
### 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)).
|
||||
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.
|
||||
- **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.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -88,7 +94,7 @@ app/src/main/java/app/closer/
|
|||
│ ├── 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)
|
||||
│ ├── 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
|
||||
|
|
@ -124,7 +130,7 @@ app/src/main/java/app/closer/
|
|||
├── paywall/ # Subscription paywall
|
||||
├── play/ # Play hub
|
||||
├── questions/ # Daily question, packs, history
|
||||
├── settings/ # All settings screens (account, privacy, security, subscription, …)
|
||||
├── settings/ # All settings screens (account, privacy, security, subscription, ...)
|
||||
├── theme/ # CloserTheme
|
||||
├── thisorthat/ # This or That game
|
||||
└── wheel/ # Spin the wheel
|
||||
|
|
@ -145,19 +151,21 @@ iphone/
|
|||
├── 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
|
||||
├── 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
|
||||
├── Crypto/ # CryptoKit E2EE parity (R24+) - see iphone/Closer/Crypto/SPEC.md for the wire format
|
||||
│ # AnswerCrypto.swift, CoupleEncryptionManager.swift, CoupleKeyStore.swift, DeviceKeyStatus.swift,
|
||||
│ # FieldEncryptor.swift, Keybox.swift, RecoveryKeyManager.swift, SealedAnswer.swift, Wordlist.swift
|
||||
├── 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
|
||||
├── Navigation/ # ContentView.swift - Root NavigationStack + TabView
|
||||
├── Onboarding/ # Onboarding, login, signup
|
||||
├── Pairing/ # Invite code, partner confirm, recovery
|
||||
├── Home/ # Home dashboard, partner mirror
|
||||
|
|
@ -166,11 +174,11 @@ iphone/
|
|||
├── 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
|
||||
├── 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. Because `createInviteCallable` and `acceptInviteCallable` require E2EE key material, pairing from iOS currently fails. See [iOS E2EE gap](#ios-e2ee-gap-pairing-is-broken-from-ios-today) for the precise scope and risk.
|
||||
The iOS `Crypto/` folder holds the E2EE parity code shipped across R24 batches 1-8 (see `IOS_E2EE_STATUS.md` and `iphone/Closer/Crypto/SPEC.md` for the wire format). Android+iOS pairing now works for the schemaVersion 2 (couple-key) daily-answer path; the schemaVersion 3 sealed-answer path is infrastructure-gated (paired-CI vector run + macOS end-to-end) and the iOS↔iOS path uses the native Path A envelope. See [iOS E2EE gap](#ios-e2ee-gap-status-as-of-current-batch) for the precise status and known gaps.
|
||||
|
||||
`Paywall/` is currently a placeholder; the actual paywall screen is rendered from `Settings/SettingsViews.swift`. A dedicated paywall view is a future cleanup.
|
||||
|
||||
|
|
@ -178,46 +186,75 @@ The iOS `Crypto/` folder is **intentionally empty** today. The Swift port defers
|
|||
|
||||
```text
|
||||
functions/src/
|
||||
├── index.ts # Admin SDK init + exports
|
||||
├── options.ts # v2 setGlobalOptions (region, maxInstances, cpu, concurrency) - imported FIRST
|
||||
├── log.ts # Shared `logger` + `redactToken` (B6d)
|
||||
├── index.ts # Admin SDK init + explicit re-exports (no glob imports)
|
||||
├── billing/
|
||||
│ ├── revenueCatWebhook.ts # HTTPS webhook — Ed25519 signature verify
|
||||
│ ├── revenueCatWebhook.ts # HTTPS webhook - Ed25519 signature verify (NOT exported / not deployed - see Billing)
|
||||
│ ├── entitlementLogic.ts # Idempotent entitlement event handlers
|
||||
│ ├── entitlementLogic.test.ts # Vitest unit tests
|
||||
│ └── syncEntitlement.ts # Callable — forced re-sync from client
|
||||
│ ├── syncEntitlement.ts # Callable - forced re-sync from client
|
||||
│ └── onEntitlementChanged.ts # users/{uid}/entitlements/premium onWrite → partner-facing push
|
||||
├── couples/
|
||||
│ ├── createInviteCallable.ts # Server-side invite creation
|
||||
│ ├── acceptInviteCallable.ts # Code validation, couple creation, rate limit
|
||||
│ ├── acceptInviteCallable.ts # Code validation, couple creation, rate limit, unconditional encryptionVersion = 2
|
||||
│ ├── 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
|
||||
│ ├── scheduledOutcomesReminder.ts # Pub/Sub schedule: 30/60/90 reminders
|
||||
│ ├── aggregateOutcomes.ts # Daily rollup → privacy-safe `aggregate_stats/` (server-only)
|
||||
│ └── aggregateOutcomes.test.ts
|
||||
├── dates/
|
||||
│ └── createDateMatch.ts # Trigger: mutual-love → date match
|
||||
│ ├── createDateMatch.ts # Mutual-love → date match + notifyOnDateMatch
|
||||
│ ├── onDateHistoryCreated.ts # Date history aggregation trigger
|
||||
│ ├── onDateReflectionWritten.ts # Date reflection written trigger
|
||||
│ └── onDateReflectionRevealed.ts # Date reflection revealed trigger
|
||||
├── games/
|
||||
│ └── onGameSessionUpdate.ts # Trigger: game state changes → notify partner
|
||||
│ └── onGameSessionUpdate.ts # Co-located: onGameSessionUpdate + per-game part-finished triggers
|
||||
│ # (onThisOrThatPartFinished, onWheelPartFinished,
|
||||
│ # onHowWellPartFinished, onDesireSyncPartFinished)
|
||||
├── 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
|
||||
│ ├── sendThinkingOfYouCallable.ts # Manual "thinking of you" nudge
|
||||
│ ├── push.ts # ONE canonical FCM sender + getUserTokens() reader (B6d)
|
||||
│ ├── quietHours.ts # `recipientInQuietHours(userData)` - fail-open
|
||||
│ ├── quietHours.test.ts
|
||||
│ ├── idempotency.ts # `claimOnce(markRef)` - gRPC ALREADY_EXISTS dedupe
|
||||
│ ├── idempotency.test.ts
|
||||
│ ├── pruneTokens.ts # `pruneDeadTokens()` - used by push.ts
|
||||
│ ├── pruneTokens.test.ts
|
||||
│ ├── time.ts # `chicagoDateKey`, `toMillis` - shared by scheduled jobs
|
||||
│ ├── push.test.ts
|
||||
│ ├── dailyQuestionReminder.ts # sendDailyQuestionProactiveReminder (4 PM CT; deployed, idempotent)
|
||||
│ ├── reengagement.ts # sendReengagementReminder (noon CT; couples 3-10 days quiet)
|
||||
│ ├── streakReminder.ts # sendStreakReminder (7 PM CT; couples w/ active streak + no shared action today)
|
||||
│ └── gameRetention.ts # sendChallengeDayReminders + unlockDueMemoryCapsules
|
||||
├── questions/
|
||||
│ ├── assignDailyQuestion.ts # Pub/Sub schedule + manual callable
|
||||
│ ├── assignDailyQuestion.ts # Pub/Sub schedule + manual callable (server-authoritative, mode-aware, deterministic)
|
||||
│ ├── assignDailyQuestion.test.ts
|
||||
│ ├── onAnswerWritten.ts # Trigger: notify partner on answer
|
||||
│ ├── onAnswerRevealed.ts # Trigger: notify partner when answers are opened
|
||||
│ └── onMessageWritten.ts # Trigger: thread messages
|
||||
├── releaseKey/
|
||||
│ └── wrapReleaseKeyCallable.ts # iOS→Android sealed-answer Tink keybox bridge
|
||||
├── backup/
|
||||
│ └── onRestoreRequested.ts # onRestoreRequested + onRestoreFulfilled (couple-key ECIES partner-assist)
|
||||
│ └── onRestoreRequested.test.ts
|
||||
├── security/
|
||||
│ └── checkDeviceIntegrity.ts # Play Integrity verdict verification
|
||||
└── users/
|
||||
└── onUserDelete.ts # Auth user deletion cascade
|
||||
└── onUserDelete.ts # Auth user deletion cascade (intentionally v1 - gen 2 has no auth.user().onDelete)
|
||||
```
|
||||
|
||||
**v2 migration (B0-B6d, 2026-07-08)**: every function except `onUserDelete` runs as **2nd gen** in `closer-app-22014` under a single shared set of global options (see `functions/src/options.ts`): `region: us-central1` (pinned to match `FirebaseFunctions.getInstance()` on Android; do not change without pinning the client), `maxInstances: 5` (runaway-bill guardrail + Cloud Run CPU-quota constraint), `cpu: 'gcf_gen1'` (gen1 fractional tiers, ~1⁄6 vCPU - chosen because the project's default regional Cloud Run CPU quota could not fit ~35 services at v2's default 1 vCPU/instance; the deploy container healthchecks failed with *"Quota exceeded for total allowable CPU per project per region"*), `concurrency: 1` (required when `cpu < 1`). At launch: request the Cloud Run CPU quota increase, then drop `cpu`/`concurrency` to restore 1 vCPU + concurrency 80. `onUserDelete` is intentionally v1 because gen 2 has no `auth.user().onDelete`; pin its runtime in lockstep with the v2 ones.
|
||||
|
||||
**Shared infrastructure (B6d)**: B6d collapsed ~10 copies of "read the user's tokens" and ~19 copies of "send to each token, log failures, prune the dead ones" into `notifications/push.ts` (`getUserTokens`, `sendPushToUser`, `batchResponseToResults`, `redactToken`) and `notifications/idempotency.ts` (`claimOnce` - gRPC `ALREADY_EXISTS` (6) dedupe, fail-OPEN on unexpected errors). `notifications/quietHours.ts` is the single server-side quiet-hour reader; `notifications/time.ts` is the shared date-key helper. Per-function log lines are routed through `log.ts` (re-exports `firebase-functions/logger` for v2 trace/execution context). The B6d split of `onGameSessionUpdate` + the 4 per-game part-finished triggers is a structural change - see [Game session push semantics](#game-session-push-semantics-idempotent-flag-claim).
|
||||
|
||||
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.
|
||||
- The `reminders.ts` callable `sendDailyQuestionReminder` was the original placeholder - that file is gone. The live scheduled nudge is the **2nd-gen Pub/Sub** function `sendDailyQuestionProactiveReminder` in `dailyQuestionReminder.ts`. It 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
|
||||
|
||||
|
|
@ -237,8 +274,8 @@ docs/ # This manual, QA notes, release prep, store assets
|
|||
|
||||
Firebase Auth supports two sign-in paths:
|
||||
|
||||
1. **Email/password** — standard sign-up and login.
|
||||
2. **Google Sign-In** — via **Credential Manager** on Android (`androidx.credentials` + `GetSignInWithGoogleOption`, extracting a `GoogleIdTokenCredential` and forwarding the `idToken` to `signInWithGoogle`), and the Google Sign-In SDK on iOS.
|
||||
1. **Email/password** - standard sign-up and login.
|
||||
2. **Google Sign-In** - initiated by the UI layer via **Credential Manager** (`androidx.credentials` + `GetSignInWithGoogleOption` → `GoogleIdTokenCredential`), which then forwards the extracted `idToken` to `FirebaseAuthDataSource.signInWithGoogle(idToken)`. That data-source method calls `auth.signInWithCredential(GoogleAuthProvider.getCredential(idToken))` (the standard Firebase path) - the `idToken` is a Google-issued JWT; the data source never holds a Google-Sign-In client id. iOS uses the Google Sign-In SDK on top of the same Firebase Auth APIs through `AuthService.swift`.
|
||||
|
||||
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.
|
||||
|
||||
|
|
@ -249,9 +286,9 @@ The pairing flow is server-mediated because 6-character codes are enumerable. Th
|
|||
```text
|
||||
Inviter (Android or iOS)
|
||||
1. Generate couple keyset + recovery phrase (CoupleEncryptionManager on Android;
|
||||
iOS skips step 1 — see iOS E2EE gap).
|
||||
iOS skips step 1 - see iOS E2EE gap).
|
||||
2. Generate 6-char code.
|
||||
3. Encrypt phrase with code (RecoveryKeyManager.encryptPhraseWithCode) — Android only.
|
||||
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.
|
||||
|
|
@ -261,8 +298,8 @@ Acceptor (any platform)
|
|||
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.
|
||||
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).
|
||||
```
|
||||
|
||||
|
|
@ -307,18 +344,18 @@ The recovery phrase is the only human-readable secret in the system. It is never
|
|||
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. **Both partners hold the same phrase** (the acceptor stores it in step 3), so either partner can re-reveal it in Settings → Security and supply it to the other — the partner is a built-in backup, and the new-device Recovery screen guides the user to "ask your partner." Changing the recovery phrase re-wraps the locally-held keyset and uploads a new `wrappedCoupleKey` to Firestore — **but it desyncs the partner's stored copy**; see the landmine [Recovery-phrase change desync](#recovery-phrase-change-desync--changing-the-couple-phrase-silently-breaks-the-partners-ask-your-partner-recovery). The change-phrase path is currently UNWIRED.
|
||||
5. The recovery phrase can be shown in settings and used to recover the couple key on a new device. **Both partners hold the same phrase** (the acceptor stores it in step 3), so either partner can re-reveal it in Settings → Security and supply it to the other - the partner is a built-in backup, and the new-device Recovery screen guides the user to "ask your partner." Changing the recovery phrase re-wraps the locally-held keyset and uploads a new `wrappedCoupleKey` to Firestore - **but it desyncs the partner's stored copy**; see the landmine [Recovery-phrase change desync](#recovery-phrase-change-desync---changing-the-couple-phrase-silently-breaks-the-partners-ask-your-partner-recovery). The change-phrase path is currently UNWIRED.
|
||||
|
||||
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.
|
||||
iOS does generate and store a recovery phrase in the current build (R24 batch 2+); iOS couples now have the same partner-assisted recovery path as Android. See [iOS E2EE gap](#ios-e2ee-gap-status-as-of-current-batch) for the still-pending schemaVersion 3 interop.
|
||||
|
||||
### 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.
|
||||
- `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
|
||||
|
||||
|
|
@ -335,13 +372,13 @@ iOS does not generate or store a recovery phrase in the current build. iOS coupl
|
|||
|
||||
| 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). |
|
||||
| 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-status-as-of-current-batch). |
|
||||
| 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.
|
||||
**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
|
||||
|
||||
|
|
@ -356,7 +393,7 @@ The couple keyset is a Tink AES-256-GCM keyset generated once per couple.
|
|||
- 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.
|
||||
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
|
||||
|
||||
|
|
@ -370,7 +407,7 @@ See [Recovery phrase flow](#recovery-phrase-flow). The recovery phrase is the on
|
|||
|
||||
### 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.
|
||||
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:
|
||||
|
||||
|
|
@ -418,7 +455,7 @@ iOS does not implement Tink's `ECIES_P256_HKDF_HMAC_SHA256_AES128_GCM` locally,
|
|||
|
||||
### 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.
|
||||
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}$
|
||||
|
|
@ -431,17 +468,17 @@ Bumping the version prefix (e.g. `sealed:v1:` → `sealed:v2:`) is a wire-format
|
|||
|
||||
### 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.
|
||||
- `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.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -452,9 +489,9 @@ Bumping the version prefix (e.g. `sealed:v1:` → `sealed:v2:`) is a wire-format
|
|||
`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.
|
||||
- **What it does**: picks one question per couple from the active free pool (the whole pool is tiny, ~75 rows, so the function fetches once and filters in memory) using a **server-authoritative, mode-aware, deterministic** selection (see [Server-authoritative selection](#server-authoritative-mode-aware-deterministic-selection)). The selected `questionId` is written into a `daily_question/{YYYY-MM-DD}` 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.
|
||||
- **Idempotency**: uses `docRef.create({...})` and catches `ALREADY_EXISTS` so a re-run on the same day is a no-op. The couple scan is **paginated** (300 couples/page, ordered by `__name__`) so deploys and re-runs never load every couple into memory.
|
||||
- **Document shape**:
|
||||
```text
|
||||
couples/{coupleId}/daily_question/{date}
|
||||
|
|
@ -464,9 +501,13 @@ Bumping the version prefix (e.g. `sealed:v1:` → `sealed:v2:`) is a wire-format
|
|||
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).
|
||||
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). The callable throws `HttpsError('internal', 'No active questions available.')` when the pool is empty. If the scheduled run finds the pool empty it logs `[assignDailyQuestion] questions pool not seeded -- skipping assignment` and writes nothing -- so an unseeded pool **silently does nothing** (intentional: a transient skip beats a half-couple; the cron retries the next day).
|
||||
|
||||
### Date math — known DST bug
|
||||
### Server-authoritative mode-aware deterministic selection
|
||||
|
||||
The picker in `functions/src/questions/assignDailyQuestion.ts` mirrors the Android client's `DailyModeResolver` (DOW_DEFAULTS table: Monday `mode_soft_monday`, Tuesday `mode_snack_mission`, ..., Sunday `mode_tiny_date_night`). The same date -> the same couple -> the same question, with no randomness and no client/server split. This **kills the DQ-MISMATCH class** of bugs (one partner sees a different question than the other) and removes cross-timezone partner splits at the root. The frozen DOW -> mode map is unit-tested; if you add a weekday or change a mode tag, update both the client resolver and the server picker in the same change. The picker is also FROZEN against the daily question's `daily_fun_mc` content: `daily_fun_mc` rows are filtered out of the HowWell prediction pool and Desire Sync pool (server-side) to match the client-side guards shipped in R30.
|
||||
|
||||
### 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:
|
||||
|
||||
|
|
@ -482,7 +523,7 @@ Answers are written by the client under `couples/{coupleId}/daily_question/{date
|
|||
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.
|
||||
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
|
||||
|
||||
|
|
@ -497,13 +538,13 @@ Token lookup reads both a legacy `fcmToken` field on the user doc and a dedicate
|
|||
|
||||
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.
|
||||
**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:
|
||||
**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.
|
||||
|
|
@ -517,13 +558,13 @@ The reveal path differs by schema version:
|
|||
|
||||
**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.
|
||||
**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.
|
||||
- `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).
|
||||
|
||||
---
|
||||
|
|
@ -723,27 +764,27 @@ isUpdatingCoupleRhythm() only streakCount/lastAnsweredAt
|
|||
|
||||
### Per-collection enforcement
|
||||
|
||||
**`users/{uid}`** — owner can read/create/update their own doc. Root-level premium state is **not** stored here; the client-writable `plan` field is informational only, and authoritative premium state lives in the server-only `entitlements/premium` subdoc. `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). Quiet-hours fields (`quietHoursEnabled`, `quietHoursStartMinutes`, `quietHoursEndMinutes`, `timezone`) and notification preferences (`notifPartnerAnswered`, `notifChatMessage`) are client-writable so Cloud Functions can honor them server-side.
|
||||
**`users/{uid}`** - owner can read/create/update their own doc. Root-level premium state is **not** stored here; the client-writable `plan` field is informational only, and authoritative premium state lives in the server-only `entitlements/premium` subdoc. `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). Quiet-hours fields (`quietHoursEnabled`, `quietHoursStartMinutes`, `quietHoursEndMinutes`, `timezone`) and notification preferences (`notifPartnerAnswered`, `notifChatMessage`) are client-writable so Cloud Functions can honor them server-side.
|
||||
|
||||
**`date_ideas/`** — read-only for any signed-in user; writes are admin-only.
|
||||
**`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.
|
||||
**`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. Direct client `create` is possible only if the document exactly matches the server-created shape (all required fields including `encryptionVersion == 2` and valid E2EE key material); in practice `acceptInviteCallable` creates the doc. `update` is limited to the rhythm/recovery-wrap field sets via `isUpdatingCoupleRhythm()` or `isUpdatingRecoveryWrap()`; everything else is server-only. `delete` is denied for clients. Field-level immutability helpers (`isUpdatingCoupleRhythm`, `isUpdatingRecoveryWrap`) define what each update path may touch. There is no encryption-migration path in the current rules.
|
||||
**`couples/{coupleId}`** - only the two members may read. Direct client `create` is possible only if the document exactly matches the server-created shape (all required fields including `encryptionVersion == 2` and valid E2EE key material); in practice `acceptInviteCallable` creates the doc. `update` is limited to the rhythm/recovery-wrap field sets via `isUpdatingCoupleRhythm()` or `isUpdatingRecoveryWrap()`; everything else is server-only. `delete` is denied for clients. Field-level immutability helpers (`isUpdatingCoupleRhythm`, `isUpdatingRecoveryWrap`) define what each update path may touch. There is no encryption-migration path in the current rules.
|
||||
|
||||
**`couples/{coupleId}/daily_question/...`** — server-only writes. Daily-question assignment and answer-related subcollections are tightly constrained.
|
||||
**`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}`** - 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}/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.
|
||||
**`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.
|
||||
**`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.
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -755,49 +796,53 @@ 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.
|
||||
- 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` | Path-based; bypass callable auth. Webhook requires Ed25519 signature verification. The unauthenticated `health` endpoint was removed in a security review. |
|
||||
| HTTPS onCall | `createInviteCallable`, `acceptInviteCallable`, `syncEntitlement`, `sendDailyQuestionReminder`, `sendPartnerAnsweredNotification`, `sendGentleReminderCallable`, `submitOutcomeCallable`, `leaveCoupleCallable`, `checkDeviceIntegrity`, `assignDailyQuestionCallable`, `wrapReleaseKeyCallable` | Caller must be authenticated. Errors throw `HttpsError`. |
|
||||
| Firestore onCreate | `onAnswerWritten`, `onAnswerRevealed`, `onMessageWritten`, `onCoupleLeave`, `onUserDelete`, `onGameSessionUpdate`, `onGamePartFinished`, `notifyOnDateMatch` | Event-driven; best-effort. |
|
||||
| HTTPS onRequest | (none deployed) | The old `revenueCatWebhook` onRequest handler is in source but **not exported from `index.ts`** - see [Billing](#billing) for the enable steps. The unauthenticated `health` endpoint was removed in an earlier security review. |
|
||||
| HTTPS onCall | `createInviteCallable`, `acceptInviteCallable`, `syncEntitlement`, `submitOutcomeCallable`, `leaveCoupleCallable`, `checkDeviceIntegrity`, `assignDailyQuestionCallable`, `wrapReleaseKeyCallable`, `sendGentleReminderCallable`, `sendThinkingOfYouCallable` | Caller must be authenticated. Errors throw `HttpsError`. All v2 → `us-central1` via global `options.ts`. |
|
||||
| Firestore onCreate / onDocumentWritten | `onAnswerWritten`, `onAnswerRevealed`, `onMessageWritten`, `onCoupleLeave`, `onUserDelete` (v1), `onGameSessionUpdate`, `onThisOrThatPartFinished`, `onWheelPartFinished`, `onHowWellPartFinished`, `onDesireSyncPartFinished`, `notifyOnDateMatch` (under `createDateMatch.ts`), `onDateReflectionWritten`, `onDateReflectionRevealed`, `onDateHistoryCreated`, `onRestoreRequested`, `onRestoreFulfilled` | Event-driven; best-effort. `onUserDelete` is intentionally v1 (gen 2 has no `auth.user().onDelete`). |
|
||||
| Firestore onUpdate | `onEntitlementChanged` | Fires when `users/{uid}/entitlements/premium` changes; sends a partner-facing FCM when the user gains premium. It does NOT mirror premium state to the user root doc. |
|
||||
| Auth onDelete | `onUserDelete` | Auth user deletion cascade. |
|
||||
| Pub/Sub schedule | `assignDailyQuestion`, `scheduledOutcomesReminder`, `sendDailyQuestionProactiveReminder`, `sendReengagementReminder`, `unlockDueMemoryCapsules`, `sendChallengeDayReminders` | Cron expression; timezone is `America/Chicago` only where explicitly set. |
|
||||
| Auth onDelete | `onUserDelete` | Auth user deletion cascade (v1). |
|
||||
| Pub/Sub schedule | `assignDailyQuestion`, `scheduledOutcomesReminder`, `sendDailyQuestionProactiveReminder`, `sendReengagementReminder`, `sendStreakReminder`, `unlockDueMemoryCapsules`, `sendChallengeDayReminders`, `aggregateOutcomeStats` | Cron expression; timezone is `America/Chicago` only where explicitly set (all the daily-cron senders do). |
|
||||
|
||||
### Per-module responsibilities
|
||||
|
||||
- **billing** — RevenueCat webhook, entitlement event handlers (`onEntitlementChanged`), forced re-sync callable (`syncEntitlement`). Entitlement writes are idempotent (write the same Firestore doc and use `entitlement_events/` as a dedup marker).
|
||||
- **couples** — invite create/accept/leave (`createInviteCallable`, `acceptInviteCallable`, `leaveCoupleCallable`, `onCoupleLeave`), outcome submission (`submitOutcomeCallable`), scheduled 30/60/90 reminders (`scheduledOutcomesReminder`).
|
||||
- **dates** — mutual-love trigger creates a date match document and notifies the couple (`notifyOnDateMatch`).
|
||||
- **games** — game session updates notify the partner and append to `notification_queue` (`onGameSessionUpdate`, `onGamePartFinished`).
|
||||
- **notifications** — daily question proactive reminder (`sendDailyQuestionProactiveReminder`), re-engagement (`sendReengagementReminder`), gentle reminder callable (`sendGentleReminderCallable`), challenge day reminders (`sendChallengeDayReminders`), capsule unlock (`unlockDueMemoryCapsules`), placeholder callables (`sendDailyQuestionReminder`, `sendPartnerAnsweredNotification`).
|
||||
- **questions** — daily question assignment (`assignDailyQuestion`, `assignDailyQuestionCallable`), answer write trigger (`onAnswerWritten`), reveal trigger (`onAnswerRevealed`), thread message trigger (`onMessageWritten`).
|
||||
- **security** — Play Integrity verdict verification (`checkDeviceIntegrity`).
|
||||
- **users** — Auth user deletion cascade (`onUserDelete`).
|
||||
- **billing** - RevenueCat webhook (source-only; not exported/deployed), entitlement event handlers (`onEntitlementChanged`), forced re-sync callable (`syncEntitlement`). Entitlement writes are idempotent (write the same Firestore doc and use `entitlement_events/` as a dedup marker).
|
||||
- **couples** - invite create/accept/leave (`createInviteCallable`, `acceptInviteCallable`, `leaveCoupleCallable`, `onCoupleLeave`), outcome submission (`submitOutcomeCallable`), scheduled 30/60/90 reminders (`scheduledOutcomesReminder`), privacy-safe aggregate rollup (`aggregateOutcomeStats`).
|
||||
- **dates** - mutual-love trigger creates a date match document and notifies the couple (`notifyOnDateMatch` under `createDateMatch.ts`); date reflection write/reveal triggers and a history-aggregation trigger.
|
||||
- **games** - game session updates notify the partner and append to `notification_queue` (`onGameSessionUpdate`); per-game part-finished triggers fire when one partner finishes an async game (`onThisOrThatPartFinished` / `onWheelPartFinished` / `onHowWellPartFinished` / `onDesireSyncPartFinished`).
|
||||
- **notifications** - daily question proactive reminder (`sendDailyQuestionProactiveReminder`), re-engagement (`sendReengagementReminder`), streak reminder (`sendStreakReminder`), gentle reminder callable (`sendGentleReminderCallable`), thinking-of-you callable (`sendThinkingOfYouCallable`), challenge day reminders (`sendChallengeDayReminders`), capsule unlock (`unlockDueMemoryCapsules`). Shared infrastructure: `push.ts` (canonical FCM send + token reader), `quietHours.ts`, `idempotency.ts`, `pruneTokens.ts`, `time.ts`, `log.ts` (B6d).
|
||||
- **questions** - daily question assignment (`assignDailyQuestion`, `assignDailyQuestionCallable`), answer write trigger (`onAnswerWritten`), reveal trigger (`onAnswerRevealed`), thread message trigger (`onMessageWritten`).
|
||||
- **releaseKey** - iOS→Android sealed-answer bridge (`wrapReleaseKeyCallable`); Tink-format `keybox:v1:` produced server-side.
|
||||
- **backup** - partner-assist restore request flow (`onRestoreRequested`, `onRestoreFulfilled` under `onRestoreRequested.ts`); see the R24-BACKUP landmine.
|
||||
- **security** - Play Integrity verdict verification (`checkDeviceIntegrity`).
|
||||
- **users** - Auth user deletion cascade (`onUserDelete`, v1).
|
||||
|
||||
### Webhook reliability
|
||||
|
||||
`revenueCatWebhook` processes the entitlement write (`applyEntitlementEvent`) **before** returning the HTTP 200 acknowledgement. If processing fails, the function returns HTTP 500 so RevenueCat will retry. This changed from an earlier implementation that acked 200 immediately and silently dropped failures. `applyEntitlementEvent` is idempotent, so retries are safe. `entitlement_events/{eventId}` remains the idempotency marker.
|
||||
|
||||
The webhook handler does not currently use a dead-letter queue. **Risk**: repeated 500s could still lose events if RevenueCat gives up. The mitigation today is the 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.
|
||||
**The webhook handler is currently NOT exported from `index.ts` and is therefore not deployed.** RevenueCat has no project yet, so exporting it would force a `REVENUECAT_SIGNING_KEY` Secret Manager entry to validate codebase-wide (a missing secret fails *every* function's deploy). To enable: create the RevenueCat project, seed the real Ed25519 public key (`firebase functions:secrets:set REVENUECAT_SIGNING_KEY`), uncomment the export, delete the old dormant v1 webhook (same-name v1->v2 is blocked in-place), and deploy. See `functions/src/index.ts` for the precise comments. The webhook handler does not currently use a dead-letter queue. **Risk**: repeated 500s could still lose events if RevenueCat gives up. The mitigation today is the 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)
|
||||
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 (default UTC)
|
||||
sendDailyQuestionProactiveReminder 0 16 * * * America/Chicago (4 PM; 2h before expiry)
|
||||
sendReengagementReminder 0 12 * * * America/Chicago (noon; targets couples 3–10 days quiet)
|
||||
sendReengagementReminder 0 12 * * * America/Chicago (noon; targets couples 3-10 days quiet)
|
||||
sendStreakReminder 0 19 * * * America/Chicago (7 PM; couples w/ active streak + no shared action today)
|
||||
unlockDueMemoryCapsules every 1 hours (default UTC)
|
||||
sendChallengeDayReminders every 24 hours (default UTC)
|
||||
aggregateOutcomeStats every 24 hours (default UTC)
|
||||
```
|
||||
|
||||
`scheduledOutcomesReminder` currently scans all couples with a 200-doc limit and no pagination. It will need to shard or paginate as the user base grows. `sendDailyQuestionProactiveReminder` is the only deployed scheduled reminder for unanswered daily questions — the placeholder `sendDailyQuestionReminder` callable in `reminders.ts` is not in the live reminder path.
|
||||
`scheduledOutcomesReminder` currently scans all couples with a 200-doc limit and no pagination. It will need to shard or paginate as the user base grows. `sendDailyQuestionProactiveReminder` is the only deployed scheduled reminder for unanswered daily questions - the placeholder `sendDailyQuestionReminder` callable in `reminders.ts` is not in the live reminder path.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -807,10 +852,12 @@ sendChallengeDayReminders every 24 hours (default
|
|||
|
||||
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.
|
||||
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.
|
||||
|
||||
**RevenueCat ↔ Firebase Auth identity (commit `b99a8338`)**. The Android billing repository calls `Purchases.logIn(firebaseAuth.currentUser!!.uid)` after every successful Firebase Auth sign-in / sign-up and `Purchases.logOut()` on sign-out, so a subscription purchased on one device follows the user across reinstalls and never gets orphaned to an anonymous RevenueCat identity. The repository also surfaces `PurchasesErrorCode` -> user-facing `BillingException` types so the paywall can show precise errors (`PurchaseInvalidError`, `NetworkError`, etc.) instead of a generic "Something went wrong" toast. The QA flow (Sam premium = ON/OFF) was updated to test the identity-persistence path: sign out -> sign back in -> entitlement must still be observed.
|
||||
|
||||
### Server-verified entitlements
|
||||
|
||||
The Android `FirestoreEntitlementChecker` is the source of truth for premium state. It:
|
||||
|
|
@ -820,7 +867,7 @@ The Android `FirestoreEntitlementChecker` is the source of truth for premium sta
|
|||
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 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 Android app uses both `EntitlementChecker` (per-user server-verified premium) and `CouplePremiumChecker` (couple-shared premium: true if either partner has premium). `CouplePremiumChecker` is the drop-in used for couple-wide gates like chat media, shared games, and date features so a single subscription covers both partners.
|
||||
|
||||
|
|
@ -844,6 +891,8 @@ Callers collect `isPremium()` reactively rather than caching a one-time snapshot
|
|||
- **Flow**: `applyEntitlementEvent(event)` runs **before** the HTTP 200 acknowledgement. If it throws, the function returns HTTP 500 so RevenueCat retries. The function is idempotent, so retries are safe.
|
||||
- **Idempotency**: `entitlement_events/{eventId}` records processed events. Re-delivered events are dropped.
|
||||
|
||||
**Currently NOT deployed** -- the export is commented out in `functions/src/index.ts`. See [Webhook reliability](#webhook-reliability) for the enable steps.
|
||||
|
||||
### 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.
|
||||
|
|
@ -892,23 +941,15 @@ Both clients use the Firebase Messaging SDK. Android uses FirebaseMessagingServi
|
|||
|
||||
### 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:
|
||||
`sendDailyQuestionProactiveReminder` (in `functions/src/notifications/dailyQuestionReminder.ts`) is the live deployed 2nd-gen Pub/Sub scheduler -- see [Schedule](#schedule) for its 4 PM CT cron. It also lives next to a callable `sendDailyQuestionReminder` in `reminders.ts` which is now a thin placeholder wrapper (it just writes a `notification_queue` record and returns; the real push goes through `push.ts`).
|
||||
|
||||
- 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.
|
||||
> The "reminders.ts" placeholder note in the v0.2.1 manual is now mostly historical -- the live scheduled reminder is `sendDailyQuestionProactiveReminder` and the callables in `reminders.ts` are thin wrappers. The real cross-cutting reader is `notifications/push.ts` and the canonical dedupe is `notifications/idempotency.ts` -- every notification sender routes FCM through `sendPushToUser(uid, content, opts)` and any per-event dedupe marker through `claimOnce(markRef)`; this collapses ~10 token readers and ~19 senders into one unit-tested module.
|
||||
|
||||
### 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
|
||||
### Gentle reminders, thinking-of-you, and challenges
|
||||
|
||||
`sendGentleReminderCallable` is a real, rate-limited function for nudges. It enforces two limits:
|
||||
|
||||
|
|
@ -917,8 +958,12 @@ When implementing real FCM delivery, consume the queue in this order: read unpro
|
|||
|
||||
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.
|
||||
|
||||
`sendThinkingOfYouCallable` is the partner-facing "thinking of you" nudge (deployed 2026-07-08 with the functions v2 round). It is auth-gated, rate-limited per-user, and writes the FCM through `push.ts`; the Android client maps the `thinking_of_you` payload key to the in-app banner via `PartnerNotificationManager` (no-op on the live Android side today -- waiting on the iOS-initiated send path).
|
||||
|
||||
`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.
|
||||
|
||||
`sendStreakReminder` (in `notifications/streakReminder.ts`) is the 7 PM CT "don't lose your streak" nudge for couples with `streakCount > 0` who have not recorded a shared action today. It is deduped per local day via a transactional `couples/{id}/streak_reminders/{dateKey}` marker, gated on the per-user `notifStreakReminder` preference and the recipient's quiet-hours window. Known limitation: the "today" boundary uses America/Chicago (the cron's timezone) rather than each couple's local day -- quiet-hours suppression keeps a mistimed fire from landing at a bad local hour.
|
||||
|
||||
**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
|
||||
|
|
@ -927,13 +972,22 @@ The notification is both an FCM push (for the system tray) and an entry in `user
|
|||
|
||||
### 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.
|
||||
The Cloud Function `functions/src/games/onGameSessionUpdate.ts` is the hub for every partner-facing push derived from a game session. 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).
|
||||
B6c split the same file into **four per-game part-finished triggers** plus the main `onGameSessionUpdate`:
|
||||
|
||||
- `onThisOrThatPartFinished` (`couples/{id}/this_or_that/{sid}`)
|
||||
- `onWheelPartFinished` (`couples/{id}/wheel/{sid}`)
|
||||
- `onHowWellPartFinished` (`couples/{id}/how_well/{sid}`)
|
||||
- `onDesireSyncPartFinished` (`couples/{id}/desire_sync/{sid}`)
|
||||
|
||||
Each fires when its session's `answers` subcollection has exactly one key (one partner finished, the other hasn't) and idempotently claims `partFinishNotifiedAt` on the session doc before sending `partner_completed_part` to the other member. Replacing the old single "if both answers exist, notify finished" pattern. See E-GAME-003 below.
|
||||
|
||||
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'`, `'join'`, `'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.
|
||||
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).
|
||||
|
||||
|
|
@ -948,21 +1002,21 @@ Key Android files: `app/src/main/java/app/closer/notifications/GamePromptControl
|
|||
| `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. |
|
||||
| `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) |
|
||||
| `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.
|
||||
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.
|
||||
|
||||
|
|
@ -983,7 +1037,7 @@ When the app is foreground, the banner takes precedence over deep-link (the user
|
|||
|
||||
### 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.
|
||||
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
|
||||
|
||||
|
|
@ -1012,7 +1066,7 @@ The iOS port now implements the strict-E2EE pairing path:
|
|||
The iOS CryptoKit implementation follows these rules of parity with Android:
|
||||
|
||||
- Use CryptoKit's `AES.GCM` for symmetric encryption. AAD binding must match Android exactly.
|
||||
- Use `P256.KeyAgreement` + `HKDF` + `AES.GCM` for the iOS-native Path A ECIES envelope. Tink's `ECIES_P256_HKDF_HMAC_SHA128_GCM` is not bit-compatible with raw CryptoKit, so iOS→Android sealed-answer key release goes through the server-side `wrapReleaseKeyCallable` (see [iOS → Android sealed-answer bridge](#ios--android-sealed-answer-bridge-wrapreleasekeycallable)). iOS↔iOS releases use the native Path A envelope.
|
||||
- Use `P256.KeyAgreement` + `HKDF` + `AES.GCM` for the iOS-native Path A ECIES envelope. Tink's `ECIES_P256_HKDF_HMAC_SHA128_GCM` is not bit-compatible with raw CryptoKit, so iOS→Android sealed-answer key release goes through the server-side `wrapReleaseKeyCallable` (see [iOS → Android sealed-answer bridge](#ios-android-sealed-answer-bridge-wrapreleasekeycallable)). iOS↔iOS releases use the native Path A envelope.
|
||||
- Use `SecItemAdd` / `SecItemCopyMatching` for keychain storage. Store the couple key as a generic password item with `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly` and `kSecAttrSynchronizable=false` so it is device-bound and not included in iCloud backup.
|
||||
- For Argon2id, use `swift-sodium` (libsodium) rather than an unaudited pure-Swift implementation. Verify byte output against the Android BouncyCastle reference before shipping.
|
||||
|
||||
|
|
@ -1027,18 +1081,18 @@ The iOS CryptoKit implementation follows these rules of parity with Android:
|
|||
- **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.
|
||||
- **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.
|
||||
`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.
|
||||
- `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
|
||||
|
||||
|
|
@ -1057,7 +1111,7 @@ The iOS CryptoKit implementation follows these rules of parity with Android:
|
|||
|
||||
### ProGuard
|
||||
|
||||
`app/proguard-rules.pro` keeps Firebase, Hilt, Room, domain models, RevenueCat, Kotlin metadata, and BuildConfig. Release builds run `minifyEnabled = true` and `shrinkResources = true`. Tink is **not** explicitly kept in the current rules; add `-keep` rules if reflection-based Tink paths start breaking in release builds. Always smoke-test a release build before publishing — ProGuard rules for new libraries are easy to forget.
|
||||
`app/proguard-rules.pro` keeps Firebase, Hilt, Room, domain models, RevenueCat, Kotlin metadata, and BuildConfig. Release builds run `minifyEnabled = true` and `shrinkResources = true`. Tink is **not** explicitly kept in the current rules; add `-keep` rules if reflection-based Tink paths start breaking in release builds. Always smoke-test a release build before publishing - ProGuard rules for new libraries are easy to forget.
|
||||
|
||||
### Common commands
|
||||
|
||||
|
|
@ -1100,7 +1154,7 @@ firebase deploy --only functions
|
|||
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
|
||||
# Private project docs (agent-only, never commit) - see .gitignore
|
||||
FUTURE.md # uppercase; legacy name in gitignore
|
||||
HISTORY.md
|
||||
PROJECT.md
|
||||
|
|
@ -1114,7 +1168,7 @@ SCRIPTS.md
|
|||
```
|
||||
|
||||
> **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. **(2026-06-28: the legacy uppercase
|
||||
`FUTURE.md` — a stale, untracked duplicate backlog — was consolidated into the tracked `Future.md` and removed. The
|
||||
`FUTURE.md` - a stale, untracked duplicate backlog - was consolidated into the tracked `Future.md` and removed. The
|
||||
`.gitignore` entry is kept so any accidentally-recreated uppercase copy stays ignored, but the one canonical backlog is
|
||||
`Future.md`.)**
|
||||
|
||||
|
|
@ -1161,46 +1215,46 @@ SCRIPTS.md
|
|||
|
||||
## 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.
|
||||
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.
|
||||
|
||||
### INVITE-DEEPLINK — join links resolve through MainActivity, not NavHost navDeepLink (onboarding gate)
|
||||
### INVITE-DEEPLINK - join links resolve through MainActivity, not NavHost navDeepLink (onboarding gate)
|
||||
**What it is**: `https://closer.app/join/{CODE}` (autoVerify App Link) + `closer://closer.app/join/{CODE}`
|
||||
(scheme fallback). `JoinLink.parseCode()` (pure, JVM-tested) extracts + validates the code;
|
||||
`MainActivity.deepLinkRouteFromIntent` stashes it in `PendingJoinCodeStore` (survives sign-up process
|
||||
death) and returns the `accept_invite?code=…` route, driven by the existing `pendingDeepLink` machinery.
|
||||
death) and returns the `accept_invite?code=...` route, driven by the existing `pendingDeepLink` machinery.
|
||||
**Re-introduction hazards**:
|
||||
- **Do NOT add a `navDeepLink` for the join URI on the `accept_invite` composable.** The whole point of
|
||||
routing through MainActivity is that `pendingDeepLink` waits until the user is past onboarding
|
||||
(`currentRoute !in entryRoutes`). A NavHost navDeepLink would let the NavController jump to the pairing
|
||||
screen while the user is still signed out / mid-signup — the destination can't load and the tap appears
|
||||
screen while the user is still signed out / mid-signup - the destination can't load and the tap appears
|
||||
to do nothing.
|
||||
- `deepLinkRouteFromIntent` must keep parsing join links **before** the `if (intent.data != null) return null`
|
||||
line, and that line must stay so all OTHER inbound `closer://` URIs (our own foreground FCM PendingIntents)
|
||||
still fall through to the NavHost's per-route navDeepLinks. The order is load-bearing.
|
||||
- **There are TWO stores**: `PendingInviteStore` (inviter's encrypted code + recovery phrase) vs
|
||||
`PendingJoinCodeStore` (invitee's plain code from a link). Don't merge them — the invitee has no phrase.
|
||||
`PendingJoinCodeStore` (invitee's plain code from a link). Don't merge them - the invitee has no phrase.
|
||||
- Share text is built by `InviteShareText.build()` and must carry the code ONLY (link + raw code), never the
|
||||
recovery phrase shown on the same screen — `InviteShareTextTest` asserts this.
|
||||
recovery phrase shown on the same screen - `InviteShareTextTest` asserts this.
|
||||
- **Owner action before App Links verify**: host `/.well-known/assetlinks.json` with the release cert
|
||||
SHA-256, plus a `/join/{code}` web landing page (Play Store redirect when the app isn't installed). Until
|
||||
then the https link still works via the app chooser; the `closer://` scheme always works.
|
||||
|
||||
### APPCHECK-TOKEN — debug token sourced from local.properties (never hardcode; post-enforcement build requirement)
|
||||
### APPCHECK-TOKEN - debug token sourced from local.properties (never hardcode; post-enforcement build requirement)
|
||||
**What it is**: the App Check debug token used to be hardcoded in `app/build.gradle.kts` (committed → anyone with
|
||||
repo history holds a console-registered token, defeating App Check the moment enforcement turns on). It now resolves
|
||||
via the `secret()` helper in `app/build.gradle.kts`: **local.properties → -P/gradle.properties → env**, empty when
|
||||
absent; `FirebaseInitializer.seedDebugToken()` skips seeding on blank, so tokenless builds (CI) mint their own
|
||||
unregistered token per install.
|
||||
**Re-introduction hazards**:
|
||||
- Never put the token (or any secret) back in a committed file. The historical value `e2dc8256-…` must be treated
|
||||
as burned — **rotate it in Firebase Console before enabling App Check enforcement**, or enforcement is hollow.
|
||||
- Never put the token (or any secret) back in a committed file. The historical value `e2dc8256-...` must be treated
|
||||
as burned - **rotate it in Firebase Console before enabling App Check enforcement**, or enforcement is hollow.
|
||||
- **After enforcement is ON**: only debug builds from machines whose `local.properties` carries the (rotated)
|
||||
`APP_CHECK_DEBUG_TOKEN` can reach Firestore. QA emulator rounds must use locally-built APKs; a CI-built APK
|
||||
(empty token) will be denied — expected, not a bug.
|
||||
(empty token) will be denied - expected, not a bug.
|
||||
- `local.properties` is NOT auto-loaded by Gradle; the explicit `Properties()` load at the top of
|
||||
`app/build.gradle.kts` is what makes it work. Don't "simplify" it back to `properties[...]`/`findProperty`.
|
||||
|
||||
### R24-BACKUP — E2EE conversation backup + full partner-assisted restore (design + re-introduction hazards)
|
||||
### R24-BACKUP - E2EE conversation backup + full partner-assisted restore (design + re-introduction hazards)
|
||||
**What it is**: devices keep a couple-key-encrypted backup of all conversations so a new/wiped device can restore
|
||||
history, and a partner can fully restore for the other (key + content, no phrase). Files:
|
||||
`data/backup/BackupManager.kt` (incremental append + compaction), `data/backup/BackupRestoreManager.kt` (restore
|
||||
|
|
@ -1220,13 +1274,13 @@ OOB code = `truncate6(SHA-256(pubkey ‖ nonce))`.
|
|||
- **Append misses mutations.** `appendChunk` only catches messages with a NEW `createdAt`; deletes/reactions on old
|
||||
messages are updates → captured only by **compaction's full-state re-read**. Don't "optimize" compaction into an
|
||||
append-only fold.
|
||||
- **Only back up resolved server timestamps** (`getTimestamp` non-null) — a pending write has a null timestamp and
|
||||
- **Only back up resolved server timestamps** (`getTimestamp` non-null) - a pending write has a null timestamp and
|
||||
would corrupt the cursor.
|
||||
- **Backup blobs live under `users/{uid}/backups/`** (uploader-scoped write, so Storage rules can authorize it +
|
||||
the existing `onUserDelete` `users/{uid}/` cleanup covers them). Do NOT move them under `couples/` without adding
|
||||
membership-checked write auth (Storage rules can't read Firestore) + a `couples/` delete cleanup.
|
||||
- **The PARTNER reads that uploader-scoped snapshot during restore via the tokenized `?token=` download URL** stored
|
||||
in the couple-gated manifest (`downloadBytes` does a plain HTTP GET on the URL — the token is the capability), NOT
|
||||
in the couple-gated manifest (`downloadBytes` does a plain HTTP GET on the URL - the token is the capability), NOT
|
||||
the uid-scoped Storage read rule (which only lets the owner read their own). The blob is `enc:v1:` so even a leaked
|
||||
URL yields ciphertext. Re-introduction hazard: if you switch restore to the authenticated SDK download or drop the
|
||||
manifest's tokenized URL, cross-user (partner-assisted) content restore silently breaks once chunks compact into the
|
||||
|
|
@ -1236,105 +1290,105 @@ OOB code = `truncate6(SHA-256(pubkey ‖ nonce))`.
|
|||
account-takeover). Never reduce it to a tap-to-approve. Consume (delete) the request after unwrap so no wrapped
|
||||
key lingers.
|
||||
- **Keybox payload carries the phrase too (R24-c).** The ECIES plaintext is now an envelope `ckx:v1:{json}` with
|
||||
`{keyset, phrase?}` — `wrapCoupleKey(..., recoveryPhrase)` includes the sender's phrase; `unwrapCoupleKey`
|
||||
`{keyset, phrase?}` - `wrapCoupleKey(..., recoveryPhrase)` includes the sender's phrase; `unwrapCoupleKey`
|
||||
returns `TransferredKey(keyset, recoveryPhrase?)`; `storeTransferredKeyset(coupleId, handle, phrase?)` persists
|
||||
the phrase so a partner-restored device can reveal it. **Backward-compatible:** a plaintext WITHOUT the
|
||||
`ckx:v1:` prefix is a legacy keyset-only keybox → decodes to `(keyset, null)`. Don't remove the legacy branch.
|
||||
The phrase rides inside the same OOB-code-gated ECIES ciphertext as the key — never log it (same rule as the key).
|
||||
- **Restore requires the couple key first** (phrase or partner keybox) — content decrypt is couple-key. Fail soft
|
||||
The phrase rides inside the same OOB-code-gated ECIES ciphertext as the key - never log it (same rule as the key).
|
||||
- **Restore requires the couple key first** (phrase or partner keybox) - content decrypt is couple-key. Fail soft
|
||||
everywhere (missing key → skip backup / "restore unavailable", never crash; never log keys/plaintext/phrase).
|
||||
- **Re-request must delete before create (R24-b, Bug A).** `createRestoreRequest` does `.set()`; over an existing
|
||||
`restore_requests/{uid}` doc that's an **update** changing `recipientPublicKey`/`requestNonce`, which matches **no**
|
||||
rule (the keybox rule needs `auth.uid != recipientUid`; the status-only rule can't touch keys) → silent
|
||||
`PERMISSION_DENIED`. So a retry after an expired/abandoned request fails. `RestoreManager.requestRestore` now
|
||||
**deletes any existing request first**, then creates fresh. Don't drop the delete.
|
||||
- **Enforce request expiry at fulfil (R24-b, Bug B).** `expiresAt` was advisory — nothing checked it, so a partner
|
||||
- **Enforce request expiry at fulfil (R24-b, Bug B).** `expiresAt` was advisory - nothing checked it, so a partner
|
||||
could approve a stale request and wrap the key to a since-replaced pubkey. `fulfillRestore` now **rejects when
|
||||
`now > expiresAt`** (`expiresAt <= 0` = no-expiry legacy is allowed); the consent screen treats expired/absent as
|
||||
"no active request" (its own empty state, distinct from a live one).
|
||||
- **Partner-assist consent shows identity + requires a confirm (R24-b).** The consent screen resolves the recipient
|
||||
via `userRepository.getUser(partnerUid)` — **email is plaintext (the anti-impersonation anchor), displayName is
|
||||
via `userRepository.getUser(partnerUid)` - **email is plaintext (the anti-impersonation anchor), displayName is
|
||||
decrypted locally** (the approving partner holds the couple key; the server can't decrypt it, so this is
|
||||
necessarily client-side). Approve is gated on `code.length==6 && confirmed`. The email/name/confirm are **UX +
|
||||
accidental-approval + social-engineering-friction** controls, NOT a takeover defense (the takeover email matches);
|
||||
the OOB code + the owner self-alert are the takeover controls. Keep the identity strictly client-side — never send
|
||||
the OOB code + the owner self-alert are the takeover controls. Keep the identity strictly client-side - never send
|
||||
displayName to a function (breaks E2EE).
|
||||
- **Owner self-alerts (R24-b).** `onRestoreRequested` sends a SECOND notification — to the **recipient's own
|
||||
- **Owner self-alerts (R24-b).** `onRestoreRequested` sends a SECOND notification - to the **recipient's own
|
||||
devices** (`type:'restore_self_alert'`, quiet-hours **bypassed**, deduped via `couples/{id}.lastRestoreSelfAlertAt`
|
||||
within 60s) — plus `onRestoreFulfilled` (onUpdate, guarded to the single REQUESTED→READY edge) on key transfer.
|
||||
within 60s) - plus `onRestoreFulfilled` (onUpdate, guarded to the single REQUESTED→READY edge) on key transfer.
|
||||
Each notification branch is independently try/caught so one failing token-fetch never aborts the other. Server
|
||||
bodies stay generic (can't name the recipient — E2EE). Client type wired in `PartnerNotificationManager`
|
||||
bodies stay generic (can't name the recipient - E2EE). Client type wired in `PartnerNotificationManager`
|
||||
(`RESTORE_SELF_ALERT` → `isEnabled` true, `routeFor` `AppRoute.SECURITY`, `fromRemoteType`).
|
||||
|
||||
### R23-DQ-001 — sourcing "already answered?" from local Room only → silent re-answer data loss against the immutable `secure/payload`
|
||||
**Symptom (R23)**: on a device whose local answer store was empty while Firestore still held the user's daily answer (fresh device / reinstall with cleared data / wiped prefs), Home showed a stale **"your turn"** and the daily-question screen offered an **editable re-answer form**. Submitting logged `Write failed at couples/{id}/daily_question/{date}/answers/{uid}/secure/payload: PERMISSION_DENIED` (swallowed). If the user picked a *different* answer it was **silently lost** — the `secure/payload` doc is immutable (`allow update: if false`), so the overwrite is denied and the reveal keeps the *old* content while the UI claimed "saved".
|
||||
### R23-DQ-001 - sourcing "already answered?" from local Room only → silent re-answer data loss against the immutable `secure/payload`
|
||||
**Symptom (R23)**: on a device whose local answer store was empty while Firestore still held the user's daily answer (fresh device / reinstall with cleared data / wiped prefs), Home showed a stale **"your turn"** and the daily-question screen offered an **editable re-answer form**. Submitting logged `Write failed at couples/{id}/daily_question/{date}/answers/{uid}/secure/payload: PERMISSION_DENIED` (swallowed). If the user picked a *different* answer it was **silently lost** - the `secure/payload` doc is immutable (`allow update: if false`), so the overwrite is denied and the reveal keeps the *old* content while the UI claimed "saved".
|
||||
**Root cause**: `DailyQuestionViewModel.loadDailyQuestion` and `HomeViewModel` derived answered-state from **local Room/prefs only** (`localAnswerRepository.getAnswer` / `observeAnswers` → `answeredQuestionIds`), with no fallback to Firestore. Room and Firestore can legitimately diverge (Auth + Firestore persist across a reinstall; the local answer store does not), so the app offered an action the rules forbid.
|
||||
**Fix (R23)**: `reconcileLocalAnswerFromFirestore` ([`ui/questions/LocalAnswerMapping.kt`](../app/src/main/java/app/closer/ui/questions/LocalAnswerMapping.kt)) — **Room-first** (returns the local answer immediately when present, so the common path is unchanged and network-free); otherwise reads the answer metadata + decrypts the owner's own read-gated couple-key payload, rebuilds the `LocalAnswer` (mapping option-texts), and **writes it back to Room** (only when it actually decrypts, so a transient key-miss never poisons Room). Wired into `DailyQuestionViewModel.loadDailyQuestion` (awaited → screen shows submitted/reveal) and `HomeViewModel.loadHome` (non-blocking heal → no stale "your turn"). Covered by `ReconcileLocalAnswerTest` (5 branches). `QuestionDetailViewModel` (pack questions) is **local-only** and not affected.
|
||||
**Re-introduction risk**: any UI that decides whether to offer a **submit-once / immutable** write (daily answers, date reflections, anything with a read-gated `secure/payload` whose `allow update:false`) must treat **Firestore as authoritative for existence**, not local cache — a local-only check re-offers the action after the local DB is lost and the immutable rule rejects the re-write silently. The date feature already does this (its `hasReflected` reads Firestore). When adding a new private→reveal collection, reconcile from Firestore on load and verify the fresh-device path (Pass N **R23-DQ-001** check), since `pm clear` (the faithful repro) is blocked in our emulator setup (App Check token).
|
||||
**Fix (R23)**: `reconcileLocalAnswerFromFirestore` ([`ui/questions/LocalAnswerMapping.kt`](../app/src/main/java/app/closer/ui/questions/LocalAnswerMapping.kt)) - **Room-first** (returns the local answer immediately when present, so the common path is unchanged and network-free); otherwise reads the answer metadata + decrypts the owner's own read-gated couple-key payload, rebuilds the `LocalAnswer` (mapping option-texts), and **writes it back to Room** (only when it actually decrypts, so a transient key-miss never poisons Room). Wired into `DailyQuestionViewModel.loadDailyQuestion` (awaited → screen shows submitted/reveal) and `HomeViewModel.loadHome` (non-blocking heal → no stale "your turn"). Covered by `ReconcileLocalAnswerTest` (5 branches). `QuestionDetailViewModel` (pack questions) is **local-only** and not affected.
|
||||
**Re-introduction risk**: any UI that decides whether to offer a **submit-once / immutable** write (daily answers, date reflections, anything with a read-gated `secure/payload` whose `allow update:false`) must treat **Firestore as authoritative for existence**, not local cache - a local-only check re-offers the action after the local DB is lost and the immutable rule rejects the re-write silently. The date feature already does this (its `hasReflected` reads Firestore). When adding a new private→reveal collection, reconcile from Firestore on load and verify the fresh-device path (Pass N **R23-DQ-001** check), since `pm clear` (the faithful repro) is blocked in our emulator setup (App Check token).
|
||||
|
||||
### B-ABANDON-001 — never UPDATE an existing session via the full-document `saveSession()`; the rule rejects dropped server-only keys
|
||||
**Symptom (R20)**: tapping **Quit** on any game (This or That / How Well / Wheel) navigated away but **left the session `active` server-side** — stranded, blocking new games — with no user-visible error. Logcat showed `Write failed at couples/{id}/sessions/{sid}: PERMISSION_DENIED` → `ThisOrThatViewModel: quit-abandon no-op`. The failure was swallowed by `runCatching{…}.onFailure{ Log.d }`. This had been mistaken for a test-data cleanup nuisance for several rounds ("Quit doesn't cancel server-side") before being root-caused.
|
||||
**Root cause**: `QuestionSessionRepositoryImpl.abandonSession` (and the dead twin `GameSessionManager.finishGame`) completed the session by calling `saveSession(activeSession.copy(status="completed", completedAt=now))`. `saveSession` does `doc.set(data)` where `data` is a **fixed 13-field map** — so it **overwrites the whole document and drops any field not in that map**, including the server-only notification flags the Cloud Function wrote via Admin SDK (`startNotifiedAt`, `joinNotifiedAt`, `partFinishNotifiedAt`, `finishNotifiedAt`). The session-update rule's `request.resource.data.diff(resource.data).affectedKeys().hasOnly(['status','completedAt','completedByUsers','joinedByUsers'])` counts a **removed** key as affected → those dropped flags fail `hasOnly` → the entire write is denied. (The normal both-finished completion path was unaffected because `markUserComplete` uses a **targeted `tx.update(docRef, {completedByUsers, status?, completedAt?})`**, never `saveSession`.)
|
||||
### B-ABANDON-001 - never UPDATE an existing session via the full-document `saveSession()`; the rule rejects dropped server-only keys
|
||||
**Symptom (R20)**: tapping **Quit** on any game (This or That / How Well / Wheel) navigated away but **left the session `active` server-side** - stranded, blocking new games - with no user-visible error. Logcat showed `Write failed at couples/{id}/sessions/{sid}: PERMISSION_DENIED` → `ThisOrThatViewModel: quit-abandon no-op`. The failure was swallowed by `runCatching{...}.onFailure{ Log.d }`. This had been mistaken for a test-data cleanup nuisance for several rounds ("Quit doesn't cancel server-side") before being root-caused.
|
||||
**Root cause**: `QuestionSessionRepositoryImpl.abandonSession` (and the dead twin `GameSessionManager.finishGame`) completed the session by calling `saveSession(activeSession.copy(status="completed", completedAt=now))`. `saveSession` does `doc.set(data)` where `data` is a **fixed 13-field map** - so it **overwrites the whole document and drops any field not in that map**, including the server-only notification flags the Cloud Function wrote via Admin SDK (`startNotifiedAt`, `joinNotifiedAt`, `partFinishNotifiedAt`, `finishNotifiedAt`). The session-update rule's `request.resource.data.diff(resource.data).affectedKeys().hasOnly(['status','completedAt','completedByUsers','joinedByUsers'])` counts a **removed** key as affected → those dropped flags fail `hasOnly` → the entire write is denied. (The normal both-finished completion path was unaffected because `markUserComplete` uses a **targeted `tx.update(docRef, {completedByUsers, status?, completedAt?})`**, never `saveSession`.)
|
||||
**Fix (R20)**: `abandonSession` now does a targeted `firestore...document(activeSession.id).update(mapOf("status" to "completed","completedAt" to now))` so `affectedKeys == {status, completedAt}` ⊆ the allowlist (and the active→completed transition satisfies the monotonic-status clause); `finishGame` delegates to it. Verified live: Quit → no denial → session `active=0` → a different game starts immediately (lockout gone).
|
||||
**Re-introduction risk**: **`saveSession()` is a CREATE primitive (full `doc.set`), not an UPDATE primitive.** Any code that mutates an *existing* session must use a targeted `update(...)` touching ONLY allowlisted keys (`status`/`completedAt`/`completedByUsers`/`joinedByUsers`) — a full `set()` silently strips server-written fields and the rule denies it. The hazard is invisible in unit tests (no rules) and is swallowed by best-effort `Log.d` callers, so **any new session-completion/abandon path must be exercised live against deployed rules** (watch logcat for `PERMISSION_DENIED` on the session doc), not just compiled. When you add a new server-only session field, it is dropped by `saveSession` for the same reason — prefer `update()` everywhere a session already exists.
|
||||
**Re-introduction risk**: **`saveSession()` is a CREATE primitive (full `doc.set`), not an UPDATE primitive.** Any code that mutates an *existing* session must use a targeted `update(...)` touching ONLY allowlisted keys (`status`/`completedAt`/`completedByUsers`/`joinedByUsers`) - a full `set()` silently strips server-written fields and the rule denies it. The hazard is invisible in unit tests (no rules) and is swallowed by best-effort `Log.d` callers, so **any new session-completion/abandon path must be exercised live against deployed rules** (watch logcat for `PERMISSION_DENIED` on the session doc), not just compiled. When you add a new server-only session field, it is dropped by `saveSession` for the same reason - prefer `update()` everywhere a session already exists.
|
||||
|
||||
### Recovery-phrase change desync — changing the couple phrase silently breaks the partner's "ask your partner" recovery
|
||||
**What**: the recovery phrase is a single **per-couple** secret that **both partners** receive at pairing and store locally (Keystore-backed `RecoveryPhraseStore` / `CoupleKeyStore`); it is never on the server in plaintext. This is by design and is a *feature* — it makes the partner a built-in backup: a user on a new device can recover by asking their partner to read them the phrase (Settings → Security), and the Recovery screen now tells them to. **The trap:** `CoupleRepository.changeRecoveryPhrase` → `CoupleEncryptionManager.rewrapWithNewPhrase` re-wraps the key under a new phrase and uploads a new `wrappedCoupleKey`, but only re-saves the new phrase on the **changer's** device. There is no channel to push the new phrase to the partner's device, so the partner's stored phrase (and their Settings → Security reveal) goes **stale** — it no longer matches `wrappedCoupleKey`, silently breaking the primary recovery path.
|
||||
**Status (2026-06-29)**: the change-phrase API is **UNWIRED** — no UI caller — so the desync is **not user-reachable today**. It now carries a loud warning at all three layers (`CoupleRepository.changeRecoveryPhrase` KDoc, the impl comment, `rewrapWithNewPhrase` KDoc).
|
||||
**Re-introduction risk**: do **not** wire a "change recovery phrase" UI without first solving partner re-sharing (force the partner to re-save the new phrase — e.g. re-share + re-confirm on their device — or treat the phrase as a fixed pairing secret that can't be changed). Otherwise a user who changed the phrase and then lost it would be unrecoverable, and "ask your partner" would hand over a wrong phrase. See [SECURITY.md](../SECURITY.md) (recovery section).
|
||||
### Recovery-phrase change desync - changing the couple phrase silently breaks the partner's "ask your partner" recovery
|
||||
**What**: the recovery phrase is a single **per-couple** secret that **both partners** receive at pairing and store locally (Keystore-backed `RecoveryPhraseStore` / `CoupleKeyStore`); it is never on the server in plaintext. This is by design and is a *feature* - it makes the partner a built-in backup: a user on a new device can recover by asking their partner to read them the phrase (Settings → Security), and the Recovery screen now tells them to. **The trap:** `CoupleRepository.changeRecoveryPhrase` → `CoupleEncryptionManager.rewrapWithNewPhrase` re-wraps the key under a new phrase and uploads a new `wrappedCoupleKey`, but only re-saves the new phrase on the **changer's** device. There is no channel to push the new phrase to the partner's device, so the partner's stored phrase (and their Settings → Security reveal) goes **stale** - it no longer matches `wrappedCoupleKey`, silently breaking the primary recovery path.
|
||||
**Status (2026-06-29)**: the change-phrase API is **UNWIRED** - no UI caller - so the desync is **not user-reachable today**. It now carries a loud warning at all three layers (`CoupleRepository.changeRecoveryPhrase` KDoc, the impl comment, `rewrapWithNewPhrase` KDoc).
|
||||
**Re-introduction risk**: do **not** wire a "change recovery phrase" UI without first solving partner re-sharing (force the partner to re-save the new phrase - e.g. re-share + re-confirm on their device - or treat the phrase as a fixed pairing secret that can't be changed). Otherwise a user who changed the phrase and then lost it would be unrecoverable, and "ask your partner" would hand over a wrong phrase. See [SECURITY.md](../SECURITY.md) (recovery section).
|
||||
|
||||
### 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.
|
||||
### 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.)
|
||||
**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. **[Superseded in part by N-NOTIF-001: it turned out the scheduled senders did NOT all honor quiet hours.]**
|
||||
### 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. **[Superseded in part by N-NOTIF-001: it turned out the scheduled senders did NOT all honor quiet hours.]**
|
||||
|
||||
### N-NOTIF-001 — a settings toggle is only real if a server reader enforces it (dead Daily/Streak toggles; scheduled-push quiet-hours blind spot)
|
||||
**Symptom (R20)**: Notification Settings showed 5 controls but only 2 worked. **Daily question reminder** was local-only (`SettingsDataStore`) and `dailyQuestionReminder` sent to everyone with no pref check → dead. **Streak reminder** was a phantom — local-only AND no streak notification existed anywhere. **Quiet hours** worked for real-time partner pushes (M-001) but the **scheduled/cron senders ignored it** (`dailyQuestionReminder`, `gameRetention`, `scheduledOutcomesReminder`), and the window was a **hardcoded 10 PM–8 AM** the user couldn't change. No promotional opt-out; no OS-permission-off awareness.
|
||||
**Fix (R20)**: (1) mirror ALL prefs to `users/{uid}` — extended `updateNotificationPrefs` to also write `notifDailyReminder`/`notifStreakReminder`/`notifPromotional`, and the `init` backfill re-mirrors on Settings open so pre-existing users heal; (2) gate `dailyQuestionReminder` on `notifDailyReminder !== false` + `recipientInQuietHours`; (3) NEW `notifications/streakReminder.ts` (couples `streakCount>0` with no shared action today, transactional per-day `streak_reminders/{dateKey}` marker, gated on `notifStreakReminder` + QH); (4) QH guards added to `gameRetention` (both senders) + `scheduledOutcomesReminder`; (5) promotional opt-out enforced on `reengagement` + the `gameRetention` challenge-day nudge (`notifPromotional`); (6) user-settable quiet-hours window via Material3 `TimePicker` + dynamic description; (7) OS-off banner (`NotificationManagerCompat.areNotificationsEnabled()` + `Settings.ACTION_APP_NOTIFICATION_SETTINGS`, re-checked `ON_RESUME`); (8) `firestore.rules` user-doc allowlist extended for the 3 new fields.
|
||||
**Re-introduction risk**: (a) **a toggle is only real if a Cloud Function reads the mirrored field** — a local-only toggle, or a `users/{uid}` pref no sender reads, is a DEAD setting; `scripts/wiring-scan.sh` Tier-4 now fails CI on any unread `notif*` field. (b) `updateNotificationPrefs` writes all prefs in ONE `set(merge)`, so adding a field there WITHOUT first adding it to the `firestore.rules` allowlist makes the **whole** write `PERMISSION_DENIED` — silently regressing the previously-working prefs too (logcat `NotifSettingsVM: PERMISSION_DENIED`; the `Log.w` in `syncNotifPrefs` surfaces it). The rules deploy is therefore a hard prerequisite — ship the Tier-1 client and the rules together. (c) **scheduled/cron senders are a QH + opt-out blind spot** — every new sender must read the relevant pref AND `recipientInQuietHours`. (d) client `QuietHoursManager.isInQuietHours` and server `recipientInQuietHours` must stay in lockstep (inclusive ends, midnight-crossing, and `start==end` ⇒ "no window"/off) — both are covered by tests (`QuietHoursManagerTest`, `quietHours.test.ts`).
|
||||
### N-NOTIF-001 - a settings toggle is only real if a server reader enforces it (dead Daily/Streak toggles; scheduled-push quiet-hours blind spot)
|
||||
**Symptom (R20)**: Notification Settings showed 5 controls but only 2 worked. **Daily question reminder** was local-only (`SettingsDataStore`) and `dailyQuestionReminder` sent to everyone with no pref check → dead. **Streak reminder** was a phantom - local-only AND no streak notification existed anywhere. **Quiet hours** worked for real-time partner pushes (M-001) but the **scheduled/cron senders ignored it** (`dailyQuestionReminder`, `gameRetention`, `scheduledOutcomesReminder`), and the window was a **hardcoded 10 PM-8 AM** the user couldn't change. No promotional opt-out; no OS-permission-off awareness.
|
||||
**Fix (R20)**: (1) mirror ALL prefs to `users/{uid}` - extended `updateNotificationPrefs` to also write `notifDailyReminder`/`notifStreakReminder`/`notifPromotional`, and the `init` backfill re-mirrors on Settings open so pre-existing users heal; (2) gate `dailyQuestionReminder` on `notifDailyReminder !== false` + `recipientInQuietHours`; (3) NEW `notifications/streakReminder.ts` (couples `streakCount>0` with no shared action today, transactional per-day `streak_reminders/{dateKey}` marker, gated on `notifStreakReminder` + QH); (4) QH guards added to `gameRetention` (both senders) + `scheduledOutcomesReminder`; (5) promotional opt-out enforced on `reengagement` + the `gameRetention` challenge-day nudge (`notifPromotional`); (6) user-settable quiet-hours window via Material3 `TimePicker` + dynamic description; (7) OS-off banner (`NotificationManagerCompat.areNotificationsEnabled()` + `Settings.ACTION_APP_NOTIFICATION_SETTINGS`, re-checked `ON_RESUME`); (8) `firestore.rules` user-doc allowlist extended for the 3 new fields.
|
||||
**Re-introduction risk**: (a) **a toggle is only real if a Cloud Function reads the mirrored field** - a local-only toggle, or a `users/{uid}` pref no sender reads, is a DEAD setting; `scripts/wiring-scan.sh` Tier-4 now fails CI on any unread `notif*` field. (b) `updateNotificationPrefs` writes all prefs in ONE `set(merge)`, so adding a field there WITHOUT first adding it to the `firestore.rules` allowlist makes the **whole** write `PERMISSION_DENIED` - silently regressing the previously-working prefs too (logcat `NotifSettingsVM: PERMISSION_DENIED`; the `Log.w` in `syncNotifPrefs` surfaces it). The rules deploy is therefore a hard prerequisite - ship the Tier-1 client and the rules together. (c) **scheduled/cron senders are a QH + opt-out blind spot** - every new sender must read the relevant pref AND `recipientInQuietHours`. (d) client `QuietHoursManager.isInQuietHours` and server `recipientInQuietHours` must stay in lockstep (inclusive ends, midnight-crossing, and `start==end` ⇒ "no window"/off) - both are covered by tests (`QuietHoursManagerTest`, `quietHours.test.ts`).
|
||||
|
||||
### 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.
|
||||
### 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
|
||||
### 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`.
|
||||
### 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
|
||||
### 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
|
||||
### 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
|
||||
### 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
|
||||
### 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
|
||||
### 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`.
|
||||
|
|
@ -1342,36 +1396,45 @@ OOB code = `truncate6(SHA-256(pubkey ‖ nonce))`.
|
|||
### 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.
|
||||
**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.
|
||||
**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.
|
||||
### 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.
|
||||
**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
|
||||
### 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).
|
||||
### 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)
|
||||
### BANNER-LIFE-001 - game-start banner must be consumed when the user enters the session, otherwise the next game's banner never shows
|
||||
**Symptom (R30)**: a banner for "Sam started This or That" lingered across both partners joining the session; once the game ended, the next game-start push correctly created a new banner but it looked like the *old* one was still on screen because `GamePromptController` keyed its active-banner set on `sessionId` alone and never consumed on entry.
|
||||
**Fix (R30, C1)**: `GamePromptController.consumeForSession(sessionId)` is called from every game's `enter/leave` (the same `ActiveGameSessionMonitor` hook the foreground-suppression check uses), and the controller replaces stale banners on a new start (last-wins by timestamp) so a banner for a session the user is already inside is never shown.
|
||||
**Re-introduction risk**: any new entry point into a game (a deep link, a partner-joined push, a "rematch?" CTA) that bypasses `ActiveGameSessionMonitor.enter` and therefore doesn't call `consumeForSession` will leave the previous banner alive - and the next push will look like a duplicate. The B6c split of `onGameSessionUpdate` into per-game part-finished triggers means a new game also needs a per-game monitor hook, not a shared one. Verified live across all four session games post-R30.
|
||||
|
||||
### FUNCTIONS-V2-DEPLOY - 2nd-gen deploys can fail mid-deploy with "Changing from HTTPS function to background triggered function" if a stray service is left behind
|
||||
**Symptom (2026-07-08)**: a deploy that swapped a Cloud Function from HTTPS to Firestore-triggered (e.g. promoting a callable to a scheduled job, or splitting a function module) failed with *"Changing from an HTTPS function to a background triggered function is not allowed"*. A first-ever v2 deploy can also fail with Eventarc service-agent IAM propagation races - the *"retry in a few minutes"* failures.
|
||||
**Root cause**: an orphaned `https` service (from a partial deploy or a previous attempt) is blocking the new background trigger from being created under the same name. The 2nd-gen quota was the proximate cause of the *initial* deploy failure (Cloud Run CPU quota couldn't fit ~35 services at v2's default 1 vCPU/instance; container healthchecks failed with *"Quota exceeded for total allowable CPU per project per region"*) - `functions/src/options.ts` pins `cpu: 'gcf_gen1'`, `concurrency: 1`, `maxInstances: 5` as the workaround.
|
||||
**Fix / deploy pattern**: when the "Changing from an HTTPS function to a background triggered function" error appears, `firebase functions:delete <stray-name>` then redeploy. Diagnose with `firebase functions:list` - healthy Firestore triggers show `google.cloud.firestore.document.v1.*` in the trigger column; strays show `https`. For the Eventarc propagation race, wait 2-3 minutes and retry. **At launch**: request the Cloud Run CPU quota increase (console → IAM & Admin → Quotas, us-central1), then drop `cpu`/`concurrency` from `options.ts` to restore 1 vCPU + concurrency 80.
|
||||
**Re-introduction risk**: a deploy that changes a function's *type* (callable → scheduled → trigger) under the same name WILL fail. Plan the rename + 2-step delete/redeploy if you need to refactor a function's surface.
|
||||
**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).
|
||||
**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 closed; theme scanner now mandatory)
|
||||
**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). (3) Hero illustrations rendered as opaque tiles had a hard bright block on dark (C-ART-EDGE-002).
|
||||
**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). (3) Hero illustrations rendered as opaque tiles had a hard bright block on dark (C-ART-EDGE-002).
|
||||
**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`.
|
||||
**Fix (R13)**: opaque hero tiles routed through `BrandIllustration(tile=true)` with `-night` variants; transparent celebration art (`spin_wheel`, `streak_milestone`, `reveal_celebration`) left as direct `Image`/`painterResource` because feathering floats is wrong.
|
||||
**Fix (R15)**: `scripts/theme-scan.sh` is a mandatory Pass C pre-check that statically detects hardcoded colors, direct `painterResource`/`Image` usage outside `BrandIllustration`, mismatched container/surface colors, and missing `preview`s. Run it before any manual visual sweep; file findings in `ClaudeReport.md` and counts in `ClaudeQACoverage.md`.
|
||||
|
|
@ -1383,19 +1446,19 @@ OOB code = `truncate6(SHA-256(pubkey ‖ nonce))`.
|
|||
|
||||
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.
|
||||
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. Pairs cleanly with the iOS E2EE code shipped in R24 - see [iOS E2EE gap](#ios-e2ee-gap-status-as-of-current-batch) for the still-pending schemaVersion 3 path.
|
||||
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.
|
||||
|
|
|
|||
Loading…
Reference in New Issue