docs(manual): R10 architecture updates — game push semantics, foreground banner, deep-link routes, premium gate pattern, landmines section
This commit is contained in:
parent
2cd0af65a8
commit
3924d63c7b
|
|
@ -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: <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
|
||||
|
|
@ -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 ("\<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
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in New Issue