docs(manual): R10 architecture updates — game push semantics, foreground banner, deep-link routes, premium gate pattern, landmines section

This commit is contained in:
null 2026-06-27 14:50:23 -05:00
parent 2cd0af65a8
commit 3924d63c7b
1 changed files with 121 additions and 7 deletions

View File

@ -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: 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. 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. 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 ### 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)`. - **Flow**: acknowledge 200 immediately, then call `applyEntitlementEvent(event)`.
- **Idempotency**: `entitlement_events/{eventId}` records processed events. Re-delivered events are dropped. - **Idempotency**: `entitlement_events/{eventId}` records processed events. Re-delivered events are dropped.
### Premium-gated features and gate pattern
The `EntitlementChecker.isPremium()` Flow is collected by feature-level ViewModels; features that gate render the paywall deep-link on a `false` value rather than throwing or showing empty state.
**Gated features (current list):**
- Premium question packs (`QuestionPackRepositoryImpl.isPremiumGate()`)
- Full answer history (free shows last 7; premium shows all)
- Custom questions
- Private notes
- Extra categories (beyond the free tier)
- Full spin-wheel session history
- Future AI-assisted question suggestions
**How to gate a new feature:**
1. Inject `EntitlementChecker` into the relevant ViewModel or Repository.
2. On entry, collect `isPremium()`. If `false`, navigate to the paywall route (`paywallScreen()` in `AppNavigation`) with a returnTo argument. Do not silently fail or render empty state.
3. Server-side: any new premium-only Cloud Function must verify the entitlement via `users/{uid}/entitlements/premium` before doing work. Do not trust the client flag for server-side gating.
**QA testing convention**: to test premium features without a real subscription, set the entitlement doc directly:
```text
users/{testUserUid}/entitlements/premium
premium: true
expiresAt: <far-future Timestamp>
```
The `EntitlementChecker` Flow picks this up reactively. The QA report convention is `Sam premium = ON` (or `= OFF`) to track the test setup state per run.
--- ---
## Notifications ## 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. `users/{uid}/notification_queue/` is a server-only collection that stores pending partner notifications. The FCM push is the user-visible surface; the queue is for in-app polling and for tracking delivery. Reads are denied for clients; the app reacts to FCM, not the queue.
### Game session push semantics (idempotent flag-claim)
The Cloud Function `functions/src/games/onGameSessionUpdate.ts` fires on every game session doc update. The original implementation diffed `status` fields to decide whether to send a push, which produced duplicate notifications when both partners updated the doc close together. The current implementation uses an **idempotent flag-claim** pattern: a single `notificationsSent` map on the session doc records each notification type the moment it's dispatched. The trigger checks the flag, sends if absent, writes the flag. Re-running the trigger is a no-op.
This pattern is the rule for any partner-facing push derived from a game session — **never diff, always claim**. The flag keys are stable strings like `'start'`, `'finish'`, `'partner_answered'` (game-specific).
### Foreground game-alert banner (R10+)
System-tray notifications are easy to miss when the app is open. R10 added an in-app banner surface (`GamePromptController`, `GamePromptBanner`) that mirrors the chat in-app banner pattern: when the app is foreground and a game-start push arrives, a prominent top banner slides in ("\<partner\> started \<Game\>" + Join). The banner is **suppressed** when the user is already on that game's screen — `ActiveGameSessionMonitor.enter/leave` tracks the active route, and `GamePromptController` consults it before showing.
Home's "Waiting for you" card was also redesigned as a bold purple-gradient hero that joins the specific game (not the Play-hub fallback). Verified live across all four session games (Spin the Wheel, This or That, How Well Do You Know Me, Desire Sync).
Key Android files: `app/src/main/java/app/closer/notifications/GamePromptController.kt`, `app/src/main/java/app/closer/ui/components/GamePromptBanner.kt`, `app/src/main/java/app/closer/notifications/ActiveGameSessionMonitor.kt`, `app/src/main/java/app/closer/notifications/PartnerNotificationManager.kt`, `app/src/main/java/app/closer/core/notifications/AppMessagingService.kt`. Deep-link routes live in `app/src/main/java/app/closer/core/navigation/AppNavigation.kt`.
### Notification deep-link routing
`PartnerNotificationManager` (Android) parses the FCM data payload and routes to the right screen. 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 ## 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) - **Application ID**: `closer.app` (the on-device package identifier used by Google Play)
- **compileSdk**: 35, **minSdk**: 26, **targetSdk**: 35 - **compileSdk**: 35, **minSdk**: 26, **targetSdk**: 35
- **Java/Kotlin**: 17 - **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 ### 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 ## Where to look first
If you are new to the codebase, read these files in order: 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. 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. 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. 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. 11. **`app/src/main/java/app/closer/notifications/GamePromptController.kt`** — foreground game-alert banner; `PartnerNotificationManager.kt` — deep-link routing.
12. **`iphone/ARCHITECTURE_AUDIT.md`** — generated iOS port blueprint. 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.