docs(manual): review pass 3 — routing route names, notification_queue reality, /questions server-only, conversations/messages data model, premium sharing, entitlement fields, daily reminder skip conditions, iOS tree fix

This commit is contained in:
null 2026-06-27 15:27:14 -05:00
parent c167211323
commit 361eff18e3
1 changed files with 93 additions and 24 deletions

View File

@ -62,6 +62,10 @@ Closer is a couples relationship app. The product goal is **private, mutual-reve
- Clients never write to another user's document or to another couple's document.
- E2EE answer content is encrypted on the device. The server sees only ciphertext.
### 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)).
### 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.
@ -144,8 +148,7 @@ iphone/
├── Info.plist # Bundle config, push entitlement, URL schemes
├── GoogleService-Info.plist # Firebase config — gitignored, copy from your project
└── Closer/
├── CloserApp.swift # @main, AppState, AppDelegate, RevenueCat init
├── ContentView.swift # Root NavigationStack + TabView
├── CloserApp.swift # @main (struct CloserApp + AppDelegate adaptor), AppState, RevenueCat init
├── Core/
│ ├── Auth/AuthService.swift
│ ├── Billing/BillingService.swift
@ -155,7 +158,7 @@ iphone/
├── Services/FirestoreService.swift # Firestore + callable wrappers
├── Theme/CloserTheme.swift # Colors, typography, spacing
├── Components/ # Shared SwiftUI components
├── Navigation/ # Root routing (paired with ContentView)
├── Navigation/ # ContentView.swift — Root NavigationStack + TabView
├── Onboarding/ # Onboarding, login, signup
├── Pairing/ # Invite code, partner confirm, recovery
├── Home/ # Home dashboard, partner mirror
@ -215,7 +218,7 @@ There is no `auth/` module. Authentication is handled entirely by the Firebase A
**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 the daily question expires at 6 PM). Queries expiring `daily_question` docs via collection group, skips couples where anyone has answered OR where a reminder was already sent today, sends FCM + writes `notification_queue`, records `automated_daily_reminder/{docId}` for idempotency.
- `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.
### Shared configuration
@ -534,9 +537,12 @@ Thread questions follow the same sealed flow but use a different path:
coupleId: string | null
hasPremium: bool # server-only write
platform: 'android' | 'ios' | null
/entitlements/premium # written by Cloud Functions only
/entitlements/premium # written by Cloud Functions only; readable by owner + current partner (see Couple-shared premium)
premium: bool
expiresAt: Timestamp | null
updatedAt: Timestamp # last entitlement change
productId: string | null # RevenueCat product identifier (e.g. 'closer_monthly')
eventType: string # RevenueCat event type ('INITIAL_PURCHASE', 'RENEWAL', 'CANCELLATION', etc.)
/fcmTokens/{tokenId} # owned by the user
token: string
platform: 'android' | 'ios'
@ -548,12 +554,17 @@ Thread questions follow the same sealed flow but use a different path:
/outcomes/{dayKey} # day_0, day_30, day_60, day_90; server-only
submittedAt: Timestamp
answers: map
/notification_queue/{id} # server-only; in-app activity feed
/notification_queue/{id} # in-app activity feed (Together screen)
type: string # e.g. 'invite_created', 'partner_joined', or whatever the caller writes
read: bool # only field the rules allow the client to update
createdAt: Timestamp
# Plus arbitrary caller-supplied fields (the writer spreads them onto the doc).
# See functions/src/games/onGameSessionUpdate.ts and functions/src/couples/createInviteCallable.ts for examples.
# NOTE: despite the comment in firestore.rules that says "the app reacts to FCM push, not this collection",
# the Android `FirestoreActivityDataSource` DOES read this collection for the in-app "Together"
# activity feed. Client reads + read-flag updates are permitted by the rules; server-side writes
# are the only way to create records here. The rules comment is stale.
/invite_attempts/{id} # rate-limit; Firestore TTL
code: string
attemptedAt: Timestamp
@ -576,16 +587,6 @@ Thread questions follow the same sealed flow but use a different path:
/desire_sync/{sessionId}
/how_well/{sessionId}
/wheel/{sessionId}
/date_swipes/{swipeId}
userId, dateIdeaId, action: 'love' | 'maybe' | 'skip'
/date_matches/{matchId}
userIds, dateIdeaId, createdAt
/date_plans/{planId}
title, dateTime, status: 'draft' | 'planned' | 'completed'
/date_plan_preferences/{uid}
categories: map
/bucket_list/{itemId}
title, category, addedByUserId, completedAt
/gentle_reminders/{YYYY-MM-DD} # one doc per calendar day per couple; daily lock
/invites/{code} # server-only writes; 24h TTL
@ -593,8 +594,50 @@ Thread questions follow the same sealed flow but use a different path:
createdAt, expiresAt
wrappedCoupleKey, kdfSalt, kdfParams, recoveryPhrase
/questions/{questionId} # read-only catalog (admin seeded)
/questions/{questionId} # SERVER-ONLY (no client rule grants access). Admin SDK reads via pickRandomQuestionId(); Android loads bundled seed JSON via Room (`seed/questions/*.json`).
text, categoryId, active, isPremium
# Client (Android) ships `seed/questions/*.json` files as Room assets via `seed/build_db.py`.
# The Firestore copy exists as the source of truth for the daily-question picker; if it's empty
# the picker falls back to id 'q_default_daily' (functions/src/questions/assignDailyQuestion.ts).
# Do not rely on client-side reads of this collection; treat it as write-once admin data.
/couples/{coupleId}/conversations/{conversationId} # E2E-encrypted chat
type: 'couple' | 'thread' # 'thread' = per-question discussion
questionId: string | null # only on threads
createdAt: Timestamp
lastMessageAt: Timestamp | null
lastMessagePreview: enc:v1: | null # E2E-encrypted preview of the latest message
lastMessageSenderId: uid | null
reads: map<uid, Timestamp> # per-user last-read timestamp
typing: map<uid, Timestamp> # per-user last-typing timestamp (TTL'd client-side)
/messages/{messageId}
authorUserId: uid
text: enc:v1: # for type='text'; isCiphertext() enforced by rules
type: 'text' | 'image' | 'voice'
mediaUrl: string | null # for image/voice; points at encrypted Storage bytes
durationMs: number | null # for voice messages
reactions: map<uid, emoji> # reactions map (any member may flip their own)
createdAt: Timestamp
deleted: bool # tombstone; only author may set this (unsend)
/couples/{coupleId}/date_swipes/{dateIdeaId} # E2E-encrypted per-date swipe state
actions: map<uid, { action: enc:v1:, swipedAt: number }>
# One doc per date idea; both partners' entries live under .actions keyed by uid.
# Server cannot read action (encrypted); only swipedAt is plaintext (used for ordering/audit).
# Cross-reference: createDateMatch.ts fires when both partners' actions are 'love'.
/couples/{coupleId}/date_matches/{matchId}
userIds, dateIdeaId, createdAt
/couples/{coupleId}/date_plans/{planId}
title, dateTime, status: 'draft' | 'planned' | 'completed' # server-created by createDateMatch
/couples/{coupleId}/date_plan_preferences/{prefId}
categories: map<category, weight> # plaintext per-user weights
/couples/{coupleId}/bucket_list/{itemId}
title, category, addedByUserId, completedAt
# NOTE: this list is not exhaustive. Game-session subcollections (this_or_that, desire_sync,
# how_well, wheel) and challenge-day / memory-lane subcollections are documented in their own
# sections. If you add a new subcollection, update both firestore.rules AND this data model.
/date_ideas/{dateIdeaId} # read-only catalog
title, description, category, imageUrl
@ -612,7 +655,7 @@ Thread questions follow the same sealed flow but use a different path:
- `users/{uid}.coupleId``couples/{coupleId}`.
- `couples/{coupleId}.userIds``users/{uid}` (the two members).
- `couples/{coupleId}/daily_question/{date}/answers/{userId}.userId``users/{uid}`.
- `couples/{coupleId}/date_swipes/{swipeId}.userId` → `users/{uid}`.
- `couples/{coupleId}/date_swipes/{dateIdeaId}.actions.{uid}` → `users/{uid}` (each swipe entry keyed by uid).
- `entitlement_events/{eventId}.userId``users/{uid}`.
---
@ -654,7 +697,7 @@ isUpdatingCoupleRhythm() only streakCount/lastAnsweredAt
### Per-collection enforcement
**`users/{uid}`** — owner can read/create/update their own doc but `hasPremium` is server-only. `entitlements/`, `notification_queue/`, and `outcomes/` are server-only writes. `fcmTokens/` and `devices/` are owner-writable. The `devices/` public key is readable by the user's current couple partner only (to wrap release keys) — restricting it prevents speculative pre-encryption by non-partners.
**`users/{uid}`** — owner can read/create/update their own doc but `hasPremium` is server-only. `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) — restricting it prevents speculative pre-encryption by non-partners.
**`date_ideas/`** — read-only for any signed-in user; writes are admin-only.
@ -876,9 +919,9 @@ 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)` (e.g. `WHEEL_SESSION`, `THIS_OR_THAT_SESSION`) — deep link into the waiting game so the screen auto-joins the active session. Falls back to `PLAY` if `gameType` is missing. E-003. |
| `partner_completed_part` | `PARTNER_COMPLETED_PART` | `gameRouteForType(payload.gameType)` — same as started_game |
| `partner_finished_game`, `game_results_ready` | `GAME_RESULTS_READY` | `gameResultsRouteFor(gameType, gameSessionId)` — results/replay route (the plain game route would show "start a new game" because `getActiveSession` only returns active sessions). Falls back to `PLAY`. 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` |
@ -1136,6 +1179,32 @@ These are bugs that cost real debugging time and are easy to re-introduce if you
**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.
**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.
### 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).
**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)
**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).
### Theme-variant + soft-edge art (C-DARKART-001, C-ART-EDGE-001; C-ART-EDGE-002 open)
**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).
**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`. Files: `ui/theme/Theme.kt`, `ui/components/BrandIllustration.kt`, `ui/components/EmptyState.kt`.
**Re-introduction risk / still-open**: any art rendered via a **direct** `painterResource(R.drawable.illustration_*)` (NOT `BrandIllustration`) bypasses both fixes — hero images (daily-question, couple_subscription/paywall/onboarding, home prompts, spin-wheel) still hard-edge on dark (**C-ART-EDGE-002, open P3**). Prefer routing illustrations through `BrandIllustration`.
---
## Where to look first
@ -1145,9 +1214,9 @@ 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.
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 fields, recovery phrase wipe, encryptionVersion derivation all in one file.
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.