Closer/Future.md

30 KiB
Raw Blame History

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. A restore_requests doc left at READY (partner wrapped the key but the recipient never completed) leaves an ECIES keybox — ciphertext sealed only to the recipient, useless to anyone else, but untidy. Add a scheduled cleanup of expired requests (and their keyboxes). R24-b already enforces expiry at fulfil time and deletes any stale request before a re-request.
  • Owner-alert precision. The R24-b "was this you?" self-alert also reaches the requesting device (harmless). Optionally exclude it via a client-written token hint on the request (would add a rules-allowed field).
  • Couple-key rotation / forward secrecy. A couple-key compromise exposes all history incl. backups (no FS today). Add rotation (both devices re-key) — hard but the right long-term hardening.
  • 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 HomeViewModelTodayWidgetUpdatertoday_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

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 retagdaily_fun_mc rows still carry sex='neutral' in the asset DB; after C3's HowWell-side exclusion this is purely Desire Sync pool hygiene (benign today via the binary filter). 48/150 sexual_preferences items are non-binary configs (Wheel-only) — 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.

  • 🟡 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. Still to grow: it's Hilt-/Firebase-free leaf-composable rendering only — 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. 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 " 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 (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)

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.