Closer/Future.md

363 lines
37 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Future — ideas & improvements backlog
Non-blocking ideas: things that work today but could be better, plus feature ideas. Actual bugs
(broken/incorrect behavior) live in `ClaudeReport.md`, not here.
## Backup, restore & E2EE (follow-ons to R24)
- **Option B — relay-and-delete for messages.** Now that E2EE backup + restore exist (R24), make the Room
`conversation_cache` the **source of truth** and flip Firestore `messages` to a transient relay
(TTL / delete-after-delivery), plus **back up media into the snapshot** (since `chat_media` would then be
relay-deleted). This is the payoff the R24 backup unblocks. Rewire the chat read to local-first + live-merge
(additive, flag-guarded — dedupe RoomFirestore by message id).
- **Backup trigger hardening.** Backups currently run opportunistically from `HomeViewModel.loadHome` (throttled).
Move to **WorkManager** (deferred, retried, network/battery-constrained) + an app-background trigger for
reliability; add a **resumable, paginated initial backfill** for very large existing histories.
- **Settings visibility + control.** "Last backed up: {time} · {N} messages" indicator + manual "Back up now" /
"Restore history", and an **opt-out** toggle for users who want zero server retention. Include a dedicated
**"Recent restore activity"** list — the R24-b `restore_self_alert` entries already land in `notification_queue`
as the raw audit; this surfaces them so the owner can review restores on their account in one place.
- **Email-verification challenge for partner-assisted restore (strongest anti-account-takeover control).** Before
a restore request is honored, send a code to the account's **registered email** and require the recipient to
enter it. A phished-password attacker who lacks inbox access can't complete it (the couple-email match that
defeats the on-screen identity check does NOT defeat this). Needs mail infra (SendGrid / a Firebase Auth action)
— deferred for that reason. R24-b shipped the on-screen identity + confirm + owner self-alert as the pragmatic
interim.
- **Restore-request lifecycle cleanup — ✅ SHIPPED + DEPLOYED (commit `8496bbed`).** Hourly
`cleanupExpiredRestoreRequests` (collectionGroup on `expiresAt`, catches orphans under deleted couples;
delete-first then a quiet expiry nudge to the requester through the house pipeline). Indexes + function
deployed; sweep observed running (`scanned 0 … failed 0` = the collectionGroup index resolves in prod).
⚠️ **The REAP itself has never been observed live** — every scheduled function stalled 2026-07-15 ~20:50 UTC
(see ClaudeReport). Re-verify once scheduling is restored: arm a short-expiry request, watch it reap + nudge.
- **Owner-alert precision — ✅ SHIPPED + DEPLOYED + VERIFIED LIVE (commit `2dd09d5d`).** Requesting device embeds
its FCM token (create-only optional field, best-effort); both self-alerts skip that one device, partner push
untouched. Live 2026-07-16 on a throwaway couple: the requesting device got **zero** notifications while the
partner's "Help your partner restore 💜" landed — and since self-alerts bypass quiet hours, the exclusion is
what silenced it.
- **Couple-key rotation / forward secrecy — ✅ PHASE 1 SHIPPED + DEPLOYED + VERIFIED LIVE (`2c44cc6f`, `cabfca5e`,
`7341f64a`, `8b0bc582`); PHASE 2 PARKED (own checkpoint).** Phase 1 = rotate-forward: new Tink key primary + old
retained (history readable, zero wire/`isCiphertext` changes), same-phrase re-wrap + strictly-increasing
`keyGeneration`, partner adopts automatically, 🔑 alert via `onCoupleKeyRotated`, Settings → Security row.
Verified live 2-device on a throwaway couple across TWO rotations: partner on the old key genuinely can't read
new content (🔒), adopts, then reads BOTH eras. Protects FUTURE content only — a stolen keyset still holds the
old key; that's phase 2.
**Two bugs the live runs caught, both fixed:** adoption only ran on Home load, so a warm app (or one opened via
a chat notification) never adopted — now a couple-doc watcher at app start (`7341f64a`, `8b0bc582`); and
**C-ROTATE-001** — a cache-fed `keyGeneration` made the 2nd rotation publish an unchanged generation, so no
trigger fired and the partner was stranded on 🔒 permanently while the rotating device saw "success"
(`cabfca5e`: server-authoritative read + a rule making the stranding write unrepresentable).
**Outstanding:** one fixture partner (Ben/5556) is still stranded from that bug — remedy is a single Rotate tap
on Ava (5554) with the current build, which is a fixture credential op the user must perform.
Phase 2 (forward secrecy for history) = per-family idempotent re-encrypt sweeps + backup re-snapshot under the
new key + both-devices old-key-destruction handshake — plan in
`/home/kaspa/.claude/plans/i-want-you-to-recursive-clover.md`, NOT started (pre-execution review checkpoint).
- **Server-independent anti-rollback freshness.** A malicious server could serve a stale manifest to hide recent
messages; today mitigated by the `generation` counter + a Phase-1 Firestore cross-check. Add a signed/monotonic
freshness signal for the Option-B world.
## Today widget (Batch 3.2 follow-ons)
The Glance "Today" widget shipped (R29): content-free daily-question state + streak, app-lock →
generic mode, taps into the daily question via `MainActivity`'s `app_route` extra. Verified: provider
registers, the `HomeViewModel``TodayWidgetUpdater``today_widget` DataStore bridge writes, headline
logic unit-tested, no crash. Remaining:
- **On-home-screen render check.** Binding a widget to the launcher isn't adb-scriptable; confirm the
Glance composition renders (light/dark) on a device, or add an instrumented test that binds via
`AppWidgetManager`.
- **Remote Config kill switch (`widget_enabled`).** Deferred: the app has **no** RemoteConfig wrapper yet
despite `firebase-config-ktx` being a dep. Build one shared wrapper once (fetch/activate + defaults),
then apply the kill switches the plan wants across the widget, solo mode, and deep links. Until then the
widget degrades safely on its own (generic card + taps into the app).
- **Day-rollover refresh.** Covered today by `updatePeriodMillis=30m` + app-open updates; a WorkManager
day-boundary trigger (plan's suggestion) would make the "Tonight's question is ready" flip crisper.
## Solo / pre-pairing (Batch 2.2 follow-ons)
The core of the pre-pairing experience shipped (R29): unpaired users can answer the daily question solo
(`DailyQuestionViewModel.submitAnswer` already saves to `LocalAnswerRepository` and skips the Firestore
sync when there's no couple), reach it via the Home secondary card, and see an honest `UnpairedLockedCard`
on the Play hub instead of a silent bounce to invite. On pairing, `SoloAnswerMigrator` offers a one-time
"bring your answers along?" consent dialog (`PairingSuccessScreen`) that writes the solo answers into
**Couple Lore** (encrypted) or discards them. Remaining, deliberately deferred:
- **Read-only date-ideas browse when unpaired.** The Date Match / Plan Date tiles still route unpaired users
to invite (they need a couple for swipes). A standalone read-only date-ideas list (the seed is client-side
and `date_ideas` rules already allow any authed read) would let a solo user browse while they wait. Pairs
well with the Firestore-backed catalog work (Batch 4.2).
- **Remote Config kill switch `solo_mode_enabled`.** Not yet wired; `firebase-config-ktx` is already a dep.
- **Convert the remaining silent bounces to `UnpairedLockedCard`.** Wheel / Messages / Answer-history / the
daily-question "Discuss" button still redirect unpaired users to `CREATE_INVITE` without explanation; reuse
the new `ui/components/UnpairedLockedCard.kt` on those surfaces for consistency.
- **Migrate-on-pairing robustness.** `SoloAnswerMigrator` is best-effort per entry (a not-yet-ready couple
key leaves the answer local for a retry), but there's no later retry trigger beyond the pairing-success
screen — add one if telemetry shows keys aren't ready at that moment.
## UI
_(No open UI defects. The P0 onboarding/auth crash filed here 2026-06-28 was fixed + verified live and moved to
`ClaudeReport.md` as **O-ONBOARD-001** — root cause was `painterResource` on the `<bitmap>` `ic_launcher_foreground`
(not the background, as originally guessed); fixed both `OnboardingScreen.CtaSlide` + `AuthVisuals.AuthLogoMark` to use
the raster `closer_launcher_foreground`. The "Add to Bucket List mixed dark/light" item is also fully resolved — dialog
tokens R16, add-FAB `Color(0xFFB98AF4)`→`primary` R18.)_
## QA
### Content pipeline & app size (2026-07-14)
**✅ DONE — the seed→app path works and is now guarded.**
- **`build_db.py` produced an unloadable database** (2 tables vs `AppDatabase`'s 4 entities, no `room_master_table`);
with `createFromAsset` + no migrations/fallback that is a first-launch crash for every user. Hence the folk rule
*"never run build_db.py"* — a broken tool guarded by memory. Rebuilt to derive schema + identity hash from Room's own
exported schema, so it follows future schema changes instead of drifting again. Content problems are now **hard
failures** (parse error, 0 questions, missing category, dup id/text, non-integer depth, choice without options) —
previously it silently skipped a whole pack or filed it under a bogus `unknown` category. Guarded by
`AssetDatabaseVerifyTest`.
- **The shipped bank was 2,292 questions out of sync with the JSON** (6,103 → 3,811). Rebuilt: **+11 wildcards** (the
`DailyModeResolver` feature was built but had *zero* content, so wildcard days were dark), **+150 quality_time** (a
category the app could not show at all), `pack_id` populated on all 3,811 (the column was plumbed to the domain model
but always NULL — it's the hook a purchased/imported pack needs).
- **Scale labels were blank in the shipped app** — the DB stored `min_label`, the only parser
(`QuestionMapper.parseAnswerConfig`) reads `minLabel`, so `optString("minLabel","")` returned `""` and the UI rendered
bare numbers. The rebuild emits the shape the parser reads.
- **Artwork → WebP** (124 files, 104M → 28M on disk; APK 141.9 → 128.8 MB; pack art 26.4 → 11.5 MB). Each file encoded
lossless *and* q95, smaller kept, lossy accepted only at PSNR ≥ 40 dB; dimensions/alpha verified per file. Also
stopped shipping ~4MB of dead weight: `app.db.bak_q4`/`bak_q5` were **tracked inside `assets/`**, and everything under
`assets/` is packaged.
**Still open:**
- **Pack art is oversized for its slot.** 23×2 illustrations at **1024×512** in `nodpi``nodpi` means **no density
split**, so every device downloads all 11.5 MB regardless of screen. The cards render at ~360dp wide, so this is
roughly 3× the pixels needed. Ship per-density variants (or one 2× asset) and the number drops again.
- **Nothing in CI validates the seed JSON.** `scripts/nav-scan.sh`, `theme-scan.sh` and the variety gate inside
`build_db.py` all only run when a human remembers. The catalog gate is the natural first CI addition — it is fast,
hermetic, and would have caught the duplicate text that blocked the importer earlier this cycle.
### From R31 (2026-07-09) — QA-environment observations (not app defects)
- **Add the RevenueCat `test_` SDK key to `local.properties` on the QA emulators.** The paywall currently can't load real offerings there (`RC_API_KEY` absent → `InvalidCredentialsError` → graceful "Couldn't load plans / Try again"). Now that RevenueCat is configured (Test Store + `closer_premium` entitlement, corrected this cycle), seeding the `test_…` key would let QA drive the *live* paywall (plan pills, purchase flow via the Test Store) instead of only the gate + the error state. The gate itself (free → Paywall) is verified; this unlocks the money-adjacent surface short of a real device.
- Minor: reinstalling the debug APK rotates the FCM token; the app re-registers a fresh one on foreground but stale tokens linger for ~33h until `pruneTokens` clears them on a real send. `qa/qa_push.js` now selects the newest token (was `docs[0]`), so the entrypoint smoke no longer false-FAILs after a reinstall.
### From R29 games review (2026-07-07) — reconciled 2026-07-08 after R30 (C1C4) + the functions v2 deploy
**✅ Shipped since filed (R30 batches C1C4 + `f2321d35`; server-side pieces live as of the 2026-07-08 deploy):**
server daily-question pool **seeded** and selection made **server-authoritative, mode-aware, deterministic**
(mirrors the client's `DailyModeResolver` weekday modes + epochDay offset — kills the DQ-MISMATCH class and
cross-timezone partner splits at the root) · **banner lifecycle BANNER-LIFE-001** (entering a session consumes
its banner via `GamePromptController.consumeForSession`; stale banners replaceable — C1) · **retention set:**
HowWell role-swap rematch CTA ("Your turn — let {name} guess you") · `NextBeatCard` return-tomorrow beat under
every game's results · Desire Sync per-couple seen-question memory (`SeenDesireQuestionStore`) · deterministic
per-couple Date Match deck shuffle + skip-already-swiped · `daily_fun_mc` excluded from the HowWell prediction
pool · Challenges "Day N unlocks tomorrow 🌙" teaser instead of spoiling day-N+1 (C2 `statusDay`).
Still open:
- **Generic session-game engine** — ThisOrThat/HowWell/DesireSync embedded VMs are ~80% clones of one ~250-line state machine (load/create/join/submit/retry/observe/abandon) and their data sources are structural copies (`EncryptedAnswerMapDataSource<T>`). Migrate one game first (ToT), full live regression, then the rest.
- **Generic replay VM/screen** — 3× ~60-line clones (ToT/DS/HW replay).
- **Shared waiting/length-picker/error composables** — each game re-implements WaitingForPartner/length chips/error screens despite shared components existing.
- **Premium-check invocation helper** — CouplePremiumChecker is invoked 4 different ways across ~10 call sites with inconsistent failure fallback.
- **GameConstants** — divergent ADVANCE_DELAY_MS (420 vs 380), QUESTION_COUNT, SPIN_DURATION_MS per file; follow SessionLength's pattern.
- **DispatcherProvider injection** — Dispatchers.IO/Default hardcoded everywhere (testability).
- **Error-surfacing standardization** — BucketList/MessagesInbox/PlayHub/AnswerHistory/WeeklyRecap/Onboarding VMs swallow load errors (blank screens); adopt uiState.error + ErrorState for reads, _events snackbar for writes (ConversationViewModel pattern).
- **Retention analytics** — first-answer / reveal-viewed / waiting-abandoned / notif-tap events: the one piece of the R29 retention list not built.
- **Content retag** — `daily_fun_mc` rows still carry `sex='neutral'` in the asset DB (**re-verified 2026-07-14 on the rebuilt DB: all 511**); after C3's HowWell-side exclusion this is purely Desire Sync pool hygiene (benign today via the binary filter). _(Count corrected 2026-07-14: sexual_preferences non-binary configs are now **110 of 150**, not 48 — the pack was rewritten to the 150-cap since this was filed. Still the same question: intended?)_
- **npm audit** — 9 moderate remain after the 2026-07-08 firebase-functions v7 bump (retry-request/teeny-request via @google-cloud/storage, i.e. **firebase-admin** transitive — untouched by the functions bump); revisit on the next firebase-admin major. _(Re-verified 2026-07-11: still 9 moderate, unchanged set; deliberately not running `npm audit fix` — no lockfile churn on the production backend for documented moderates.)_
- **`scheduledOutcomesReminder` scaling cap** — the daily cron scans `couples` with a flat `.limit(200)` and no pagination (`functions/src/couples/scheduledOutcomesReminder.ts:35`), so couples beyond the first 200 silently never get 30/60/90-day outcome nudges. Fine pre-launch; **before the userbase approaches ~200 couples**, paginate with the same `orderBy(__name__)+startAfter` page loop `assignDailyQuestion` already uses (see `assignDailyQuestion.ts` PAGE_SIZE pattern).
- **Compose stability: adopt `ImmutableList` for `*UiState` list/set fields (codebase-wide).** Strong-skipping (K2 default) is on, and the Home refactor added `@Immutable` to the Home card models — but `List<>`/`Set<>` params still only reference-skip. Add the `kotlinx.collections.immutable` dependency (JetBrains, first-party) and switch `*UiState` list/set fields to `ImmutableList`/`ImmutableSet` (with `.toImmutableList()` at each VM construction site) so repeated card/list params become structurally skippable. **Do it consistently across all ~20 screens in one pass — not per-screen — to avoid a snowflake.** `ui/home/HomeModels.kt` is already partly there (`@Immutable` on `HomeAction`/`PendingActionCard`/`HomeCategorySummary`), so Home is the natural reference implementation.
- **Test `HomeViewModel.loadHome` (the honest follow-up to the Home decomposition).** The R31 Home split made
Home navigable, extracted its pure action logic into `HomeActionMapper` (12 JVM tests) and added a render
smoke — but deliberately did **not** touch `loadHome`, the ~180-line Firestore orchestration (5 parallel
retention fetches + streak/outcome calc + error handling) that remains the **highest-risk, still-untested**
code on the screen. The render-smoke exercises the render path, not `loadHome`'s data assembly. Real next
priority: a `HomeViewModel` test with fake repos covering the parallel-fetch success/partial-failure paths.
Needs the repo interfaces faked (no Hilt/Firebase) — its own follow-up, not a Home-UI change.
- **De-duplicate `dueFollowUpDay` across Home / Settings / YourProgress (3 copies).** The 30/60/90-day
outcome-reminder day math is implemented three times (now `HomeActionMapper.dueFollowUpDay`,
`SettingsViewModel`, `YourProgressViewModel`), all on the API-safe `Instant.atZone().toLocalDate()` path
since R31. Lift to one shared helper so the three can't drift; low-risk once `loadHome` has a test around it.
Improvement & feature ideas surfaced while QA-testing as a consumer (each works today — none are defects).
- **Tier 3: Compose screenshot diff for visual regression.** The static scanner in `scripts/theme-scan.sh`
catches ~80% of light/dark theme mismatches, but it cannot detect *compositional* failures: a theme-token
color used on the wrong surface, a gradient with hardcoded light stops, or subtle contrast collapse.
Implement a screenshot pipeline with Roborazzi / Shot / papAROS that renders every `AppRoute` in both
light and dark, pixel-diffs them, and fails on unexpected white backgrounds or invisible text. When done,
run it in CI against every UI PR.
**No longer hypothetical — 2026-07-14 evidence.** A single day's content change surfaced five visual defects,
and **every one was caught only by a human reading a screenshot**; no scanner or test flagged any of them:
22 of 23 category glyphs silently fell back to a star (icon_name became a vocabulary `CategoryGlyph` didn't
speak), the raw `icon_name` leaked into a user-facing chip as "Chat Bubble Outline", pack names broke
mid-word ("Communicatio/n"), the pack detail page drew two back arrows, and pack art sat as a hard-edged
tile. They share a shape: **the UI stays renderable and simply shows the wrong thing**, so the compile, the
unit suite, and `theme-scan.sh` all stay green. A pixel diff over `PairedHomePreviewScreen` + the pack
library/detail routes would have caught all five. This is the highest-value test gap in the app — it guards
the class the current tooling is structurally blind to. (Nav dead-ends are now covered separately and
statically by `scripts/nav-scan.sh` — see C-NAV-004; that one didn't need pixels.)
- **🟡 STARTED (R20) — Instrumented / on-device test coverage (was 0 androidTest).** First cut shipped:
`app/src/androidTest/.../ui/FirstRunRenderSmokeTest.kt` — an on-device **Compose render smoke** of the first-run
screens (`CtaSlide` + `AuthLogoMark`, light + dark), the exact `painterResource` logo sites that crashed every fresh
install in **O-ONBOARD-001**. It's the net for the "composes fine, crashes on first paint" class the JVM unit tests +
static scanners structurally can't catch. **Proven (R20):** passes green on-device, and FAILS with the original
`IllegalArgumentException: Only VectorDrawables…` when the bug is reintroduced. Infra added: `testInstrumentationRunner`
+ `ui-test-junit4` (build.gradle.kts); also un-blocked the androidTest source set (the stale `CanonicalVectorCaptureInstrumentTest`
couldn't compile against `private deriveKey` → made it `@VisibleForTesting internal`). Run: `./gradlew :app:connectedDebugAndroidTest`.
**Grown since (2026-07-14) — now 3 real suites, same "prove it fails when the bug returns" bar:**
`ui/home/HomeContentRenderSmokeTest.kt` (R31 — renders the VM-free `PairedHomePreviewScreen` light+dark, the net for
the Home decomposition) and `data/local/AssetDatabaseVerifyTest.kt` (4 tests — forces a real Room open of the bundled
`app.db` via `openHelper.readableDatabase`, which is what triggers the copy + schema/identity check, then asserts
content, no orphan `category_id`s, integer depth, and the camelCase `answer_config` contract). That last one exists
because `build_db.py` had drifted into producing a database Room could not load at all — a first-launch crash for
every user that nothing would have caught.
**Still to grow:** all three are Hilt-/Firebase-free (leaf composables + a raw DB open) — extend toward a fuller
sign-in → pair → answer daily Q → open a game → send a message flow (needs a Hilt test runner + fakes), and wire
`connectedDebugAndroidTest` into the per-round gate / CI alongside `qa/entrypoint_smoke.sh`. **Note the running
theme:** every one of these suites was written *after* the class of bug it guards had already shipped. *Prompted by:*
the QA-plan render-coverage gap (O-ONBOARD-001 escaped because nothing rendered a composable).
- **✅ DONE — Consistent brand glyphs across game cards + waiting surfaces.** G-set + G2 (17 glyphs) in
`res/drawable-nodpi/glyph_*.xml`; **13 wired + verified live:** every Play-hub card (This or That, How Well, Desire
Sync, Connection Challenges, Memory Lane, Date Match, Plan Date, Question Packs, Bucket List, Past Games — Spin the
Wheel keeps its full illustration), WaitingForPartner per-game glyph, and Settings (Subscription/Security/Privacy/
Delete). 4 unused have no clean slot (notif uses `ic_notification_closer`; Today uses hero art; quiet-hours uses its
illustration; no export-data row exists). Full map in `ClaudeBrandingReview.md`. _(This-or-That backdrop redesign is
Codex C-DARK-UI-001.)_
- **Minor proactive-notification gaps (low priority).** ~~No push when a partner *joins* your active game
(`partner_joined_game`)~~ → **BUILT + LIVE (deployed 2026-07-08 with the functions v2 round):** the non-starter
opening an active session writes `joinedByUsers` (rule-gated), `onGameSessionUpdate` notifies the starter
"<Name> joined your game" with the joiner's avatar (one-time `joinNotifiedAt`); foreground shows the standardized
in-app banner. (Same deploy also stopped the false "X finished — see your results!" push for abandoned/quit
sessions — a real completion requires both uids in `completedByUsers`.) **Still open:** no push when a partner
*ends/abandons* a game (`game_ended`/`game_abandoned`) — the other partner sees it in-session /
on WaitingForPartner, so nothing's broken, just less proactive. *Prompted by:* Pass E (R8) inventory.
- **Clarify Connection Challenges day-progress when partners are out of step.** If one partner catches up a *missed* day ("Pick it back up") while the other doesn't, the two devices show different **"Day N of 7"** (seen R10: QA Day 4 vs Sam Day 3) even though the 🔥 streak stays in sync on both. Not broken (plausibly individual-pace-through-the-series by design), but two people in the same shared challenge seeing different day numbers is confusing — consider a shared "you're on Day N together" framing or a clearer caught-up/ahead indicator. *Prompted by:* Pass B (R10) Connection Challenges playthrough.
### Security hardening (defense-in-depth — not vulnerabilities; rules already hold)
> **Canonical security doc: [`SECURITY.md`](SECURITY.md)** (2026-06-29) — full threat model, what's
> protected vs. exposed (metadata), known caveats, and the **prioritized hardening roadmap**. The P0
> there (**enforce App Check on the backend**, independent audit, release hardening), P1 (encrypt
> profile metadata, opt-in telemetry, recovery-phrase UX, biometric re-lock), and P2 (cert pinning,
> multi-device keys, data export, key rotation) are the authoritative list; the two items below are the
> older notes that fed into it.
- **Enforce App Check on Firestore (currently OFF).** Round 7 raw-API test: an authenticated request with **no App
Check token** (raw Firestore REST) returned `200` for a member — so rules are the *sole* gate. Rules correctly deny
non-members/cross-couple (all `403`), so this is not a live hole, but enabling App Check enforcement on Firestore
would block non-app clients entirely (defense-in-depth). *Prompted by:* R7 D3 raw-API angle.
- **✅ DONE (R21) — Biometric app-lock now re-arms on background/timeout (was: only cold-start/process-death).**
`MainActivity` observes the lifecycle: while the lock is on and the session is unlocked, it records when the app is
backgrounded and **re-locks if it returns after >60s away** (`BIOMETRIC_RELOCK_GRACE_MS`) — so a picked-up, already-open
phone re-prompts, not only on Activity recreation. The grace window avoids re-locking on quick task-switches (the
biometric prompt, photo picker, share sheet). Code-complete + compiles; **live re-lock not yet driven** — emulators have
no enrolled biometric/PIN, so verify on a physical device. (SECURITY.md rec #7.)
> Artwork to generate (ChatGPT prompts, house-style-matched) lives in `ClaudeBrandingReview.md`, not here.
## Backend — Cloud Functions v2 follow-ups (migrated + deployed 2026-07-08)
All 34 functions run as **2nd gen** in `closer-app-22014` (plus `onUserDelete`, intentionally v1 — gen 2 has
no `auth.user().onDelete`). Deploy-day learnings + deferred items:
- **Restore full vCPU + concurrency at launch (quota).** The project's default **Cloud Run regional CPU quota**
can't fit ~35 services at v2's default 1 vCPU/instance — deploys failed container healthchecks with
*"Quota exceeded for total allowable CPU per project per region"*, so `functions/src/options.ts` pins
`cpu: 'gcf_gen1'` (gen1 fractional tiers, 256MiB → ⅙ vCPU), `concurrency: 1`, `maxInstances: 5`. Costless at
dev scale. **At launch:** request the Cloud Run CPU quota increase (console → IAM & Admin → Quotas,
us-central1), then drop the `cpu`/`concurrency` overrides to restore 1 vCPU + concurrency 80, revisit
`maxInstances`, and consider `minInstances: 1` on the cold-start-sensitive callables (`checkDeviceIntegrity`,
`createInviteCallable`, `acceptInviteCallable` — app startup + pairing are first impressions).
- **Deploy-failure recovery pattern (keep for ops).** A background-trigger create that fails mid-deploy (quota,
Eventarc propagation) leaves an **orphaned `https` service**; retries then error *"Changing from an HTTPS
function to a background triggered function is not allowed."* Fix: `firebase functions:delete <name>` the
stray, then redeploy it. Diagnose with `firebase functions:list` — healthy Firestore triggers show
`google.cloud.firestore.document.v1.*`, strays show `https`. (First-ever gen-2 deploy also needs Eventarc
service-agent IAM to propagate — the "retry in a few minutes" failures were this.)
- **✅ DONE (2026-07-08) — Node runtime bumped 20 → 22.** All 35 functions redeployed in-place on **nodejs22**
(incl. the gen-1 `onUserDelete` — accepted cleanly); both `backend-ci.yml` node pins bumped to match. Verified:
`functions:list` runtime column, callable cold-boot smoke (UNAUTHENTICATED JSON = our code running), clean logs.
nodejs24 was evaluated and rejected: **2nd-gen only**, and the runtime is codebase-wide (gen-1 `onUserDelete`
blocks it). **Next forced bump:** Node 22 is deprecated 2027-04-30 / decommissioned 2027-10-31 — by then either
nodejs24 supports gen 1, or `onUserDelete` needs migrating off the gen-1 auth trigger first.
- **RevenueCat webhook deploy (gated on the signing secret).** The v2 `revenueCatWebhook` is code-complete
and its export is now **live** in `functions/src/index.ts`, but it is **not deployed yet** — deploy waits on
the secret. Auth is **HMAC-SHA256** (RevenueCat's webhook signature verification): the handler reads
`X-RevenueCat-Webhook-Signature: t=<ts>,v1=<hex>` and verifies `HMAC-SHA256("<ts>.<rawBody>")` against
`REVENUECAT_WEBHOOK_SECRET`, constant-time, with a ±5-min timestamp replay guard. Because that
`defineSecret` is validated codebase-wide at deploy (a missing secret fails *every* function's deploy, even
with `--only`), the secret must exist first. To deploy: ① in the RevenueCat dashboard create the webhook +
enable signature verification, copy the signing secret, ② `firebase functions:secrets:set REVENUECAT_WEBHOOK_SECRET`,
**delete the old dormant v1 `revenueCatWebhook` first** (still deployed; same-name v1→v2 is blocked
in-place), ④ `firebase deploy --only functions:revenueCatWebhook`, ⑤ point RevenueCat at the function URL and
send a test event. (The `closer_premium` entitlement already exists in the RevenueCat project.)
## Roadmap — features & strategy (consolidated from the old `FUTURE.md`, 2026-06-28)
- **Two-sided date planning (deliberate not-now, 2026-07-16).** The original Date Builder vision — each
partner records preferences (time/budget/duration) and the app assembles a plan from both — was never
built past a write-only stub (N-002). The dead plumbing (`date_plan_preferences` datasource/DAO/rules,
the placeholder `assemblePlanSuggestion`) was REMOVED in Batch F; the Room table `date_plan_preferences`
remains schema-frozen in the asset DB (identity hash) but has no reader/writer. If this returns, design
it fresh: both-partner input, a real merge, and a reader surface — don't resurrect the old plumbing.
Launch checklist: one admin sweep for stray pre-R15 `date_plan_preferences` docs (now default-deny).
**Unbuilt games (Play Hub is live; these remain):**
- **Would You Rather / Truth or Dare** — tiered sweet→spicy, consent-gated; reuses the deck + match engine. Strong **premium "spicy" tier** lever.
- **Daily Sync / Rose-Bud-Thorn** — one-tap emotional check-in, see partner's; small/new, free retention driver.
**"2026, not 2019" differentiators (strategic):**
- **AI-personalized prompts** — server-side (latest Claude) generate/weight prompts from the couple's history/season/unexplored topics; add a `generatePrompts` callable (Cloud Functions already in place). Biggest modern lever; pairs with content-metadata routing below.
- **Async + real-time hybrid** — partner-presence ("they're here now") for live co-play, everything still async-friendly (long-distance).
- **Multi-modal answers** — voice/photo answers (esp. Memory Lane + daily check-ins).
- **Home/lock-screen widgets / live updates** — glanceable today's-prompt + partner status (Glance/Wear surface).
- **Gentle gamification** — forgiving "gentle streaks" (grace days) + shared wins, not loss-aversion/guilt.
- **Consent-first spicy content** — Intimacy/Dare tiers opt-in, double-confirmed, reveal-only-on-overlap.
**Cross-platform:** Android-first is fine for MVP; iOS is the strategic gap (couples split devices). Decision + plan live in `ClaudeiOSPlan.md` — don't start before release/trust polish.
## Product polish (consolidated from the old `FUTURE.md`)
- **Skeleton/loading states over bare spinners (P8).** `LoadingState` exists but many screens still use a bare `CircularProgressIndicator`. Add skeletons for question lists, game histories, home modules, paywall offerings, sync/reveal waits. Acceptance: no primary route shows an isolated spinner on an otherwise blank screen.
- **Paywall / store value framing (P12).** Paywall has benefits/restore/legal/RevenueCat; needs stronger value framing, real offering/trial clarity, screenshots/previews, and test coverage before release. (Overlaps Pass K/O.)
- **Content metadata & personalization (P13).** The bank is clean; the next leap is *routing*, not more questions — tag mood/depth/relationship-stage/conflict-safe/intimacy-level/time-needed and extend the selection APIs so prompts adapt to skipped topics, relationship length, and recent answers.
- **Copy convention + i18n gate.** The app renders copy from hardcoded Compose literals; a half-built `strings.xml` catalog (55 unwired entries) was purged (2026-07-09), and voice copy is being centralized into typed Kotlin catalogs (`ui/brand/CloserBrandCopy.kt` = privacy rotator, `ui/brand/CloserCopy.kt` = product/paywall voice) — one reviewable home, compile-safe, no `stringResource()` friction. Convention: **voice copy → catalog; generic chrome ("Continue"/"Restore") stays inline.** Deliberately NOT localized. **Hard pre-localization gate:** the day translation is on the roadmap, migrate the Kotlin copy catalogs → `res/values/strings.xml` in one pass (retrofitting i18n across hardcoded Compose after launch is far more expensive). First slice done: paywall + subscription; remaining voice copy (onboarding, invite share, age-gate) migrates incrementally as those screens are touched. (Also surfaced: the paywall vs. subscription **benefit lists diverge** — noted in `CloserCopy.kt` for a copy decision.)
## Release / pre-ship (consolidated + re-verified 2026-06-28)
- **Real release config before any store submission.** Confirmed still open: `app/build.gradle.kts` `versionCode = 1` / `versionName = "0.1.0"` and `core/navigation/ExternalLinks.kt` legal URLs are placeholder TODOs (`https://closer.app/privacy|terms|subscription-terms`). _Already done:_ a release-blocking Gradle check now fails the build if `RC_API_KEY` is unset/placeholder (`build.gradle.kts:106110`), so the old "add a gradle guard" sub-item is closed. Remaining: set real version, real legal/support URLs, real RC key + verify offerings/purchase/restore on internal testing. (Tracked by **Pass O** release-readiness.)
## Help & support surface (consolidated — old "Notes to Consider")
A future Help/Support screen could include: contact support · report a bug · send feedback · FAQ · subscription/billing help · pairing help · recovery-phrase/account help · app version + build number · optional "copy diagnostics" button.
<!--
Completed (2026-06-27, Future.md backend pass — deployed + verified live):
- subscription_entitlement_changed push — new Cloud Function `onEntitlementChanged` (users/{uid}/entitlements/premium
onWrite, edge-triggered inactive→active) notifies the PARTNER "X upgraded — you both have Premium now"; skips if the
partner already had premium. Client type `SUBSCRIPTION_CHANGED` added (routes to Subscription). Verified: QA premium
ON → Sam queued the push; Sam ON while QA already premium → no redundant notify. Deployed to closer-app-22014.
- users/{uid} update-rule field allowlist — `allow update` now uses `affectedKeys().hasOnly([...12 model/aux fields])`,
blocking junk keys + `hasPremium`. Verified raw-API: allowlisted field PATCH 200, junk field 403, hasPremium 403.
(Keep the list synced with User.kt + FirestoreUserDataSource — guard noted in firestore.rules.) Deployed.
Completed (2026-06-27, Future.md fix pass):
- Daily-reveal isRevealed retry — on reveal load, if local says revealed but the server doc's isRevealed is still
false (a prior best-effort markRevealed failed offline/transiently), re-issue markRevealed idempotently so
onAnswerRevealed fires and the partner gets the "opened your answers" push (AnswerRevealViewModel.load()).
- Brand glyphs — G-set copied to res/drawable/; Date Match + Plan Date cards wired to glyph_date_card_heart
(remaining surfaces tracked above + in ClaudeBrandingReview.md).
- Memory Lane "title runs into preview" — investigated, NOT a bug: the capsule list renders title (own line, ellipsized)
+ open-date; sealed content is never previewed. The R12 "run-on" was adb-typed test data landing in the title field.
Completed (2026-06-25) and removed from the backlog:
- Inclusive sex/gender options in onboarding (Female/Male/Non-binary/Prefer not to say) + honest copy
(Desire Sync is already gender-neutral, so no tailoring fallback was needed).
- Turn-aware Home "waiting to play" copy ("Your turn to play.").
- Partner-action/results pushes exempt from the weekly promotional rate-limit ceiling.
- Suppress the redundant results / "partner finished" push when the recipient is already on that
game's screen (new ActiveGameSessionMonitor mirroring ActiveThreadMonitor).
- Friendlier paywall error state: retry-with-backoff, offline-aware message, Continue hidden until plans load.
- Wire iOS illustrations into Android empty states — already wired (history, invite, daily-question,
together-empty, partner-activation all show their illustration).
- Rotating privacy messages on sign-up + forgot-password (login already had it).
-->