From 361eff18e342dc107af3f29b46096027042318c9 Mon Sep 17 00:00:00 2001 From: null Date: Sat, 27 Jun 2026 15:27:14 -0500 Subject: [PATCH] =?UTF-8?q?docs(manual):=20review=20pass=203=20=E2=80=94?= =?UTF-8?q?=20routing=20route=20names,=20notification=5Fqueue=20reality,?= =?UTF-8?q?=20/questions=20server-only,=20conversations/messages=20data=20?= =?UTF-8?q?model,=20premium=20sharing,=20entitlement=20fields,=20daily=20r?= =?UTF-8?q?eminder=20skip=20conditions,=20iOS=20tree=20fix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/Engineering_Reference_Manual.md | 117 +++++++++++++++++++++------ 1 file changed, 93 insertions(+), 24 deletions(-) diff --git a/docs/Engineering_Reference_Manual.md b/docs/Engineering_Reference_Manual.md index 5d34d770..17738455 100644 --- a/docs/Engineering_Reference_Manual.md +++ b/docs/Engineering_Reference_Manual.md @@ -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 # per-user last-read timestamp + typing: map # 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 # 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 + # 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 # 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.