diff --git a/docs/Engineering_Reference_Manual.md b/docs/Engineering_Reference_Manual.md index 8275284d..cc560730 100644 --- a/docs/Engineering_Reference_Manual.md +++ b/docs/Engineering_Reference_Manual.md @@ -449,10 +449,10 @@ In practice the date key is correct most of the time, but the comment "America/C Answers are written by the client under `couples/{coupleId}/daily_question/{date}/answers/{userId}`. The Firestore rules require the document to match one of two shapes: -1. **Legacy / company-proof** (any `schemaVersion` ≠ 3): `enc:v1:` ciphertext fields, content is encrypted with the couple key. -2. **Sealed / partner-proof** (`schemaVersion = 3`): `sealed:v1:` payload + `sha256:` commitment; content key is released only after both partners have submitted. +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. +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 @@ -721,6 +721,36 @@ Callers collect `isPremium()` reactively rather than caching a one-time snapshot - **Flow**: acknowledge 200 immediately, then call `applyEntitlementEvent(event)`. - **Idempotency**: `entitlement_events/{eventId}` records processed events. Re-delivered events are dropped. +### Premium-gated features and gate pattern + +The `EntitlementChecker.isPremium()` Flow is collected by feature-level ViewModels; features that gate render the paywall deep-link on a `false` value rather than throwing or showing empty state. + +**Gated features (current list):** + +- Premium question packs (`QuestionPackRepositoryImpl.isPremiumGate()`) +- Full answer history (free shows last 7; premium shows all) +- Custom questions +- Private notes +- Extra categories (beyond the free tier) +- Full spin-wheel session history +- Future AI-assisted question suggestions + +**How to gate a new feature:** + +1. Inject `EntitlementChecker` into the relevant ViewModel or Repository. +2. On entry, collect `isPremium()`. If `false`, navigate to the paywall route (`paywallScreen()` in `AppNavigation`) with a returnTo argument. Do not silently fail or render empty state. +3. Server-side: any new premium-only Cloud Function must verify the entitlement via `users/{uid}/entitlements/premium` before doing work. Do not trust the client flag for server-side gating. + +**QA testing convention**: to test premium features without a real subscription, set the entitlement doc directly: + +```text +users/{testUserUid}/entitlements/premium + premium: true + expiresAt: +``` + +The `EntitlementChecker` Flow picks this up reactively. The QA report convention is `Sam premium = ON` (or `= OFF`) to track the test setup state per run. + --- ## Notifications @@ -772,6 +802,47 @@ The notification is both an FCM push (for the system tray) and an entry in `user `users/{uid}/notification_queue/` is a server-only collection that stores pending partner notifications. The FCM push is the user-visible surface; the queue is for in-app polling and for tracking delivery. Reads are denied for clients; the app reacts to FCM, not the queue. +### Game session push semantics (idempotent flag-claim) + +The Cloud Function `functions/src/games/onGameSessionUpdate.ts` fires on every game session doc update. The original implementation diffed `status` fields to decide whether to send a push, which produced duplicate notifications when both partners updated the doc close together. The current implementation uses an **idempotent flag-claim** pattern: a single `notificationsSent` map on the session doc records each notification type the moment it's dispatched. The trigger checks the flag, sends if absent, writes the flag. Re-running the trigger is a no-op. + +This pattern is the rule for any partner-facing push derived from a game session — **never diff, always claim**. The flag keys are stable strings like `'start'`, `'finish'`, `'partner_answered'` (game-specific). + +### Foreground game-alert banner (R10+) + +System-tray notifications are easy to miss when the app is open. R10 added an in-app banner surface (`GamePromptController`, `GamePromptBanner`) that mirrors the chat in-app banner pattern: when the app is foreground and a game-start push arrives, a prominent top banner slides in ("\ started \" + Join). The banner is **suppressed** when the user is already on that game's screen — `ActiveGameSessionMonitor.enter/leave` tracks the active route, and `GamePromptController` consults it before showing. + +Home's "Waiting for you" card was also redesigned as a bold purple-gradient hero that joins the specific game (not the Play-hub fallback). Verified live across all four session games (Spin the Wheel, This or That, How Well Do You Know Me, Desire Sync). + +Key Android files: `app/src/main/java/app/closer/notifications/GamePromptController.kt`, `app/src/main/java/app/closer/ui/components/GamePromptBanner.kt`, `app/src/main/java/app/closer/notifications/ActiveGameSessionMonitor.kt`, `app/src/main/java/app/closer/notifications/PartnerNotificationManager.kt`, `app/src/main/java/app/closer/core/notifications/AppMessagingService.kt`. Deep-link routes live in `app/src/main/java/app/closer/core/navigation/AppNavigation.kt`. + +### Notification deep-link routing + +`PartnerNotificationManager` (Android) parses the FCM data payload and routes to the right screen. The full routing table (from `PartnerNotificationType.fromRemoteType`): + +| Payload key | Notification type | Routes to | +| --- | --- | --- | +| `partner_answered` | `PARTNER_ANSWERED` | `ANSWER_REVEAL` for that date | +| `partner_opened_answer` | `PARTNER_OPENED_ANSWER` | `ANSWER_REVEAL` | +| `reveal_ready` | `REVEAL_READY` | `ANSWER_REVEAL` (both-answered couple-key unlock) | +| `partner_started_game` | `PARTNER_STARTED_GAME` | Game session screen for that game | +| `partner_completed_part` | `PARTNER_COMPLETED_PART` | The user's part screen for that session | +| `partner_finished_game`, `game_results_ready` | `GAME_RESULTS_READY` | Results screen for that session | +| `challenge_day_ready`, `challenge_waiting` | `CHALLENGE_WAITING` | Challenge screen | +| `memory_capsule_unlocked` | `CAPSULE_UNLOCKED` | Memory Lane | +| `daily_question`, `daily_question_reminder` | `DAILY_QUESTION_REMINDER` | Today screen | +| `chat_message` | `CHAT_MESSAGE` | Thread screen | +| `outcome_reminder` | `OUTCOME_REMINDER` | Outcomes check-in | +| `gentle_reminder` | `GENTLE_REMINDER` | Home | +| `partner_joined` | `PARTNER_JOINED` | Pairing confirm | +| `date_match` | `DATE_MATCH` | Date Matches screen | +| `reengagement` | `REENGAGEMENT` | Home | +| `partner_left`, `partner_deleted_account` | `PARTNER_UNPAIRED` | Settings / pair again flow | + +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, then add a `when` branch in `fromRemoteType` mapping the server's payload key. Server-side: emit the matching payload key from the Cloud Function. Both sides must be updated together; mismatches silently drop the notification. + --- ## iOS-specific notes @@ -840,7 +911,7 @@ When implementing iOS E2EE parity: - **Application ID**: `closer.app` (the on-device package identifier used by Google Play) - **compileSdk**: 35, **minSdk**: 26, **targetSdk**: 35 - **Java/Kotlin**: 17 -- **Versioning**: `versionCode` is integer; `versionName` is a string. **Current state**: `versionCode = 1`, `versionName = "0.1.0"`. HISTORY.md describes versions up to `0.2.x`. Bump `versionName` in `app/build.gradle.kts` when cutting a release. +- **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 @@ -968,6 +1039,47 @@ These are agent-only or workspace-only docs and have no place in the public repo --- +## 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. + +### F-RACE-001 — duplicate game-start push on rapid partner update +**Symptom**: both partners got a "game started" push when only one started it. Caused by diffing `status` field on game session update trigger; two near-simultaneous updates both saw the diff. +**Fix**: replaced status-diff with idempotent flag-claim on a `notificationsSent` map (commit `6e79cd9`). See [Game session push semantics](#game-session-push-semantics-idempotent-flag-claim). +**Re-introduction risk**: any new game event that wants to push MUST use the flag-claim pattern, not status diff. + +### E-GAME-001 — notification deep-link landed in stale/finished game +**Symptom**: tapping a game-start notification re-entered a finished play screen instead of the active one. +**Fix**: `MainActivity` `singleTop` launch mode + server-first read in `PartnerNotificationManager` (commit `b9b1560`). +**Re-introduction risk**: changing `MainActivity` launch mode, or making the navigation read from local state before fetching server state. + +### E-GAME-002 — game-start push easy to miss when app is foreground +**Symptom**: system-tray notification was buried; partner missed game starts. +**Fix**: foreground in-app banner via `GamePromptController` + bold Home "Game waiting" hero (commit `38fdc6d`). See [Foreground game-alert banner](#foreground-game-alert-banner-r10). +**Re-introduction risk**: removing `GamePromptController` from the FCM message handler path, or breaking `ActiveGameSessionMonitor.enter/leave` in any game's ViewModel. + +### C-NAV-001 — back from Home resurfaces onboarding/auth +**Symptom**: after login + Home + BACK, app returned to onboarding instead of exiting. +**Fix**: login flow must `popUpTo` the auth destination after successful navigation (commit `ebd3b2e`). +**Re-introduction risk**: adding a new auth flow without `popUpTo`. The pattern is in `AppNavigation.kt`. + +### C-SEC-001 — recovery phrase reading wrong store on accepter +**Symptom**: after accepting an invite, the recovery-phrase reveal in Settings showed a disabled state with wrong "invite your partner" copy. The accepter's phrase was being read from the inviter's path. +**Fix**: `SecurityViewModel` now reads via `encryptionManager.recoveryPhrase(coupleId)` from `CoupleKeyStore` (R10 fix phase, commit `9c84c36`). +**Re-introduction risk**: any future change to `CoupleKeyStore` access that bypasses `EncryptionManager`. Always go through `EncryptionManager`. + +### Back-stack gotchas (C-NAV-002, C-NAV-003) +**Symptom**: Wheel results → BACK re-entered finished play screen (C-NAV-002); Wheel History / Past Games / Partner Home showed double app bars because the route was in both `shellBackRoutes` and the screen's own `TopAppBar` (C-NAV-003). +**Fix**: `popUpTo(WHEEL_SESSION){inclusive=true}` on session→complete navigation; removed `WHEEL_HISTORY`/`GAME_HISTORY`/`PARTNER_HOME` from `shellBackRoutes` in `AppNavigation.kt`. +**Re-introduction risk**: adding new screens with their own `TopAppBar` to `shellBackRoutes` — check the route list before adding. + +### Home duplicate pending-action card (C-HOME-001) +**Symptom**: Home showed the same pending action twice — once in the `primaryAction` hero, once in the `buildPendingActions` row. +**Fix**: `buildPendingActions().filterNot { it.target == primary?.target }` to dedupe (R10). +**Re-introduction risk**: adding a new pending-action type without checking it isn't already promoted to hero. + +--- + ## Where to look first If you are new to the codebase, read these files in order: @@ -982,7 +1094,9 @@ If you are new to the codebase, read these files in order: 8. **`app/src/main/java/app/closer/crypto/SealedAnswerEncryptor.kt`** and **`SealedRevealManager.kt`** — sealed-answer wire format and reveal flow. 9. **`app/src/main/java/app/closer/ui/answers/AnswerRevealViewModel.kt`** — the client-side reveal state machine. 10. **`app/src/main/java/app/closer/core/billing/FirestoreEntitlementChecker.kt`** — server-verified entitlement flow. -11. **`iphone/Closer/Services/FirestoreService.swift`** — the iOS side of cross-platform data contracts. -12. **`iphone/ARCHITECTURE_AUDIT.md`** — generated iOS port blueprint. +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. +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.