Compare commits

...

22 Commits

Author SHA1 Message Date
null bbd7ef0806 docs(qa): define per-pass chunk granularity (sub-batch to one context window)
Round-1 calibration: A & D fit as single batches; B/C/E overflowed and got deferred.
Add a batch-sizing table: B=1 game/chunk, C=1 screen-group/chunk, D=~4 sub-areas,
E=3-5 types/chunk, F=1 dimension/chunk. Chunk = largest unit that finishes+commits in one
window; commit + run-state update per chunk.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 21:47:04 -05:00
null 84dd5f1152 docs(qa): senior-QA review additions — Pass F, env/matrix, migration, iOS-native dims
- Pass F (cross-cutting): concurrency/realtime races, lifecycle/process-death, network
  resilience, idempotency/rapid-input, time-dependent (daily rollover/streaks/capsules),
  account/couple lifecycle, crash reporting.
- Methodology: prefer Firebase emulator/staging over prod; device/OS matrix; automate the
  smoke; test-data hygiene.
- Pass D7: encryptionVersion 0->1->2 migration. Reporting/re-QA now A-F.
- iOS: iOS-native QA dims (Dynamic Type/VoiceOver/safe-area/edge-swipe-back/sizes),
  real-device/sandbox needs (App Attest/APNs/StoreKit), crypto golden vectors.
- Logged D-OBS: PERMISSION_DENIED on outcomes/challenges/capsules to investigate in Round 2.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 21:44:02 -05:00
null e907453f3f docs(plan): add Part 2 (build iOS to parity) + Part 3 (iOS QA) — ClaudeiOSPlan.md
Program now Part1 Android QA -> Part2 iOS build -> Part3 iOS QA + cross-platform.
iOS = native SwiftUI (iphone/ scaffold, audit stale at v0.2.0). Decisions: full
Tink-compatible E2EE (Android<->iOS decrypt), working-parity build (no App Store).
Hard constraint: iOS build/run/QA needs macOS (not this Linux box) — Linux = author
Swift + refresh audit only; compile/run/QA deferred to a Mac.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 21:37:26 -05:00
null 16ba464752 docs(qa): autonomous run-to-completion mode — never stop; unblock by fixing; finish to flawless
Adds Execution-mode directive: run all passes -> fixes -> re-QA continuously to a flawless
round without checking in; fix anything that BLOCKS progress inline (stale data, crash, build
break, broken nav) to keep going; context limits = checkpoint not stop. Only a denied gated
action (prod deploy / admin write / entitlement toggle) may be surfaced, after all other work.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 21:28:45 -05:00
null 99f0ae0c49 docs(qa): Pass C also checks navigation from every entry point + back-stack/double-back
UI review now verifies each screen opens correctly from ALL its entry points (inbox/Discuss/
notification, Play/notification, paywall from each gate) and that back (system + in-app)
returns correctly with no dead-ends, exit-app surprises, or two-back/duplicate-stack issues.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 21:26:23 -05:00
null f121eab67f docs(qa): require a full one-time playthrough of each game (not just launch)
Pass B now mandates playing each game end-to-end on both devices (start -> every step ->
finish/reveal/results); launch-only = partial. Reflected in playbook, report run-state,
and coverage (full playthroughs owed in Round 2).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 21:24:08 -05:00
null 6ca65ce7e9 qa(round1): close out — all P0-P2 fixed; P3 + deferred coverage tracked for round 2
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 21:08:22 -05:00
null ce12abb1a6 fix(notifications): route daily_question + challenge_day_ready taps (E-001 P2)
Client fromRemoteType mapped only daily_question_reminder/challenge_waiting; functions
send daily_question/challenge_day_ready too. Tapping those now deep-links to Today /
Connection Challenges. Also records Pass C (main screens clean) + Pass D (security clean).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 21:07:32 -05:00
null 58208fd443 qa(round1): A-001 fixed+verified (couple-shared premium); A-003 P3 logged (static badge)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 20:53:39 -05:00
null e8892a9669 fix(premium): couple-shared premium everywhere (A-001)
Route all feature gates (Play hub, Desire Sync, Memory Lane, Connection Challenges,
Question Packs, wheel category/spin/history) through CouplePremiumChecker instead of
per-user EntitlementChecker. CouplePremiumChecker now exposes isPremium()/hasPremium()
that resolve the partner internally (self OR partner premium). Verified live: Sam premium →
QA enters Desire Sync; both free → QA → paywall.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 20:52:22 -05:00
null c54ceb16c3 qa(round1): Pass B games launch sweep — no crashes; stale session recovery verified (B-001 P3)
This or That / How Well / Connection Challenges / Spin the Wheel launch clean (no FATAL).
A stale active wheel session blocked all games; in-app 'End their game' recovery works.
Full two-device lifecycle partial this round. Report-only.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 20:04:54 -05:00
null 452aaf787a qa(round1): Pass A complete — couple-shared premium gap (A-001, P1)
Only chat uses CouplePremiumChecker; all other gates are per-user → a free partner of a
premium user stays locked. Confirmed live (Sam premium, QA still locked on Desire Sync +
Memory Lane). Report-only.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 19:59:04 -05:00
null 64f0a7e6c8 docs(qa): save full-app QA playbook (5 passes: premium, games, visual, security, notifications)
Reusable QA → fix → re-QA plan. Report-only passes with severity labels, then fix
one-at-a-time by severity, then re-QA until flawless. State/resume lives in ClaudeReport.md
+ ClaudeQACoverage.md. Not yet executed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 19:43:19 -05:00
null 6580299f05 feat(packs): route pack Discuss to the unified conversation
Replace the orphaned in-thread QuestionDiscussionThread (which wrote to question_threads
and sent no notifications) with a 'Discuss this together' button that opens the conversation
(q_<questionId>), same as the daily flow — so pack discussions are notified and appear in
the Messages inbox.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 18:55:40 -05:00
null 7a9ff31ae6 feat(chat): couple-shared premium gating for sending media + reactions
CouplePremiumChecker ORs self.isPremium with a live read of the partner's entitlement
doc (reactive). Composer photo/camera/voice buttons + keyboard GIF/sticker insert + the
reaction action gate on canSendMedia: locked buttons show a lock badge and route to the
existing PaywallScreen (with a chat_media paywall analytics event). Text/viewing/receiving
stay free. Rules: paired partner may read the entitlement doc. Verification pending deploy.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 18:52:50 -05:00
null f29d4699ca feat(chat): typing indicator
Debounced typing flag (typing:{uid:ts}) on the conversation doc, cleared on stop/send/
leave; partner sees 'typing…' with a ~6s TTL safety net (ticker-driven auto-hide). Rules
allow members to write the typing field. Live verification pending the Phase B deploy.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 18:47:39 -05:00
null 5b9596e042 feat(chat): message reactions + delete (unsend) via long-press menu
Long-press a message for a reaction bar (heart/laugh/thumb/wow/sad/fire), Copy (text),
and Delete (author). Reactions stored as a reactions:{uid:emoji} map; delete sets a
'deleted' tombstone ('This message was deleted') and updates the inbox preview if it was
last. Rules: any member may change only reactions; author may set only deleted. Live
verification pending the Phase B rules deploy.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 18:44:13 -05:00
null 7f1b938aa5 feat(chat): read receipts (Seen) via the conversation reads map
Observe the partner's last-read timestamp on the conversation doc; show 'Seen · time'
under the last own message once the partner has read past it. No rules change (reuses reads).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 18:38:42 -05:00
null 3aa182a466 feat(chat): voice playback progress bar + 2-min recording cap
Voice bubbles show a determinate progress bar + elapsed time that advance during
playback (polling MediaPlayer position); recording auto-stops and sends at 2 minutes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 18:35:32 -05:00
null cfea8f0d41 feat(chat): send/upload feedback + message pagination
- Pagination: observeMessages(limit) uses limitToLast(N); a single live window grows
  by a page when scrolled to the top (keeps just-sent messages in view, no merge needed).
- Send feedback: 'Sending photo/voice…' chip above the composer with retry + dismiss on
  failure, plus a snackbar; media uploads fail fast when offline (connectivity pre-check +
  30s Storage retry cap) instead of a stuck spinner.
- Auto-scroll to bottom only on new messages when near the bottom (never on load-older).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 18:32:01 -05:00
null 3544b7a84a feat(chat): message timestamps + day separators
Show a muted clock time under the last bubble of each sender-run (side-aligned),
and a centered Today/Yesterday/date pill between messages from different days.
Falls back to now for a just-sent message whose server timestamp is unresolved.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 18:14:05 -05:00
null 8a68ae3107 feat(chat): compress gallery/camera photos before encryption (EXIF-safe, skip GIFs)
Downscale to 1600px + JPEG 80% on the gallery/camera send path only; keyboard
GIFs/stickers/Bitmoji stay untouched to preserve animation/transparency. Applies
EXIF rotation and falls back to the original bytes on any failure.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 18:10:24 -05:00
25 changed files with 1385 additions and 162 deletions

45
ClaudeQACoverage.md Normal file
View File

@ -0,0 +1,45 @@
# Claude QA Coverage Matrix
> Resume anchor. Status: `todo | pass | fail(→id) | n/a`. See `ClaudeReport.md` run-state header for current position.
> Round 1 in progress.
## Pass A — Couple-shared premium (states: neither / partner-only / self)
| Feature | neither→locked | partner→both unlock | self→unlock | Status |
|---|---|---|---|---|
| Chat media + reactions | pass | pass | pass | pass (couple-shared) |
| Play: Desire Sync | pass | **fail→A-001** | pass | fail→A-001 |
| Play: Memory Lane | pass | **fail→A-001** | pass | fail→A-001 |
| Play: Connection Challenges | pass | fail→A-001 | pass | fail→A-001 |
| Question Packs (premium) | pass | fail→A-001 | pass | fail→A-001 |
| Wheel: Category Picker / Spin / History | pass | fail→A-001 | pass | fail→A-001 |
| Date Match / Plan Date | pass | fail→A-001 | pass | fail→A-001 |
| Subscription screen (own status) | n/a | n/a | n/a | pass (by-design per-user) |
Pass A: **complete** (1 systemic P1). **A-001 FIXED** (e8892a9) — couple-shared everywhere; re-verify each feature in re-QA. New cosmetic A-003 (P3, badge). Subscription screen by-design.
## Pass B — Games lifecycle (start / play / finish + results)
| Game | starts | plays | finishes/results | no crash | Status |
|---|---|---|---|---|---|
| This or That | pass (launch) | partial | todo | pass | launch ok |
| How Well Do You Know Me | pass (launch) | partial | todo | pass | launch ok |
| Desire Sync | n/a (premium) | todo | todo | todo | needs premium toggle |
| Connection Challenges | pass (launch) | partial | todo | pass | launch ok |
| Memory Lane | n/a (premium) | todo | todo | todo | needs premium toggle |
| Spin the Wheel | pass (launch) | partial | todo | pass | launch ok |
| Date Match | todo | todo | todo | todo | todo |
_Note: stale active session blocked games (B-001); cleared via in-app "End their game" (recovery verified)._
**REQUIREMENT (updated): each game must be played ONE COMPLETE time through on both devices (every step → finish/
reveal/results), not just launched.** All rows above are currently `launch ok / partial` only → **full playthrough
still owed for every game** in Round 2 (premium games need a premium toggle). A launch-only row counts as `partial`, not `pass`.
## Pass C — Visual (light + dark), all ~50 routes
_todo — enumerate from AppRoute.kt; 5554=Dark, 5556=Light. Main tabs pass; deep/stateful screens owed._
**Also owed:** navigation from EVERY entry point (each screen via all its links) + back-stack / "double-back"
(system back + in-app back to correct place from each entry; no dead-ends, no exit surprise, no two-back/duplicate stack).
## Pass D — Security & Encryption (D1D6)
_todo_
## Pass E — Notifications (17 types × {foreground, background, killed} + tap-to-open)
_todo_

250
ClaudeQAPlan.md Normal file
View File

@ -0,0 +1,250 @@
# Claude QA Playbook — Full-App QA → Fix → Re-QA until flawless
> Reusable QA plan for the Closer app. Run report-only first, fix everything, then re-QA until a clean round.
> Progress/state is tracked in **ClaudeReport.md** (issues) + **ClaudeQACoverage.md** (coverage matrix), which are
> the authoritative source of truth. See the Continuity section before resuming.
>
> **Program roadmap:** **Part 1** = Android QA (this doc) → **Part 2** = build the iOS app to Android's current
> parity → **Part 3** = run these same passes on iOS + a cross-platform (Android↔iOS) pass. **Parts 2 & 3 live in
> `ClaudeiOSPlan.md`** (note: iOS build/run/QA requires macOS — not possible from this Linux box).
## Context
Drive the real app on both emulators, verify each thing live, report, fix, re-verify. Five QA dimensions:
1. **Couple-shared premium** — if EITHER partner is premium, **all** premium features unlock for **both**.
2. **Games** — each starts, plays, finishes correctly on both devices.
3. **Full visual pass, light + dark** — every screen, text readable, nothing clipped/invisible.
4. **Security & encryption (cornerstone)** — every private field is ciphertext at rest, rules hold against
non-members, keys/recovery are sound. Findings here default to P0.
5. **Notifications** — all 17 types deliver to the right partner (foreground/background/killed), deep-link
correctly, and leak no private content.
Scope decisions: **exhaustive** visual pass (all ~50 screens, both modes); **full scope incl. pre-pairing** flows
(fresh throwaway account); **couple-shared everywhere** — per-user gates are bugs, fixed by routing through
`core/billing/CouplePremiumChecker.kt`.
**Early known signal:** only chat uses `CouplePremiumChecker`; games/packs/dates/wheel gate on the user's own
`EntitlementChecker.isPremium()` — so premium almost certainly does NOT unlock for the free partner there. Pass A
confirms + enumerates this; the fix phase applies couple-shared everywhere.
## Execution mode — run to completion (autonomous; do NOT stop)
- **Do not stop to check in or ask for approval.** Run all five passes → the fix phase → re-QA rounds **continuously
until a flawless round** (zero open P0P2, Passes D + E clean, every game fully played through, navigation/back-stack
verified). Don't hand control back early.
- **Unblock yourself:** if anything **blocks progress** (a stale/blocking session, a crash, a build break, a missing
prerequisite state, a broken nav path that prevents reaching a screen), **fix it immediately and continue** — even
though passes are otherwise report-only. Blocking issues are fixed inline so the run can proceed; non-blocking
findings are still logged and fixed in the fix phase.
- **"Once executed, complete it":** never declare done before the Definition of Done is met — keep cycling fix → re-QA
until flawless, then stop.
- **Context limits ≠ stopping.** If a context window fills, that's a checkpoint, not a stop: make sure the run-state
header + MD files are current and committed; the resume command continues automatically. Keep the loop alive across
sessions until flawless.
- **Don't pause for "by-design vs bug":** log the ambiguous finding and keep going (don't unilaterally rewrite
deliberate design — the log captures it). Never halt the run to ask.
- **Only true stop = a gated action you cannot perform.** Production deploys, admin Firestore writes/seeds, and
entitlement toggles still need per-occurrence authorization (the classifier enforces this regardless of this doc).
If one is genuinely required to proceed and is denied, do **all** other work first, then surface only that single
blocker — don't halt the whole run for it.
## Methodology (every pass)
- Devices: **5554 (QA)**, **5556 (Sam)**, paired; one **fresh throwaway account** for pre-pairing flows.
- Drive via adb tap/swipe; resolve coords from `uiautomator dump` bounds; downscale screenshots to read;
scan `logcat` for `FATAL EXCEPTION`/ANR on each screen.
- Premium toggled via `scratchpad/set_premium.js` (admin, **user-authorized each time**).
- Theme toggled via **Settings → Appearance (Light/Dark)** (`MainActivity` `ThemeMode`).
- **REPORT-ONLY during passes — never fix mid-pass.**
- **Environment (senior-QA rec):** prefer the **Firebase Local Emulator Suite or a dedicated staging project** over
production — isolates test data, makes seeding / entitlement toggles / D3 negative tests **free** (no gated prod
writes), and avoids polluting real users. Caveat: App Check, RevenueCat IAP, and real FCM/APNs push need real
services — run those against staging/prod with test accounts. (We've been on prod with test accounts — works, but
every seed/toggle/deploy hits the gate.)
- **Device/OS matrix:** don't certify on one emulator only — cover **minSdk + targetSdk**, a **small** and a **large**
screen, and at least one **physical device** (App Check / Play Integrity behave differently on emulators).
- **Automate the regression smoke:** capture the smoke checklist as a runnable script (adb/Maestro) so every round
re-checks it cheaply instead of by hand.
- **Test-data hygiene:** keep known test accounts; clean up artifacts (stray messages/reactions/sessions) between
rounds so they don't masquerade as bugs.
## Continuity & resumability (this effort WILL span many context windows — don't lose state)
State lives in **files**, not memory:
- **`ClaudeReport.md`** = the issue log (committed). Each issue row is **self-contained in text** (repro + expected
+ actual) — screenshots are session-only and won't survive a compaction; never rely on a screenshot path alone.
- **`ClaudeQACoverage.md`** = the coverage matrix: every screen×mode, feature×premium-state, game×lifecycle,
notification×{foreground,background,killed}, each `todo | pass | fail(→issue id)`. The resume anchor.
- **Persistent memory** (`memory/`): QA methodology + exact commands; emulator↔account↔coupleId mapping;
`scratchpad/set_premium.js` + admin tooling; the couple-shared-premium-everywhere goal + the per-user-gate gap.
- **Run-state header** pinned at the TOP of `ClaudeReport.md`, always current: `Round N | Pass X | Chunk Y |
NEXT ACTION: …` — first thing to read, last thing to update before stopping.
- **Stable issue IDs**: `A-001 / B-002 / C-… / D-… / E-…` (pass-letter + number); coverage references the ID for
every `fail`. Never renumber or reuse.
- **Source of truth**: the two MD files are authoritative; the TodoWrite list is scratch for the current chunk only.
Update the MD files + run-state header *before* ending a session.
- **Commit cadence**: commit `ClaudeReport.md` + `ClaudeQACoverage.md` after each pass and each chunk.
- **Chunking**: run small chunks (Pass C one screen-group; Pass A one feature), checkpoint after each.
- **Session-start ritual**: (1) read run-state header + both MD files; (2) `adb devices` shows **both** emulators
online; (3) **installed build == current HEAD** (rebuild+reinstall if unsure — never QA a stale APK); (4) continue
at the first `todo` / unverified-fix.
## Batch sizing — sub-batch each pass to ONE context window (Round-1 calibration)
A pass is a **category**, not a unit of work. Execute each pass as **sub-batches (chunks)**, where a chunk = the
**largest coherent unit that reliably finishes AND commits within one context window, with margin**. End every chunk
with a commit + run-state update. If a chunk starts overflowing, split it; if chunks feel trivial, merge them.
**Why:** in Round 1, A & D fit as single batches, but B/C/E were too large → got cut off → deferred. Sub-batching
prevents half-done/lost work and gives cleaner per-chunk verification + revertable commits.
| Pass | Chunk granularity | ~chunks |
|---|---|---|
| A Premium | free-state gating sweep; then couple-shared verify (mostly code + a few live taps) | 12 |
| B Games | **one game per chunk** — full two-device playthrough + edges + commit | 7 |
| C Visual | **one screen-group per chunk** (both themes, ~610 screens, montage-reviewed + nav/back for that group) — never "all screens" at once (heaviest, image-bound) | 68 |
| D Security | D1 at-rest · D2 rules + D3 negative · D4 keys/recovery · D5D7 appcheck/secrets/leaks/migration | ~4 |
| E Notifications | **35 types per chunk** × {foreground/background/killed} + tap-to-open | ~4 |
| F Resilience | **one dimension per chunk** (concurrency · lifecycle/process-death · network · time · account-lifecycle) | ~5 |
Context-cost tips: prefer **code/admin-read audits** (cheap) before live UI sweeps; **montage** screenshots
(dark|light pairs) to review many at once; keep one chunk = one TodoWrite focus.
## Guardrails & efficiency
- **Never `pm clear` / wipe app data** — breaks the App Check debug token. Pre-pairing QA: sign-out → fresh sign-up.
- **Never run `seed/build_db.py`.** Admin seeds/writes, entitlement toggles, and any deploys are **user-authorized per occurrence**.
- **By-design vs bug:** if a finding may be intended behavior, **log it and keep going** (don't stop to ask; don't unilaterally rewrite deliberate design — the log captures it).
- **Pass C parallelism:** set **5554 = Dark, 5556 = Light** to capture both themes at once.
- Never log decrypted message/answer content.
## Severity scale (label every issue)
- **P0 Critical** — crash/ANR, data loss, encryption/security leak, feature fully broken, premium bypass.
- **P1 Major** — feature partly broken, premium not unlocking for partner, wrong/missing notification, dead-end nav.
- **P2 Minor** — readability/contrast, clipping/overflow/truncation, theme not adapting, inconsistent styling.
- **P3 Polish** — spacing/alignment/copy nits.
## QA passes (Round 1 = baseline)
### Pass A — Couple-shared premium (target: either partner premium → both unlock)
Test each gated feature in 3 states: **neither** premium → locked + paywall; **partner-only** premium → BOTH unlock;
**self** premium → unlock. Toggle Sam premium, confirm QA (free) unlocks; toggle off.
Features: Play-hub games (Desire Sync + any premium-badged), Connection Challenges, Memory Lane; Question Packs;
Spin the Wheel / Category Picker / Wheel History; Date Match / Plan Date / Date Builder; chat media + reactions
(regression — already couple-shared); Subscription/Settings reflects entitlement.
Gated files (for the fix): `ui/play/PlayHubViewModel`, `ui/desiresync/DesireSyncScreen`,
`ui/wheel/{CategoryPicker,SpinWheel,WheelHistory}*`, `ui/questions/QuestionPackLibrary*`,
`ui/dates/{DateMatch,DateMatches}Screen`, `ui/memorylane/MemoryLaneScreen`, `ui/challenges/ConnectionChallengesScreen`.
### Pass B — Games lifecycle (MANDATORY: play each game ONE complete time through)
Games: This or That, How Well Do You Know Me, Desire Sync, Connection Challenges, Memory Lane, Spin the Wheel, + Date Match.
- **A launch/crash check is NOT sufficient. Each game MUST be played one full way through, end-to-end, on BOTH
devices** — start → answer/interact through **every** step/round/question on each device → reach the
**finish/reveal/results** screen → confirm the result renders correctly for both partners. Verify each
intermediate screen and interaction works (selections register, progress advances, both-answered gating,
reveal/scoring/summary correct). Premium games (Desire Sync, Memory Lane) need a premium toggle to play.
- The session lifecycle is exercised by the real playthrough: `status` active→completed; reveal/results correct on both.
- Edges: re-open a completed session, leave mid-game (resume), no stuck session, no crash, logcat clean.
- Game start/finish pushes (`onGameSessionUpdate`) exercised here; full delivery/deep-link audit in **Pass E**.
- **Media permissions** (CAMERA, RECORD_AUDIO): granted works, denied degrades gracefully.
- **Done = every game has one verified complete playthrough** (a launch-only "opens, no crash" row is `partial`, not `pass`).
### Pass C — Visual pass, light + dark, ALL screens
Every route in `core/navigation/AppRoute.kt` (~50), in **both** modes: text contrast/readability (no invisible/
low-contrast), no clipping/overflow/ellipsis breakage, icons visible, backgrounds adapt, controls legible. Groups:
auth/onboarding/pairing (fresh acct); Home (solo + paired); Play + every game; Today + reveal/history; Messages
(inbox + conversation); Packs; Dates (Match/Builder/Matches/Bucket List); Wheel (picker/session/complete/history);
Settings + all sub-pages (Account, Notifications, Appearance, Privacy, Subscription, Relationship, Security, Delete
Account); Paywall; Your Progress/Activity; Recovery.
- **Probe:** `ui/theme/Theme.kt` hardcoded brand colors + chat's custom `closerBackgroundBrush` — verify dark mode
truly adapts; grep screens for hardcoded `Color(0x...)`.
- **States, not just happy path:** empty / loading / error / not-paired where they exist; many need data setup
(seeding is user-gated) — note unreachable states in coverage rather than skipping silently.
- **Readability at scale:** default font size + spot-check largest system font scale on text-heavy screens.
- **Navigation from every entry point:** reach each screen from **all** the places that link to it and confirm it
opens correctly each time — e.g. a conversation from the inbox AND from "Discuss" AND from a notification; a game
from the Play hub AND from a notification; Paywall from each gated feature; Settings sub-pages; reveal from Today
AND from history AND from `partner_answered`. A screen that works from one entry but breaks/duplicates from another = bug.
- **Back-stack / "double back":** from every entry point, **system back AND the in-app back arrow** return to the
correct previous screen — no dead-ends, no exiting the app unexpectedly, and **no screen that requires pressing
back twice** (duplicate/stacked destinations on the back stack = bug). Bottom-tab reselection and deep-link/
notification entries must land with a sane back stack (back → Home, not off the app or a blank screen). Wrong/
double back or a dead-end = **P2** (P1 if it traps the user).
- **D1 At-rest coverage:** admin-read RAW docs/objects, assert ciphertext for every private type — chat text +
`lastMessagePreview` (`enc:v1:`), chat media bytes (Tink `01 69 59 51 f0…`), answers (`sealed:v1:`/`enc:v1:`),
date plans + `date_swipes`, Memory Lane capsules, Bucket List. Also: **wrappedCoupleKey** + recovery material never
plaintext; **invite code (KDF seed) never stored raw**; **no push payload carries private content**.
- **D2 Rules audit (static):** member-only reads, author/server-only writes, ciphertext enforced on every private
field, immutability, **no premium self-grant**, entitlements write:false; re-audit conversations/typing/reactions
+ entitlement partner-read; **no catch-all** `match /{document=**}`; list/query not enumerable; `get()`-rules don't
over-expose; **no legacy plaintext/downgrade path** (`coupleEncryptionEnabled` holds; no disabled-encryption branch).
- **D3 Negative access tests:** a **non-member** account is *denied* reading messages/answers/dates/entitlements,
writing plaintext to encrypted fields, self-granting premium, cross-couple access (live rules or rules-emulator).
- **D4 Key exchange / management / recovery (E2EE crux):** couple key client-generated, only leaves device **wrapped**
(KDF from invite seed; server holds only `wrappedCoupleKey`+`kdfSalt`/`kdfParams`+`encryptedRecoveryPhrase`); **KDF
strength**; Tink AEAD = AES-GCM/256 with **AAD=coupleId**, no weak/custom crypto/nonce reuse; keybox/sealed/commitment
integrity; **recovery-wrap server-blind**; **unpair revokes decrypt**; invites CSPRNG + single-use + expiry.
- **D5 App Check / Functions / secrets:** App Check enforced; callables validate auth+membership; webhook authenticity;
admin-only writes rejected from clients; service-account JSONs never committed; no plaintext/secrets in logcat; temp
files deleted.
- **D6 Leak vectors:** no private content in analytics/crash; `allowBackup=false` + backup rules exclude sensitive data;
deep links re-check membership; clipboard user-initiated; consider `FLAG_SECURE`; repo scan for committed secrets.
- **D7 Encryption migration:** test the `encryptionVersion` paths (0 plaintext → 1 migrating → 2 strict) on a legacy
couple — migration completes without exposing plaintext or losing/garbling old content, and a half-migrated couple
is safe (no mixed read failures, no downgrade). This is the riskiest data path for existing users.
### Pass E — Notifications (every type delivers, deep-links, leaks nothing)
For each: trigger fires → delivered to the **right partner (never self)** → in **foreground/background/killed**
correct channel + copy with **no private content****tap opens exactly the right item** (loaded, not generic Home/
dead-end) → no duplicates → rate limiter (20/day,100/week) doesn't drop legit ones.
Inventory (type → trigger → destination), all 17: `chat_message`(onMessageWritten→conversation, foreground→chat-head
bubble), `partner_started_game`/`partner_finished_game`(onGameSessionUpdate→game/results), `partner_answered`
(onAnswerWritten→reveal), `daily_question`(assignDailyQuestion)/`daily_question_reminder`/`daily_reminder`
(dailyQuestionReminder→Today), `date_match`(createDateMatch→match), `partner_joined`+`invite_created`
(acceptInviteCallable→pairing/home), `partner_left`(onCoupleLeave)/`partner_deleted_account`(onUserDelete→home/
relationship settings), `memory_capsule_unlocked`(scheduled→capsule), `challenge_day_ready`(→Connection Challenges),
`outcome_reminder`(scheduledOutcomesReminder), `reengagement`(reengagement/gameRetention), `gentle_reminder`
(sendGentleReminderCallable), `spki`(identify + confirm handled).
- **Tap-to-open:** every notification opens the **specific item** from foreground/background/killed; tapping in-app
doesn't stack/duplicate; logged-out/unpaired tap is graceful. Wrong/dead destination = P1.
- **Scheduled/time-based:** trigger manually (invoke callable/function or seed due condition — user-gated).
- **Foundations:** FCM token registration on sign-in (`TokenRegistrar`) + `onNewToken`; POST_NOTIFICATIONS prompt +
denied path; channels (`di/NotificationModule`); deep-link routing (`MainActivity.deepLinkRouteFromIntent` →
`AppNavigation`); foreground/background split (`core/notifications/AppMessagingService`).
- Build a delivery matrix (type × {foreground,background,killed}) in ClaudeQACoverage.md. Missed delivery or wrong
deep-link = P1; private content in any payload = P0.
### Pass F — Resilience, concurrency, lifecycle & time (cross-cutting; a 2-user realtime app needs these)
- **Concurrency / realtime races (two partners at once):** both answer the daily question simultaneously; both start
a game / swipe a date / react at the same time; partner acts while you're mid-flow. No lost writes, no stuck state,
no duplicate sessions, reveal still correct. (This is where a couples app breaks.)
- **Lifecycle / process death:** background mid-flow + return; force-kill the app and relaunch (Android may kill the
process) — state/auth/draft restore sanely; deep-link/notification after process death still loads (verified for
chat — extend to all). Rotation/config-change doesn't lose Compose state. Low-memory.
- **Network resilience:** offline / flaky / airplane mid-action across answers, games, dates (not just chat media) —
graceful failure + retry/queue, no crash, no silent data loss, recovery on reconnect.
- **Idempotency / rapid input:** double-tap send/submit, rapid nav, double-start — guarded (no double-send, no crash).
- **Time-dependent behavior:** daily-question rollover (6 PM CST assignment), streak day-boundary + repair window,
capsule unlock times, reminder schedules — test across a date change (manipulate device clock / trigger functions).
- **Account/couple lifecycle:** brand-new (empty) account; unpaired state; pair → unpair → re-pair; partner leaves
mid-session; account deletion cascade; same account on two devices. No orphaned/broken state.
- **Crash reporting:** confirm crashes/ANRs are actually captured (Crashlytics) so field issues surface.
## Reporting → ClaudeReport.md (living QA report)
- Header: date, build, devices, round number + run-state header.
- One section per pass (A/B/C/D/E/F), each a table: **ID | Area | Screen/Route | Mode | Severity | Description | Repro
| Evidence | Suggested fix | Status**.
- Summary: counts by severity. Report only during passes — no fixes recorded until the fix phase.
## Fix phase (only AFTER all passes of the round complete)
- Work strictly by severity: **all P0 → P1 → P2 → P3**.
- **One issue at a time**: implement → `./gradlew :app:assembleDebug` → install both → verify THAT fix live (correct
device/theme) + regression smoke (launch/no-crash, send text, inbox loads, a game opens, **content still ciphertext
in Firestore**) → flip its row to **Fixed** + **commit** (one per issue/cluster) → next. Don't start the next until
the current is verified.
- **Couple-shared premium fix**: replace direct `isPremium()` gates with
`CouplePremiumChecker.coupleHasPremium(partnerId)` in every gated VM/screen (partner-entitlement read rule deployed).
**High regression risk** — re-verify each feature in BOTH self-premium and free states.
- Gated actions (entitlement toggles, deploys) are **user-authorized per occurrence**.
- **New issues found while fixing** are logged (new ID), not silently fixed beyond scope — next re-QA round catches them.
**Definition of done:** a **pass** is done when every coverage row is `pass`/`fail→id`; a **round** is done when all
five passes are done; **flawless** = one full round with **zero open P0P2 and Passes D + E fully clean**. Then stop
(P3s optional). Don't re-open a clean pass within the same round.
## Re-QA loop (until flawless)
After the fix phase, re-run Pass A/B/C/D/E/F (regression + confirm fixes). Repeat **fix → re-QA** rounds until a full
round yields zero P0P2 and Passes D+E fully clean.

View File

@ -1,71 +1,82 @@
# Claude QA Report — Games & Notifications
# Claude QA Report — Full-App QA (living report)
**Updated:** 2026-06-24
**Devices:** emulator-5554 (Device A = QATester) + emulator-5556 (Device B = Sam), paired for real (coupleId `xNd1H2UGUDNqvyrDGgfu`).
**Focus this pass:** every game works end-to-end **and notifications fire correctly on game start + finish.**
> **RUN-STATE: Round 2 (re-QA + deferred coverage) NEXT | NEXT ACTION: re-verify A-001 + E-001 fixes; **play each game ONE complete time through on both devices** (Pass B was launch-only — full playthroughs still owed); then Pass C deep/stateful screens (reveal, wheel session, dates, bucket list, auth/onboarding) in both themes + **navigation from every entry point & back-stack/double-back checks**, full live notification matrix, D3 live non-member test; **Pass F (resilience/concurrency/lifecycle/time/migration)**; investigate **D-OBS** PERMISSION_DENIED on outcomes/challenges/capsules.**
> Round 1 complete (all 5 passes run report-only; P0P2 found were fixed in-line). Fixes: A-001 (e8892a9), E-001 (ce12abb). Open P3: A-003, B-001, E-002.
> **EXECUTION MODE: autonomous run-to-completion — do NOT stop; fix anything that blocks progress and continue; keep cycling fix→re-QA until a flawless round. Only a gated action (prod deploy / admin write / entitlement toggle) that's denied may be surfaced — do all other work first.**
> Playbook: `ClaudeQAPlan.md`. Coverage matrix: `ClaudeQACoverage.md`. Report-only during passes (no fixes until the fix phase).
> Devices: emulator-5554 (QA=`Y05AKO`) + emulator-5556 (Sam=`imDjjO`), paired (coupleId `Xal3Kw3gjSdn0niERYKJ`). Build == HEAD `64f0a7e`.
**Severity:** 🔴 critical · 🟠 high · 🟡 medium · 🟢 low
**Status:** 🔎 found · 🛠 fixing · ✅ fixed & builds · ✅✅ verified live · ⚠️ needs deploy
_(Prior games/notifications QA from 2026-06-24 was completed + verified; superseded by this full-app effort.)_
---
## OPEN — current error log
## Severity summary (Round 1)
| Severity | Open | Fixed |
|---|---|---|
| P0 | 0 | 0 |
| P1 | 0 | 1 |
| P2 | 0 | 1 |
| P3 | 3 | 0 |
### N1. 🔴 FCM token was NEVER registered → no push notifications worked at all — ✅✅ FIXED & VERIFIED
`TokenRegistrar.register()` (which fetches `messaging.token` and stores it) was **never called anywhere**. The only other path, `AppMessagingService.onNewToken`, bails with `currentUserId ?: return` — and FCM generates the token at **install, before sign-in**, so `onNewToken` ran with no uid and stored nothing; it never fires again afterwards. Result: **`users/{uid}.fcmToken` was empty for every account**, so no game/message/daily push could ever be delivered.
**Fix:** `MainActivity` now observes `authState` and calls `tokenRegistrar.register()` whenever a user is authenticated. ([MainActivity.kt](app/src/main/java/app/closer/MainActivity.kt))
**Verified live:** after the fix both A and B have a stored `fcmToken`; a direct push to A rendered the heads-up "Your partner started a game — Tap to join them."
### N2. 🔴 POST_NOTIFICATIONS permission was never requested → notifications can't display on Android 13+ — ✅ FIXED
`NotificationPermissionHelper` existed but had **no caller**. On API 33+ notifications are silently dropped without the runtime grant.
**Fix:** `MainActivity` requests `POST_NOTIFICATIONS` on launch via an Activity Result launcher. ([MainActivity.kt](app/src/main/java/app/closer/MainActivity.kt))
### N3. 🟠 Game-START notification named the WRONG person — ✅ FIXED ⚠️ needs functions deploy
`onGameSessionUpdate` passed the **recipient's** name into the body, so the partner saw *"<their own partner-name> has started a game"* (live: B/Sam received "Sam has started a game"). It should name the **starter**.
**Fix:** use `startedByUserId` → starter's name + avatar for both title and body. ([onGameSessionUpdate.ts](functions/src/games/onGameSessionUpdate.ts))
### N4. 🟠 Game-FINISH notification only reached one partner — ✅ FIXED ⚠️ needs functions deploy
Completion branch gated the "notify both" path on `currentData.partnerCompletedAt`, **a field the client never writes** (the client tracks `completedByUsers`). So when both finished, the partner who'd been waiting often got nothing (or a "tap to continue playing" that no longer applied).
**Fix:** on `active → completed` (both partners done = reveal ready) notify **both** partners, each naming the other ("<name> finished — tap to see your results!"). ([onGameSessionUpdate.ts](functions/src/games/onGameSessionUpdate.ts))
### N5. 🟡 Finish copy was wrong (title + client mapping) — ✅✅ FIXED & VERIFIED
- FCM title was hard-coded `"<name> is playing"` even for finish events (shown verbatim when the app is backgrounded). Now type-aware → `"<name> finished the game"`. ([onGameSessionUpdate.ts](functions/src/games/onGameSessionUpdate.ts))
- Client mapped `partner_finished_game``PARTNER_COMPLETED_PART` ("finished their part, open yours when ready") — wrong once **both** are done. Added a dedicated `GAME_RESULTS_READY` type ("Your game results are ready! You both finished — tap to see how you compare."). ([PartnerNotificationManager.kt](app/src/main/java/app/closer/notifications/PartnerNotificationManager.kt))
**N5 verified live:** pushed both types to A — start rendered "Your partner started a game — Tap to join them."; finish rendered "Your game results are ready! You both finished…". The client renders the correct copy for each type.
### N6. 🟠 Deployed functions are STALE for Date Match — ⚠️ needs functions deploy
Source exports `notifyOnDateMatch`, but the **deployed** function is still the old `createDateMatchOnMutualLove`. `onMessageWritten` is current; the rest predate recent edits (incl. N3N5).
**Action:** `firebase deploy --only functions` (allow it to delete `createDateMatchOnMutualLove`).
**Round 1: all P0P2 found were FIXED** (A-001 premium P1, E-001 notif-routing P2). Open = P3 cosmetics only
(A-003 badge, B-001 stale-session guard, E-002 informational notif routing). Deferred for a clean "flawless"
certification: exhaustive deep/stateful screens (Pass C), full live notification matrix + D3 live non-member test.
---
## Per-game status
## Pass A — Couple-shared premium ✅ pass complete
**Target:** if either partner is premium, all premium features unlock for both.
**Result:** only chat is couple-shared. Every other feature gate is per-user → a free user whose partner paid stays locked.
| Game | Functional | Start notif | Finish notif |
|---|---|---|---|
| Spin the Wheel | ✅✅ live (prior) | via `sessions` trigger* | via `sessions` trigger* |
| This or That | ✅✅ live (prior) | via `sessions` trigger* | via `sessions` trigger* |
| How Well | ✅ fixed (prior) | via `sessions` trigger* | via `sessions` trigger* |
| Desire Sync | ✅ fixed (prior) | via `sessions` trigger* | via `sessions` trigger* |
| Connection Challenges | ✅ clean (prior) | n/a (completion-based) | n/a |
| Date Match | ✅ E2EE + rules (prior) | ⚠️ `notifyOnDateMatch` not deployed (N6) | — |
| Daily reveal | ✅✅ live (prior) | `onAnswerWritten` / `sendPartnerAnsweredNotification` | reveal-ready |
| ID | Area | Screen/Route | Severity | Description | Repro | Status |
|---|---|---|---|---|---|---|
| A-001 | Premium gating | PlayHubViewModel, DesireSyncScreen, MemoryLaneScreen, ConnectionChallengesScreen, QuestionPackLibraryViewModel, wheel CategoryPicker/SpinWheel/WheelHistory(VMs) | **P1** | These gated on per-user `EntitlementChecker.isPremium()` instead of couple-shared. A free partner of a premium user stayed locked. | Set Sam premium, QA free → QA Play hub still showed 🔒 on Desire Sync + Memory Lane. | **FIXED** `e8892a9` — routed all gates through `CouplePremiumChecker` (now exposes isPremium/hasPremium resolving partner internally). Verified: Sam premium → QA enters Desire Sync; both free → QA → paywall. |
| A-003 | Premium UI (cosmetic) | PlayHubScreen (Desire Sync + Memory Lane cards) | **P3** | The "🔒 Premium" badge on these two cards is static (rendered in separate card composables that don't receive `hasPremium`), so it still shows a lock even when the couple has premium access. Feature IS accessible (gate fixed in A-001) — only the badge is misleading. | With couple premium, QA's Play hub still shows 🔒 Premium on Desire Sync/Memory Lane though tapping enters the game. | Open (deferred — thread hasPremium into the card composables) |
| A-002 | Premium (control) | ConversationViewModel (chat) | — | **Working correctly** (couple-shared) — kept as the reference pattern for the A-001 fix. | Verified prior round: partner-premium unlocks chat media/reactions for the free partner. | OK |
\* **All games share one notification trigger:** start writes `couples/{id}/sessions/{sessionId}.status="active"`, finish writes `"completed"`, and `onGameSessionUpdate` fires on that single doc. So N3/N4/N5 fix start+finish notifications for **every** game at once. Pipeline is proven (function writes `notification_queue` + sends FCM; FCM delivery verified in N1); the start-name/finish-both fixes take effect after the deploy in N6.
**Note (by-design, not a bug):** `SubscriptionScreen` uses per-user `isPremium()` — correct, it reflects the user's *own* subscription/account state, not a feature gate.
---
## Pass B — Games lifecycle (launch/crash sweep done; full two-device lifecycle partial)
| ID | Area | Screen/Route | Severity | Description | Repro | Status |
|---|---|---|---|---|---|---|
| B-001 | Games / sessions | couples/{id}/sessions | **P3** (observe) | A stale `active` wheel session (startedBy QA, `createdAt` missing/undefined) blocked **all** games ("Waiting for Sam"). In-app **"End their game" recovery works** (session→completed, games unblocked). Likely a prior-test artifact, but: sessions appearing without a timestamp + one stale session blocking every game is worth a guard. | Open This or That → "Waiting for Sam… Sam is playing a Wheel game"; tap End their game → unblocked. | Open |
## DEPLOY CHECKLIST (your call — prod deploys/admin writes are blocked for the agent)
1. `firebase deploy --only functions` — ships N3, N4, N5 (server side) and the `notifyOnDateMatch` rename (N6).
2. `firebase deploy --only firestore:rules` — Date Match (`date_swipes`/`date_matches`) + sealed `releaseKeys` sender-read, if not already live.
3. Install the refreshed APK (`Closer-v0.1.0-debug-2026-06-24.apk`) — ships N1, N2, N5 (client) + the in-app message bubble.
4. A leftover **active `this_or_that` session** is in `couples/{id}/sessions` from prior testing; it blocks starting a new game until that game is finished (or the doc is removed — admin delete needs your authorization).
**Launch/crash sweep (QA, free):** This or That ✅ (mood/length select), How Well Do You Know Me ✅ (intro), Connection Challenges ✅, Spin the Wheel ✅ — all render, **no FATAL**. Desire Sync + Memory Lane are premium-gated (covered in Pass A; gameplay needs premium toggle). Date Match: todo. Full two-device start→finish + results not exhaustively re-run this round (the prior round verified `onGameSessionUpdate` start/finish end-to-end).
## Completed earlier (kept for reference, no longer open)
- Daily reveal sealed-key exchange (release-key tolerant read, epoch-millis `updatedAt`, id→label mapping) — ✅✅ verified live.
- Game-start crash (`saveSession` empty id → invalid path) — ✅✅ fixed; This or That verified live.
- Game re-entry flicker/re-submit (This or That, How Well, Desire Sync) — ✅ pre-check → WAITING.
- Daily question determinism + shared `DailyQuestionResolver` — ✅✅ verified live.
- Partner identity (users partner-read rule) — ✅✅ verified live ("Connected with Sam/QATester").
- Date Match E2EE + rules rewrite — ✅ (⚠️ still needs the deploy in checklist #1/#2).
## Pass C — Visual (light + dark) (main screens verified; deep/stateful screens pending)
**Method:** 5554=Dark, 5556=Light; readable dark|light pair montages + a code scan for non-adapting colors.
**Verified clean (both themes, readable, no clipping):** Home, Today, Play, Messages inbox, Settings. `closerBackgroundBrush()` is theme-aware (adapts). No FATAL on these.
| ID | Area | Screen | Severity | Description | Status |
|---|---|---|---|---|---|
| C-OBS | Theming | ~20 screens (AnswerRevealScreen 15, WheelSessionScreen 14, DateMatchScreen 10, PaywallScreen 9, BucketListScreen 9, SettingsScreen 7, HomeScreen 5, …) | observe | Use hardcoded `Color(0x…)` literals (195 total) that don't adapt to theme — a dark-mode contrast risk to verify per-screen. Main screens checked look fine; deep/stateful screens (reveal, wheel session, dates, bucket list) still need visual verification in both themes. | Open (verify in continuation) |
_Deep/stateful screens (answer reveal, wheel session/complete, date match/builder/matches, bucket list, memory capsule, history, paywall, auth/onboarding/pairing) need their states set up — pending next chunk._
**Pass B requirement (updated):** each game must be **played one complete time through on both devices** (start → every step → finish/reveal/results), not just launched. Round 1 did launch-only → **full playthroughs owed in Round 2** for all 7 (premium games need a premium toggle). A launch-only result = `partial`, not `pass`.
**Pass C requirement (added):** **navigation from every entry point** (each screen reached from all its links — e.g. conversation from inbox/Discuss/notification; game from Play/notification; paywall from each gate) + **back-stack / "double-back"** (system back AND in-app back return to the right place from each entry; no dead-ends, no exit-app surprise, **no screen needing two backs**/duplicate stack entries; deep-link/notification entries land with a sane back stack). Owed in Round 2. Wrong/double back or dead-end = P2 (P1 if it traps the user).
## Pass D — Security & Encryption ✅ clean (no P0/P1 found)
- **D1 at-rest:** all private content is ciphertext — message `text` + `lastMessagePreview` + thread messages = `enc:v1:`; daily answers `encryptedPayload` = `sealed:v1:`. Metadata (dates, types, commitmentHash, ids) plaintext as expected. Chat media bytes = Tink ciphertext (verified prior round + unchanged code path). **No plaintext content leak.**
- **D2 rules:** no catch-all `match /{document=**}`, no blanket `if true`; **`hasPremium` server-only** (client create/update blocked, rules L172/174); entitlements `write:false`; conversations/messages/typing/reactions + entitlement partner-read scoped to members.
- **D4 key exchange:** pairing uses a **wrapped couple key** (`wrappedCoupleKey` + `kdfSalt`/`kdfParams` + `encryptedRecoveryPhrase`); invite code is the KDF seed, never stored raw; strict E2EE (invites without a wrapped key rejected) — confirmed in `acceptInviteCallable`.
- **D5 App Check/secrets:** App Check enforced (`SecurityModule`, `PlayIntegrityChecker`, `FirebaseInitializer`); both service-account JSONs gitignored **and untracked**; `allowBackup=false`.
- **D6 leak vectors:** analytics events carry only metadata (no message/answer content); `allowBackup=false`.
_Follow-ups (not blockers): live **non-member negative test** (D3) needs a fresh 3rd account (rule logic verified member-scoped); a fresh Storage-bytes spot-check of chat media._
| ID | Area | Severity | Description | Status |
|---|---|---|---|---|
| D-OBS | Rules / data load | **P2?** (investigate) | During Pass B, logcat showed `PERMISSION_DENIED` for client listeners on `couples/{id}/outcomes`, `couples/{id}/challenges (where status==active)`, and `couples/{id}/capsules`. Either a rules gap or queries firing for features the (free) user can't read → console errors / possibly broken Connection Challenges + Memory Lane data load. **Round 2: confirm whether these features load correctly + fix the rule or guard the query.** | Open |
## Pass E — Notifications
- **Copy carries no private content:** all function notification bodies are generic ("Tap to read and reply", "Answer together before it expires", etc.); `${title}` refers to public question/game titles, not user answers. ✓ (ties to D6)
- **Routing:** centralized in `PartnerNotificationType` (`fromRemoteType` → `routeFor`); chat opens the exact conversation, reveal→answerReveal(questionId), games→Play, capsule→Memory Lane, etc.
- **Foundations** (prior round, code present): FCM token registration on sign-in, POST_NOTIFICATIONS, channels.
| ID | Area | Severity | Description | Status |
|---|---|---|---|---|
| E-001 | Notification routing | **P2** | Type-string mismatch: functions send `daily_question` + `challenge_day_ready`, but client mapped only `daily_question_reminder` + `challenge_waiting` → tapping those did NOT deep-link to Today / Connection Challenges. | **FIXED** `<pending-commit>` — added `daily_question`/`challenge_day_ready` to `fromRemoteType` (build green; live tap-verify deferred). |
| E-002 | Notification routing | **P3** | `partner_left`, `partner_deleted_account`, `invite_created`, `spki` are unmapped → tap lands on default (no deep-link). Informational types; acceptable but ideally routed. | Open |
_Full live delivery matrix (17 types × foreground/background/killed) deferred — key types verified prior round; routing now code-correct._

113
ClaudeiOSPlan.md Normal file
View File

@ -0,0 +1,113 @@
# Claude iOS Plan — Part 2 (build to parity) + Part 3 (iOS QA)
> Program: **Part 1** = Android QA (`ClaudeQAPlan.md`, in progress) → **Part 2** = build the iOS app to Android's
> CURRENT state → **Part 3** = run the same QA passes on iOS. This doc covers Parts 2 & 3.
## Decisions (locked)
- **Approach:** native **SwiftUI** + Firebase iOS SDK (the `iphone/` scaffold already chose this: SwiftPM + XcodeGen,
feature dirs mirroring Android, `ARCHITECTURE_AUDIT.md`). Do NOT switch to KMP/Compose-MP.
- **E2EE:** **full Tink-compatible** crypto — iOS must byte-match Android's wire formats so an **iOS↔Android couple
decrypts each other**. (Overrides the audit's "skip E2EE for MVP" note — encryption is the cornerstone.)
- **Scope:** **working parity build** (Simulator + real device, TestFlight optional). **No App Store submission.**
## ⚠️ Hard constraint — macOS required (we are on Linux)
iOS build/run/QA needs **macOS + Xcode + iOS Simulator**; there is **no adb-equivalent from Linux**. Therefore:
- **On this Linux box** I can only: author/refresh Swift source, refresh the audit, write the plan/specs, and reason
about the code. **I cannot compile, run the Simulator, or do interactive/visual QA here.**
- **Everything that requires building or running is deferred to a Mac** (a Mac, cloud-Mac, or macOS CI runner).
- Part 2 done-on-Linux = "code authored + self-reviewed"; Part 2 *truly* done = builds green in Xcode and runs on
Simulator + device (on a Mac). **Part 3 (iOS QA) is entirely macOS-gated.**
## Current iOS state (as found)
`iphone/` = scaffold only: ~19 stub Swift files, `Crypto/` **empty**, most feature dirs empty, one `FirestoreService`.
`ARCHITECTURE_AUDIT.md` is thorough but generated from Android **v0.2.0** — it **predates** Messages/conversations,
chat media (photo/GIF/sticker/voice), reactions/typing/read-receipts/pagination, and couple-shared premium (A-001).
So this is a near-full build, not a top-up.
---
## Part 2 — Build iOS to current Android parity
### 2.0 Refresh the audit to CURRENT Android (do first)
Update `iphone/ARCHITECTURE_AUDIT.md` from the live Android code: add the **Messages** tab (conversations inbox +
conversation chat: text/photo/GIF/sticker/voice, reactions, delete/tombstone, typing, read-receipts, pagination,
chat-head bubble), the conversations Firestore model, the new notification types, and **couple-shared premium**
(`CouplePremiumChecker` — either partner premium unlocks all features for both). Re-tally screens/models/functions.
### 2.1 Project + dependencies (Mac)
XcodeGen/`project.yml`; SPM: Firebase Auth/Firestore/Functions/Messaging/Storage, RevenueCat (`purchases-ios`),
GoogleSignIn; `GoogleService-Info.plist`; entitlements (push, keychain); **App Check** via DeviceCheck/App Attest
(Android uses Play Integrity); APNs setup for FCM.
### 2.2 Core infrastructure
`AppDependencies` (manual DI), `AuthService` + `AuthRateLimiter` (mirror Android limits), `FirestoreService` +
per-domain services (User/Couple/Invite/Question/Answer/Game/Date/Conversation), `NotificationService`
(APNs+FCM + deep-link routing mirroring `PartnerNotificationType`), navigation (TabView + NavigationStack per tab,
`AppRoute` mirror, deep-link handler), `CloserTheme` (light+dark), reusable components.
### 2.3 E2EE — full Tink-compatible crypto (HIGHEST RISK; cornerstone)
Implement byte-compatible Swift crypto for every Android wire format:
- `SealedAnswerEncryptor` (AES-256-GCM, 96-bit IV, AAD `"{coupleId}|{questionId}|{userId}"`, `sealed:v1:`),
`FieldEncryptor` (`enc:v1:`, AAD=coupleId) — used by messages/previews/dates,
`AnswerCommitment` (SHA-256, `sha256:`), `UserKeyManager` (ECIES P-256 HKDF-HMAC-SHA256 AES128-CTR-HMAC, keypair in
Keychain, `pub:v1:`), `ReleaseKeyEncryptor` (`keybox:v1:`), `RecoveryKeyManager` (**Argon2id m=46MiB, t=3, p=1**,
BIP39-style wordlist), `CoupleEncryptionManager`, Keychain-backed key stores.
- **Interop test harness (the acceptance gate):** (a) decrypt Android-produced fixtures (`sealed:v1:`/`enc:v1:`/
`keybox:v1:`) on iOS; (b) have Android decrypt iOS-produced ciphertext; (c) verify an Android-generated recovery
phrase unlocks on iOS and vice versa. Wire formats must match byte-for-byte. If a format can't be matched with
CryptoKit, use a vetted Swift lib (e.g. swift-sodium/SwiftArgon2) — never a non-interoperable shortcut.
- **Golden vectors** for the deterministic primitives (checked into both repos): `AnswerCommitment` SHA-256 and
`RecoveryKeyManager` Argon2id (fixed salt/params) must produce **identical bytes** on both platforms — assert in unit
tests on each side. AEAD/ECIES use random IVs so they can't be golden-matched; cover those with the **round-trip**
harness above. Generate the Android fixtures now (on Linux) so iOS has them ready.
### 2.4 Screens & features to parity (~48 + new messaging)
All routes from the refreshed audit's screen map, **including the NEW Messages experience** (inbox + conversation
with full media/reactions/typing/read-receipts/pagination) and the games (full, not stubs), dates, wheel, settings,
paywall. Mirror the Android UX + the couple-shared premium gating (route gated features through the iOS
`EntitlementChecker` couple-shared equivalent).
### 2.5 Build & smoke (Mac)
Xcode build green; run on Simulator + a real device; smoke each major flow (auth → pair → home/today/play/messages/
settings) with no crashes. **This step + 2.1/2.5 runs on a Mac.**
---
## Part 3 — iOS QA (reuse `ClaudeQAPlan.md` passes AE on iOS)
Same methodology, severity scale (P0P3), report-only→fix→re-QA-until-flawless loop, continuity (use
`ClaudeReport-iOS.md` + `ClaudeQACoverage-iOS.md` + run-state header), and **autonomous run-to-completion** mode.
**macOS-gated.** Tooling adaptation from Android:
| Android (Part 1) | iOS (Part 3) |
|---|---|
| `adb` install/tap/screencap | `xcrun simctl` install/io screenshot; XCUITest or `idb` to drive taps |
| 2 emulators (5554/5556) | 2 iOS Simulators (or Simulator + device) for the two partners |
| `adb logcat` FATAL/ANR | `xcrun simctl spawn booted log stream` / Console crash logs |
| `cmd uimode night` for theme | Simulator Appearance (light/dark) per device |
| premium toggle via `set_premium.js` | same admin script (server-side) |
Run all passes on iOS:
- **A** couple-shared premium (gates + paywall), **B** each game one full playthrough on both, **C** visual
light+dark all screens + navigation-from-every-entry + back-stack/double-back, **D** security/encryption, **E**
notifications (APNs/FCM delivery + tap-to-open every type).
- **NEW — cross-platform pass (X):** a **mixed couple (Android + iOS)** — messaging, answers, dates, premium, and
notifications work cross-platform, and **E2EE decrypts both ways** (the Part 2 interop gate, verified live on real
paired devices). This is the make-or-break for the cornerstone.
- **iOS-native dimensions (add to the passes):** Dynamic Type (largest sizes), **VoiceOver**, safe-area / notch /
Dynamic Island, multiple device sizes (SE → Pro Max; iPad if supported), **edge-swipe-back gesture** + interactive
pop (the iOS "double-back"/nav-stack analog), `scenePhase` background/foreground, dark/light.
- **Real-device / sandbox needs:** **App Attest/DeviceCheck** and **APNs push** don't fully work on the Simulator —
use a real device (or `xcrun simctl push` with a payload for local notif routing); **RevenueCat IAP** needs a
StoreKit config file or a sandbox Apple ID. Plan Pass A/E around this.
## Definition of done
- **Part 2:** iOS builds green + runs on Simulator + device at feature parity with current Android; E2EE interop
harness passes (Android↔iOS decrypt both ways).
- **Part 3:** an iOS QA round is flawless (zero P0P2, D+E clean, every game fully played, nav/back verified) AND the
cross-platform pass X is clean.
## What can proceed now (Linux) vs needs a Mac
- **Now (Linux):** 2.0 audit refresh; author Swift for models/services/crypto/screens; write iOS QA tooling scripts;
prepare Android-produced crypto fixtures for the interop harness.
- **Needs a Mac:** 2.1 project/deps, all compiling, 2.5 build+run, and **all of Part 3**. Surface this as the blocker
when execution reaches it (per the autonomous-mode rule: do all non-gated work first, then flag the Mac requirement).

View File

@ -0,0 +1,65 @@
package app.closer.core.billing
import app.closer.data.remote.FirestoreCollections
import app.closer.domain.repository.CoupleRepository
import com.google.firebase.auth.FirebaseAuth
import com.google.firebase.firestore.FirebaseFirestore
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flow
import javax.inject.Inject
import javax.inject.Singleton
/**
* Couple-shared premium: a premium feature unlocks if EITHER partner has an active subscription, so a
* single subscription covers the couple. Combines the current user's [EntitlementChecker.isPremium]
* with a live read of the partner's `users/{partnerId}/entitlements/premium` doc (same active/expiry
* rule as [FirestoreEntitlementChecker]). Reactive: flips the moment either side subscribes/expires.
*
* Exposes [isPremium]/[hasPremium] with the SAME shape as [EntitlementChecker] so it's a drop-in for
* any feature gate (resolves the partner internally), plus [coupleHasPremium] when the caller already
* knows the partner id.
*/
@Singleton
class CouplePremiumChecker @Inject constructor(
private val entitlementChecker: EntitlementChecker,
private val firestore: FirebaseFirestore,
private val coupleRepository: CoupleRepository
) {
/** Couple-shared premium for the current user (resolves the partner internally). Drop-in for EntitlementChecker. */
fun isPremium(): Flow<Boolean> = flow {
val uid = FirebaseAuth.getInstance().currentUser?.uid
val partnerId = uid?.let {
runCatching { coupleRepository.getCoupleForUser(it)?.userIds?.firstOrNull { id -> id != it } }.getOrNull()
}
emitAll(coupleHasPremium(partnerId))
}
/** One-shot couple-shared premium check. Drop-in for EntitlementChecker.hasPremium. */
suspend fun hasPremium(): Boolean = isPremium().first()
fun coupleHasPremium(partnerId: String?): Flow<Boolean> =
if (partnerId.isNullOrBlank()) entitlementChecker.isPremium()
else combine(entitlementChecker.isPremium(), observePartnerPremium(partnerId)) { mine, theirs ->
mine || theirs
}.distinctUntilChanged()
private fun observePartnerPremium(partnerId: String): Flow<Boolean> = callbackFlow {
val ref = firestore.collection(FirestoreCollections.USERS).document(partnerId)
.collection(FirestoreCollections.Users.ENTITLEMENTS)
.document(FirestoreCollections.Users.ENTITLEMENT_PREMIUM_DOC)
val listener = ref.addSnapshotListener { snap, err ->
if (err != null || snap == null || !snap.exists()) { trySend(false); return@addSnapshotListener }
val premium = snap.getBoolean("premium") ?: false
val expiresAt = snap.getTimestamp("expiresAt")
val active = premium && (expiresAt == null || expiresAt.seconds > System.currentTimeMillis() / 1000)
trySend(active)
}
awaitClose { listener.remove() }
}
}

View File

@ -0,0 +1,81 @@
package app.closer.core.media
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Matrix
import android.media.ExifInterface
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
/**
* Downscales + JPEG-compresses photos from the gallery/camera before they're encrypted and
* uploaded, to cut upload time and storage. Deliberately leaves **animated GIFs and already-small
* images untouched** (so keyboard GIFs/stickers keep their animation + transparency those never
* reach this path anyway) and applies **EXIF orientation** so photos aren't sent sideways.
*
* Never throws: on any failure it returns the original bytes, so a photo is never lost.
*/
object MediaCompressor {
private const val MAX_DIM = 1600
private const val JPEG_QUALITY = 80
private const val SKIP_BELOW_BYTES = 200 * 1024
fun compressPhoto(input: ByteArray): ByteArray = runCatching {
if (isGif(input)) return input
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeByteArray(input, 0, input.size, bounds)
val w = bounds.outWidth
val h = bounds.outHeight
if (w <= 0 || h <= 0) return input
// Already small — leave it (also preserves small stickers/PNGs picked from the gallery).
if (w <= MAX_DIM && h <= MAX_DIM && input.size <= SKIP_BELOW_BYTES) return input
val opts = BitmapFactory.Options().apply { inSampleSize = sampleSize(w, h, MAX_DIM) }
var bmp = BitmapFactory.decodeByteArray(input, 0, input.size, opts) ?: return input
// Exact-fit downscale on the long edge.
val longEdge = maxOf(bmp.width, bmp.height)
if (longEdge > MAX_DIM) {
val scale = MAX_DIM.toFloat() / longEdge
val scaled = Bitmap.createScaledBitmap(bmp, (bmp.width * scale).toInt(), (bmp.height * scale).toInt(), true)
if (scaled != bmp) bmp.recycle()
bmp = scaled
}
// Re-apply EXIF rotation (lost when decoding to a bitmap).
val rotation = orientationDegrees(input)
if (rotation != 0f) {
val m = Matrix().apply { postRotate(rotation) }
val rotated = Bitmap.createBitmap(bmp, 0, 0, bmp.width, bmp.height, m, true)
if (rotated != bmp) bmp.recycle()
bmp = rotated
}
val out = ByteArrayOutputStream()
bmp.compress(Bitmap.CompressFormat.JPEG, JPEG_QUALITY, out)
bmp.recycle()
val result = out.toByteArray()
if (result.isNotEmpty() && result.size < input.size) result else input
}.getOrDefault(input)
private fun isGif(b: ByteArray): Boolean =
b.size >= 3 && b[0] == 'G'.code.toByte() && b[1] == 'I'.code.toByte() && b[2] == 'F'.code.toByte()
private fun sampleSize(w: Int, h: Int, target: Int): Int {
var s = 1
val longEdge = maxOf(w, h)
while (longEdge / (s * 2) >= target) s *= 2
return s
}
private fun orientationDegrees(input: ByteArray): Float = runCatching {
val exif = ExifInterface(ByteArrayInputStream(input))
when (exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL)) {
ExifInterface.ORIENTATION_ROTATE_90 -> 90f
ExifInterface.ORIENTATION_ROTATE_180 -> 180f
ExifInterface.ORIENTATION_ROTATE_270 -> 270f
else -> 0f
}
}.getOrDefault(0f)
}

View File

@ -1,11 +1,14 @@
package app.closer.data.remote
import android.content.Context
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import android.net.Uri
import com.google.firebase.storage.FirebaseStorage
import com.google.firebase.storage.StorageMetadata
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.suspendCancellableCoroutine
import java.io.IOException
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.coroutines.resume
@ -16,7 +19,17 @@ class FirebaseStorageDataSource @Inject constructor(
@ApplicationContext private val context: Context
) {
private val storage = FirebaseStorage.getInstance()
// Cap retries so an offline upload surfaces a failure in seconds instead of the ~2-minute default.
private val storage = FirebaseStorage.getInstance().apply {
maxUploadRetryTimeMillis = 30_000
maxOperationRetryTimeMillis = 30_000
}
private fun isOnline(): Boolean {
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager ?: return true
val caps = cm.activeNetwork?.let { cm.getNetworkCapabilities(it) } ?: return false
return caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
}
suspend fun uploadProfilePhoto(uid: String, uri: Uri): String =
suspendCancellableCoroutine { cont ->
@ -40,6 +53,11 @@ class FirebaseStorageDataSource @Inject constructor(
*/
suspend fun uploadEncryptedMedia(uid: String, encryptedBytes: ByteArray): String =
suspendCancellableCoroutine { cont ->
// Fail fast when clearly offline so the chat shows a retry instead of a stuck spinner.
if (!isOnline()) {
cont.resumeWithException(IOException("No internet connection"))
return@suspendCancellableCoroutine
}
val ref = storage.reference.child("users/$uid/chat_media/${java.util.UUID.randomUUID()}")
val metadata = StorageMetadata.Builder()
.setContentType("application/octet-stream")

View File

@ -149,9 +149,15 @@ class FirestoreConversationDataSource @Inject constructor(
).voidAwait()
}
fun observeMessages(coupleId: String, conversationId: String): Flow<List<QuestionMessage>> = callbackFlow {
/**
* Live listener on the newest [limit] messages (ascending for display). Using `limitToLast`
* keeps just-sent messages inside the window regardless of pending server-timestamp ordering;
* "load older" simply grows [limit] (a single live listener, so incoming messages still appear).
*/
fun observeMessages(coupleId: String, conversationId: String, limit: Int): Flow<List<QuestionMessage>> = callbackFlow {
val listener = messagesRef(coupleId, conversationId)
.orderBy("createdAt", Query.Direction.ASCENDING)
.limitToLast(limit.toLong())
.addSnapshotListener { snap, err ->
if (err != null || snap == null) return@addSnapshotListener
val aead = encryptionManager.aeadFor(coupleId)
@ -160,6 +166,66 @@ class FirestoreConversationDataSource @Inject constructor(
awaitClose { listener.remove() }
}
/** Toggles the caller's emoji reaction on a message (pass null to remove). Any couple member may react. */
suspend fun setReaction(coupleId: String, conversationId: String, messageId: String, userId: String, emoji: String?) {
val ref = messagesRef(coupleId, conversationId).document(messageId)
if (emoji == null) {
ref.update("reactions.$userId", FieldValue.delete()).voidAwait()
} else {
ref.update(mapOf("reactions.$userId" to emoji)).voidAwait()
}
}
/** Unsend: tombstones a message (author-only) and reflects it in the inbox preview if it was last. */
suspend fun deleteMessage(coupleId: String, conversationId: String, messageId: String) {
messagesRef(coupleId, conversationId).document(messageId).update("deleted", true).voidAwait()
val latest = runCatching {
messagesRef(coupleId, conversationId)
.orderBy("createdAt", Query.Direction.DESCENDING).limit(1).get().await()
}.getOrNull()
if (latest?.documents?.firstOrNull()?.id == messageId) {
val aead = encryptionManager.aeadFor(coupleId) ?: return
conversationsRef(coupleId).document(conversationId).set(
mapOf("lastMessagePreview" to fieldEncryptor.encrypt("Message deleted", aead, coupleId)),
SetOptions.merge()
).voidAwait()
}
}
/** Fire-and-forget typing flag on the conversation doc (ephemeral; cleared on stop/send/leave). */
fun setTyping(coupleId: String, conversationId: String, userId: String, typing: Boolean) {
val ref = conversationsRef(coupleId).document(conversationId)
val value = if (typing) FieldValue.serverTimestamp() else FieldValue.delete()
ref.update(mapOf("typing.$userId" to value)).addOnFailureListener { /* best-effort */ }
}
/** Emits the partner's latest typing timestamp (0 if not typing). */
fun observePartnerTyping(coupleId: String, conversationId: String, currentUserId: String): Flow<Long> = callbackFlow {
val listener = conversationsRef(coupleId).document(conversationId)
.addSnapshotListener { snap, err ->
if (err != null || snap == null) return@addSnapshotListener
@Suppress("UNCHECKED_CAST")
val typing = (snap.get("typing") as? Map<String, com.google.firebase.Timestamp>).orEmpty()
val at = typing.filterKeys { it != currentUserId }.values.maxOfOrNull { it.toDate().time } ?: 0L
trySend(at)
}
awaitClose { listener.remove() }
}
/** Emits the partner's most recent read timestamp for this conversation (0 if never read). */
fun observePartnerReadAt(coupleId: String, conversationId: String, currentUserId: String): Flow<Long> = callbackFlow {
val listener = conversationsRef(coupleId).document(conversationId)
.addSnapshotListener { snap, err ->
if (err != null || snap == null) return@addSnapshotListener
@Suppress("UNCHECKED_CAST")
val reads = (snap.get("reads") as? Map<String, com.google.firebase.Timestamp>).orEmpty()
val partnerReadAt = reads.filterKeys { it != currentUserId }
.values.maxOfOrNull { it.toDate().time } ?: 0L
trySend(partnerReadAt)
}
awaitClose { listener.remove() }
}
suspend fun loadDecryptedMedia(coupleId: String, mediaUrl: String): ByteArray? {
val aead = encryptionManager.aeadFor(coupleId) ?: return null
val cipher = runCatching { storageDataSource.downloadBytes(mediaUrl) }.getOrNull() ?: return null
@ -196,12 +262,16 @@ class FirestoreConversationDataSource @Inject constructor(
): QuestionMessage? {
val userId = getString("authorUserId") ?: return null
val type = getString("type") ?: "text"
@Suppress("UNCHECKED_CAST")
val reactions = (get("reactions") as? Map<String, String>).orEmpty()
return QuestionMessage(
id = id,
userId = userId,
type = type,
mediaUrl = getString("mediaUrl") ?: "",
durationMs = getLong("durationMs") ?: 0L,
reactions = reactions,
deleted = getBoolean("deleted") ?: false,
text = if (type == "text") (fieldEncryptor.decryptForDisplay(getString("text"), aead, coupleId) ?: "") else "",
createdAt = getTimestamp("createdAt")?.toDate()?.time ?: 0L
)

View File

@ -25,8 +25,17 @@ class ConversationRepositoryImpl @Inject constructor(
override suspend fun markRead(coupleId: String, conversationId: String, userId: String) =
dataSource.markRead(coupleId, conversationId, userId)
override fun observeMessages(coupleId: String, conversationId: String): Flow<List<QuestionMessage>> =
dataSource.observeMessages(coupleId, conversationId)
override fun observeMessages(coupleId: String, conversationId: String, limit: Int): Flow<List<QuestionMessage>> =
dataSource.observeMessages(coupleId, conversationId, limit)
override fun observePartnerReadAt(coupleId: String, conversationId: String, currentUserId: String): Flow<Long> =
dataSource.observePartnerReadAt(coupleId, conversationId, currentUserId)
override fun setTyping(coupleId: String, conversationId: String, userId: String, typing: Boolean) =
dataSource.setTyping(coupleId, conversationId, userId, typing)
override fun observePartnerTyping(coupleId: String, conversationId: String, currentUserId: String): Flow<Long> =
dataSource.observePartnerTyping(coupleId, conversationId, currentUserId)
override suspend fun sendMessage(coupleId: String, conversationId: String, userId: String, text: String) =
dataSource.sendMessage(coupleId, conversationId, userId, text)
@ -37,6 +46,12 @@ class ConversationRepositoryImpl @Inject constructor(
override suspend fun sendVoiceMessage(coupleId: String, conversationId: String, userId: String, audioBytes: ByteArray, durationMs: Long) =
dataSource.sendVoiceMessage(coupleId, conversationId, userId, audioBytes, durationMs)
override suspend fun setReaction(coupleId: String, conversationId: String, messageId: String, userId: String, emoji: String?) =
dataSource.setReaction(coupleId, conversationId, messageId, userId, emoji)
override suspend fun deleteMessage(coupleId: String, conversationId: String, messageId: String) =
dataSource.deleteMessage(coupleId, conversationId, messageId)
override suspend fun loadDecryptedMedia(coupleId: String, mediaUrl: String): ByteArray? =
dataSource.loadDecryptedMedia(coupleId, mediaUrl)
}

View File

@ -10,8 +10,12 @@ data class QuestionMessage(
val mediaUrl: String = "",
/** Voice-note length in milliseconds (0 for non-voice). */
val durationMs: Long = 0L,
/** Emoji reactions keyed by reactor uid, e.g. {uid: "❤️"}. */
val reactions: Map<String, String> = emptyMap(),
/** True once the author has unsent (tombstoned) the message. */
val deleted: Boolean = false,
val createdAt: Long = 0L
) {
val isImage: Boolean get() = type == "image" && mediaUrl.isNotBlank()
val isVoice: Boolean get() = type == "voice" && mediaUrl.isNotBlank()
val isImage: Boolean get() = type == "image" && mediaUrl.isNotBlank() && !deleted
val isVoice: Boolean get() = type == "voice" && mediaUrl.isNotBlank() && !deleted
}

View File

@ -9,9 +9,14 @@ interface ConversationRepository {
suspend fun ensureMainConversation(coupleId: String)
suspend fun ensureQuestionConversation(coupleId: String, questionId: String): String
suspend fun markRead(coupleId: String, conversationId: String, userId: String)
fun observeMessages(coupleId: String, conversationId: String): Flow<List<QuestionMessage>>
fun observeMessages(coupleId: String, conversationId: String, limit: Int): Flow<List<QuestionMessage>>
fun observePartnerReadAt(coupleId: String, conversationId: String, currentUserId: String): Flow<Long>
fun setTyping(coupleId: String, conversationId: String, userId: String, typing: Boolean)
fun observePartnerTyping(coupleId: String, conversationId: String, currentUserId: String): Flow<Long>
suspend fun sendMessage(coupleId: String, conversationId: String, userId: String, text: String)
suspend fun sendImageMessage(coupleId: String, conversationId: String, userId: String, imageBytes: ByteArray)
suspend fun sendVoiceMessage(coupleId: String, conversationId: String, userId: String, audioBytes: ByteArray, durationMs: Long)
suspend fun setReaction(coupleId: String, conversationId: String, messageId: String, userId: String, emoji: String?)
suspend fun deleteMessage(coupleId: String, conversationId: String, messageId: String)
suspend fun loadDecryptedMedia(coupleId: String, mediaUrl: String): ByteArray?
}

View File

@ -287,10 +287,12 @@ enum class PartnerNotificationType(
// is ready, so this maps to the results-ready copy (not "open yours when ready").
"partner_finished_game" -> GAME_RESULTS_READY
"game_results_ready" -> GAME_RESULTS_READY
"challenge_waiting" -> CHALLENGE_WAITING
// Server emits 'challenge_day_ready'; keep the legacy 'challenge_waiting' alias too.
"challenge_day_ready", "challenge_waiting" -> CHALLENGE_WAITING
"memory_capsule_unlocked" -> CAPSULE_UNLOCKED
"gentle_reminder" -> GENTLE_REMINDER
"daily_question_reminder" -> DAILY_QUESTION_REMINDER
// Server emits both 'daily_question' (assignment) and 'daily_question_reminder' — both open Today.
"daily_question", "daily_question_reminder" -> DAILY_QUESTION_REMINDER
"chat_message" -> CHAT_MESSAGE
"outcome_reminder" -> OUTCOME_REMINDER
"partner_joined" -> PARTNER_JOINED

View File

@ -56,7 +56,7 @@ import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import app.closer.core.billing.EntitlementChecker
import app.closer.core.billing.CouplePremiumChecker
import app.closer.core.navigation.AppRoute
import app.closer.data.challenges.ChallengesCatalog
import app.closer.data.remote.FirestoreChallengeDataSource
@ -105,7 +105,7 @@ class ConnectionChallengesViewModel @Inject constructor(
private val authRepository: AuthRepository,
private val coupleRepository: CoupleRepository,
private val challengeDataSource: FirestoreChallengeDataSource,
private val entitlementChecker: EntitlementChecker
private val premiumChecker: CouplePremiumChecker
) : ViewModel() {
private val _uiState = MutableStateFlow(ChallengesUiState())
@ -114,7 +114,7 @@ class ConnectionChallengesViewModel @Inject constructor(
private var progressJob: Job? = null
init {
entitlementChecker.isPremium()
premiumChecker.isPremium()
.onEach { premium -> _uiState.update { it.copy(hasPremium = premium) } }
.launchIn(viewModelScope)
load()

View File

@ -58,7 +58,7 @@ import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import app.closer.core.billing.EntitlementChecker
import app.closer.core.billing.CouplePremiumChecker
import app.closer.core.navigation.AppRoute
import app.closer.data.remote.FirestoreDesireSyncDataSource
import android.content.Context
@ -123,7 +123,7 @@ class DesireSyncViewModel @Inject constructor(
private val repository: QuestionRepository,
private val gameSessionManager: GameSessionManager,
private val dataSource: FirestoreDesireSyncDataSource,
private val entitlementChecker: EntitlementChecker
private val premiumChecker: CouplePremiumChecker
) : ViewModel() {
private val _uiState = MutableStateFlow(DesireSyncUiState())
@ -144,7 +144,7 @@ class DesireSyncViewModel @Inject constructor(
private fun load() {
viewModelScope.launch {
if (!entitlementChecker.hasPremium()) {
if (!premiumChecker.hasPremium()) {
_uiState.update { it.copy(navigateTo = AppRoute.PAYWALL) }
return@launch
}

View File

@ -70,7 +70,7 @@ import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import app.closer.core.billing.EntitlementChecker
import app.closer.core.billing.CouplePremiumChecker
import app.closer.core.navigation.AppRoute
import app.closer.data.remote.FirestoreCapsuleDataSource
import app.closer.domain.model.TimeCapsule
@ -140,7 +140,7 @@ class MemoryLaneViewModel @Inject constructor(
private val authRepository: AuthRepository,
private val coupleRepository: CoupleRepository,
private val capsuleDataSource: FirestoreCapsuleDataSource,
private val entitlementChecker: EntitlementChecker
private val premiumChecker: CouplePremiumChecker
) : ViewModel() {
private val _uiState = MutableStateFlow(MemoryLaneUiState())
@ -152,7 +152,7 @@ class MemoryLaneViewModel @Inject constructor(
private fun load() {
viewModelScope.launch {
val hasPremium = entitlementChecker.hasPremium()
val hasPremium = premiumChecker.hasPremium()
if (!hasPremium) {
_uiState.update { it.copy(phase = MemoryLanePhase.LIST, hasPremium = false) }
return@launch

View File

@ -6,6 +6,7 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
@ -14,21 +15,32 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@ -38,8 +50,11 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import app.closer.core.navigation.AppRoute
import app.closer.ui.messages.components.ChatComposer
import app.closer.ui.messages.components.ChatDaySeparator
import app.closer.ui.messages.components.ChatMessageRow
import app.closer.ui.messages.components.isSameChatDay
import app.closer.ui.theme.closerBackgroundBrush
import coil.compose.AsyncImage
@ -51,14 +66,42 @@ fun ConversationScreen(
) {
val state by viewModel.uiState.collectAsState()
val listState = rememberLazyListState()
val snackbarHostState = remember { SnackbarHostState() }
LaunchedEffect(state.messages.size) {
if (state.messages.isNotEmpty()) listState.animateScrollToItem(state.messages.lastIndex)
val routeToPaywall: () -> Unit = {
viewModel.onMediaPaywallShown()
onNavigate(AppRoute.PAYWALL)
}
// Surface send failures as a snackbar.
LaunchedEffect(Unit) {
viewModel.events.collect { snackbarHostState.showSnackbar(it) }
}
// Scroll to the bottom on first load and when a NEW message arrives while near the bottom —
// never when older history is prepended (that would yank the reader away).
var prevLastId by remember { mutableStateOf<String?>(null) }
val lastId = state.messages.lastOrNull()?.id
LaunchedEffect(lastId) {
if (lastId == null) return@LaunchedEffect
val lastIndex = state.messages.lastIndex
val lastVisible = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0
if (prevLastId == null || lastVisible >= lastIndex - 2) {
listState.animateScrollToItem(lastIndex)
}
prevLastId = lastId
}
// Load older messages when the user scrolls to the very top.
val atTop by remember { derivedStateOf { listState.firstVisibleItemIndex == 0 } }
LaunchedEffect(atTop, state.canLoadMore) {
if (atTop && state.canLoadMore) viewModel.loadOlderMessages()
}
Scaffold(
containerColor = Color.Transparent,
modifier = Modifier.background(closerBackgroundBrush()),
snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = {
TopAppBar(
title = {
@ -96,32 +139,109 @@ fun ConversationScreen(
contentPadding = PaddingValues(horizontal = 14.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
val lastOwnIndex = state.messages.indexOfLast { it.userId == viewModel.currentUserId }
itemsIndexed(state.messages, key = { _, m -> m.id }) { index, message ->
val isMe = message.userId == viewModel.currentUserId
val showAvatar = index == state.messages.lastIndex ||
val isLastInRun = index == state.messages.lastIndex ||
state.messages[index + 1].userId != message.userId
val prev = state.messages.getOrNull(index - 1)
if (prev == null || !isSameChatDay(prev.createdAt, message.createdAt)) {
ChatDaySeparator(message.createdAt)
}
val seen = isMe && index == lastOwnIndex &&
message.createdAt > 0 && state.partnerReadAt >= message.createdAt
ChatMessageRow(
message = message,
isCurrentUser = isMe,
partnerAvatarUrl = state.partnerPhotoUrl,
showAvatar = showAvatar,
showAvatar = isLastInRun,
showTimestamp = isLastInRun,
showSeen = seen,
canReact = state.canSendMedia,
onReact = { emoji -> viewModel.react(message.id, emoji) },
onReactBlocked = routeToPaywall,
onDelete = { viewModel.deleteMessage(message.id) },
loadDecryptedMedia = viewModel::loadDecryptedMedia
)
}
}
if (state.partnerTyping) {
Text(
text = "typing…",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
modifier = Modifier.padding(start = 20.dp, bottom = 2.dp)
)
}
state.pendingMedia.forEach { pm ->
PendingMediaChip(
pending = pm,
onRetry = { viewModel.retryMedia(pm.id) },
onDismiss = { viewModel.dismissPending(pm.id) }
)
}
ChatComposer(
value = state.messageInput,
onValueChange = viewModel::updateMessageInput,
onSend = viewModel::sendMessage,
onSendImage = viewModel::sendImage,
onSendVoice = viewModel::sendVoice,
canSendMedia = state.canSendMedia,
onUpgrade = routeToPaywall,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 6.dp)
)
}
}
}
@Composable
private fun PendingMediaChip(
pending: PendingMedia,
onRetry: () -> Unit,
onDismiss: () -> Unit
) {
val label = if (pending.type == "voice") "voice note" else "photo"
Surface(
shape = RoundedCornerShape(20.dp),
color = MaterialTheme.colorScheme.surfaceVariant,
modifier = Modifier.padding(horizontal = 14.dp, vertical = 2.dp)
) {
Row(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
if (pending.failed) {
Text(
text = "Couldn't send $label",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error
)
IconButton(onClick = onRetry, modifier = Modifier.size(28.dp)) {
Icon(Icons.Filled.Refresh, contentDescription = "Retry", tint = MaterialTheme.colorScheme.primary)
}
IconButton(onClick = onDismiss, modifier = Modifier.size(28.dp)) {
Icon(Icons.Filled.Close, contentDescription = "Dismiss", tint = MaterialTheme.colorScheme.onSurfaceVariant)
}
} else {
CircularProgressIndicator(
strokeWidth = 2.dp,
modifier = Modifier.size(16.dp),
color = MaterialTheme.colorScheme.primary
)
Text(
text = "Sending $label",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
@Composable
private fun ConversationAvatar(url: String?) {
val size = 32.dp

View File

@ -3,6 +3,8 @@ package app.closer.ui.messages
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import app.closer.core.analytics.AnalyticsTracker
import app.closer.core.billing.CouplePremiumChecker
import app.closer.data.local.QuestionDao
import app.closer.data.local.mapper.toQuestion
import app.closer.domain.model.QuestionMessage
@ -12,21 +14,42 @@ import app.closer.domain.repository.UserRepository
import app.closer.notifications.ActiveThreadMonitor
import com.google.firebase.auth.FirebaseAuth
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import java.util.UUID
import javax.inject.Inject
/** A media send in flight (or failed) — shown as a chip above the composer, not in the message list. */
data class PendingMedia(val id: String, val type: String, val failed: Boolean = false)
data class ConversationUiState(
val title: String = "",
val partnerPhotoUrl: String? = null,
val messages: List<QuestionMessage> = emptyList(),
val pendingMedia: List<PendingMedia> = emptyList(),
val canLoadMore: Boolean = false,
val partnerReadAt: Long = 0L,
val partnerTyping: Boolean = false,
/** Couple-shared premium: gates SENDING media + reactions (false until confirmed). */
val canSendMedia: Boolean = false,
val messageInput: String = "",
val isLoading: Boolean = true
)
@OptIn(ExperimentalCoroutinesApi::class)
@HiltViewModel
class ConversationViewModel @Inject constructor(
private val repository: ConversationRepository,
@ -34,6 +57,8 @@ class ConversationViewModel @Inject constructor(
private val userRepository: UserRepository,
private val questionDao: QuestionDao,
private val activeThreadMonitor: ActiveThreadMonitor,
private val couplePremiumChecker: CouplePremiumChecker,
private val analyticsTracker: AnalyticsTracker,
savedStateHandle: SavedStateHandle
) : ViewModel() {
@ -44,6 +69,15 @@ class ConversationViewModel @Inject constructor(
private val _uiState = MutableStateFlow(ConversationUiState())
val uiState: StateFlow<ConversationUiState> = _uiState.asStateFlow()
/** One-shot, non-sticky messages for the snackbar (send failures). */
private val _events = MutableSharedFlow<String>(extraBufferCapacity = 4)
val events: SharedFlow<String> = _events.asSharedFlow()
private val messageLimit = MutableStateFlow(PAGE_SIZE)
private data class RetryData(val type: String, val bytes: ByteArray, val durationMs: Long)
private val retryStore = mutableMapOf<String, RetryData>()
init {
// Reading this conversation suppresses its bubble + clears its unread.
activeThreadMonitor.enter(conversationId)
@ -53,6 +87,7 @@ class ConversationViewModel @Inject constructor(
override fun onCleared() {
super.onCleared()
activeThreadMonitor.leave(conversationId)
stopTyping() // fire-and-forget; clears our typing flag when leaving
}
private fun load() {
@ -78,48 +113,161 @@ class ConversationViewModel @Inject constructor(
it.copy(title = title, partnerPhotoUrl = partner?.photoUrl, isLoading = false)
}
// Couple-shared premium gate for sending media + reactions.
launch {
couplePremiumChecker.coupleHasPremium(partnerId)
.collect { premium -> _uiState.update { it.copy(canSendMedia = premium) } }
}
runCatching { repository.markRead(coupleId, conversationId, currentUserId) }
launch {
repository.observeMessages(coupleId, conversationId).collect { msgs ->
_uiState.update { it.copy(messages = msgs) }
runCatching { repository.markRead(coupleId, conversationId, currentUserId) }
}
messageLimit
.flatMapLatest { limit -> repository.observeMessages(coupleId, conversationId, limit) }
.collect { msgs ->
_uiState.update { it.copy(messages = msgs, canLoadMore = msgs.size >= messageLimit.value) }
runCatching { repository.markRead(coupleId, conversationId, currentUserId) }
}
}
launch {
repository.observePartnerReadAt(coupleId, conversationId, currentUserId)
.collect { readAt -> _uiState.update { it.copy(partnerReadAt = readAt) } }
}
// Partner typing: combine the typing timestamp with a ticker so it auto-hides after ~6s
// even if the partner's "stop" write never arrives (crash safety net).
launch {
combine(
repository.observePartnerTyping(coupleId, conversationId, currentUserId),
flow { while (true) { emit(Unit); delay(2000) } }
) { typingAt, _ -> typingAt > 0 && System.currentTimeMillis() - typingAt < TYPING_TTL_MS }
.distinctUntilChanged()
.collect { typing -> _uiState.update { it.copy(partnerTyping = typing) } }
}
}
}
private var typingStopJob: Job? = null
private var lastTypingPingAt = 0L
private fun onUserTyping() {
val now = System.currentTimeMillis()
if (now - lastTypingPingAt > 1500) {
lastTypingPingAt = now
repository.setTyping(coupleId, conversationId, currentUserId, true)
}
typingStopJob?.cancel()
typingStopJob = viewModelScope.launch {
delay(3000)
stopTyping()
}
}
private fun stopTyping() {
typingStopJob?.cancel()
lastTypingPingAt = 0L
repository.setTyping(coupleId, conversationId, currentUserId, false)
}
/** Grow the live window by another page (triggered when scrolled to the top). */
fun loadOlderMessages() {
if (_uiState.value.canLoadMore) messageLimit.update { it + PAGE_SIZE }
}
fun updateMessageInput(text: String) {
_uiState.update { it.copy(messageInput = text.take(MAX_MESSAGE_LENGTH)) }
if (text.isNotBlank()) onUserTyping()
}
fun sendMessage() {
val text = _uiState.value.messageInput.trim()
if (text.isBlank() || currentUserId.isEmpty()) return
_uiState.update { it.copy(messageInput = "") }
stopTyping()
viewModelScope.launch {
runCatching { repository.sendMessage(coupleId, conversationId, currentUserId, text) }
.onFailure { _events.tryEmit("Couldn't send message. Check your connection.") }
}
}
fun sendImage(imageBytes: ByteArray) {
if (currentUserId.isEmpty() || imageBytes.isEmpty()) return
val id = UUID.randomUUID().toString()
retryStore[id] = RetryData("image", imageBytes, 0L)
addPending(PendingMedia(id, "image"))
viewModelScope.launch {
runCatching { repository.sendImageMessage(coupleId, conversationId, currentUserId, imageBytes) }
.onSuccess { removePending(id); retryStore.remove(id) }
.onFailure { markPendingFailed(id); _events.tryEmit("Couldn't send photo. Tap retry.") }
}
}
fun sendVoice(audioBytes: ByteArray, durationMs: Long) {
if (currentUserId.isEmpty() || audioBytes.isEmpty()) return
val id = UUID.randomUUID().toString()
retryStore[id] = RetryData("voice", audioBytes, durationMs)
addPending(PendingMedia(id, "voice"))
viewModelScope.launch {
runCatching { repository.sendVoiceMessage(coupleId, conversationId, currentUserId, audioBytes, durationMs) }
.onSuccess { removePending(id); retryStore.remove(id) }
.onFailure { markPendingFailed(id); _events.tryEmit("Couldn't send voice note. Tap retry.") }
}
}
/** Toggle the caller's emoji reaction on a message (tap the same emoji again to remove). */
fun react(messageId: String, emoji: String) {
if (currentUserId.isEmpty()) return
val current = _uiState.value.messages.firstOrNull { it.id == messageId }?.reactions?.get(currentUserId)
val next = if (current == emoji) null else emoji
viewModelScope.launch {
runCatching { repository.setReaction(coupleId, conversationId, messageId, currentUserId, next) }
.onFailure { _events.tryEmit("Couldn't add reaction.") }
}
}
/** Called when a gated media/reaction action routes the user to the paywall. */
fun onMediaPaywallShown() {
analyticsTracker.trackPaywallViewed("chat_media")
}
fun deleteMessage(messageId: String) {
viewModelScope.launch {
runCatching { repository.deleteMessage(coupleId, conversationId, messageId) }
.onFailure { _events.tryEmit("Couldn't delete message.") }
}
}
fun retryMedia(id: String) {
val data = retryStore[id] ?: return
removePending(id)
retryStore.remove(id)
when (data.type) {
"image" -> sendImage(data.bytes)
"voice" -> sendVoice(data.bytes, data.durationMs)
}
}
fun dismissPending(id: String) {
removePending(id)
retryStore.remove(id)
}
suspend fun loadDecryptedMedia(mediaUrl: String): ByteArray? =
repository.loadDecryptedMedia(coupleId, mediaUrl)
private fun addPending(p: PendingMedia) =
_uiState.update { it.copy(pendingMedia = it.pendingMedia + p) }
private fun removePending(id: String) =
_uiState.update { it.copy(pendingMedia = it.pendingMedia.filterNot { m -> m.id == id }) }
private fun markPendingFailed(id: String) =
_uiState.update { it.copy(pendingMedia = it.pendingMedia.map { m -> if (m.id == id) m.copy(failed = true) else m }) }
companion object {
const val MAX_MESSAGE_LENGTH = 2000
const val PAGE_SIZE = 50
private const val TYPING_TTL_MS = 6000L
}
}

View File

@ -10,10 +10,13 @@ import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.PickVisualMediaRequest
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
@ -29,15 +32,21 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Send
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Image
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.Mic
import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.PhotoCamera
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
@ -57,12 +66,15 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider
import app.closer.core.media.MediaCompressor
import app.closer.domain.model.QuestionMessage
import coil.ImageLoader
import coil.compose.AsyncImage
@ -80,12 +92,21 @@ import java.nio.ByteBuffer
* One chat row Messenger style: only the partner's avatar shows (on the left); our own messages
* are bubbles on the right with no avatar. Handles text, encrypted image/GIF, and voice messages.
*/
val REACTION_EMOJIS = listOf("❤️", "😂", "👍", "😮", "😢", "🔥")
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun ChatMessageRow(
message: QuestionMessage,
isCurrentUser: Boolean,
partnerAvatarUrl: String?,
showAvatar: Boolean,
showTimestamp: Boolean,
showSeen: Boolean = false,
canReact: Boolean = true,
onReact: (String) -> Unit = {},
onReactBlocked: () -> Unit = {},
onDelete: () -> Unit = {},
loadDecryptedMedia: suspend (String) -> ByteArray?
) {
val bubbleShape = if (isCurrentUser) {
@ -93,35 +114,175 @@ fun ChatMessageRow(
} else {
RoundedCornerShape(topStart = 4.dp, topEnd = 16.dp, bottomStart = 16.dp, bottomEnd = 16.dp)
}
var menuOpen by remember { mutableStateOf(false) }
val clipboard = LocalClipboardManager.current
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = if (isCurrentUser) Arrangement.End else Arrangement.Start,
verticalAlignment = Alignment.Bottom
) {
if (!isCurrentUser) {
ChatAvatar(partnerAvatarUrl, visible = showAvatar)
Spacer(modifier = Modifier.width(6.dp))
}
Column(modifier = Modifier.fillMaxWidth()) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = if (isCurrentUser) Arrangement.End else Arrangement.Start,
verticalAlignment = Alignment.Bottom
) {
if (!isCurrentUser) {
ChatAvatar(partnerAvatarUrl, visible = showAvatar)
Spacer(modifier = Modifier.width(6.dp))
}
when {
message.isImage -> EncryptedChatImage(message.mediaUrl, bubbleShape, loadDecryptedMedia)
message.isVoice -> EncryptedVoiceMessage(message.mediaUrl, message.durationMs, isCurrentUser, bubbleShape, loadDecryptedMedia)
else -> Surface(
shape = bubbleShape,
color = if (isCurrentUser) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.surfaceVariant,
modifier = Modifier.widthIn(max = 264.dp)
Box(
modifier = if (message.deleted) Modifier
else Modifier.combinedClickable(onClick = {}, onLongClick = { menuOpen = true })
) {
Text(
text = message.text,
style = MaterialTheme.typography.bodyMedium,
color = if (isCurrentUser) MaterialTheme.colorScheme.onPrimary
else MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 14.dp, vertical = 9.dp)
when {
message.deleted -> DeletedBubble(bubbleShape, isCurrentUser)
message.isImage -> EncryptedChatImage(message.mediaUrl, bubbleShape, loadDecryptedMedia)
message.isVoice -> EncryptedVoiceMessage(message.mediaUrl, message.durationMs, isCurrentUser, bubbleShape, loadDecryptedMedia)
else -> Surface(
shape = bubbleShape,
color = if (isCurrentUser) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.surfaceVariant,
modifier = Modifier.widthIn(max = 264.dp)
) {
Text(
text = message.text,
style = MaterialTheme.typography.bodyMedium,
color = if (isCurrentUser) MaterialTheme.colorScheme.onPrimary
else MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 14.dp, vertical = 9.dp)
)
}
}
MessageActionMenu(
expanded = menuOpen,
onDismiss = { menuOpen = false },
canCopy = message.type == "text",
canDelete = isCurrentUser,
onReact = { emoji ->
menuOpen = false
if (canReact) onReact(emoji) else onReactBlocked()
},
onCopy = {
menuOpen = false
clipboard.setText(AnnotatedString(message.text))
},
onDelete = { menuOpen = false; onDelete() }
)
}
}
if (message.reactions.isNotEmpty() && !message.deleted) {
ReactionChip(
emojis = message.reactions.values.toList(),
modifier = Modifier
.align(if (isCurrentUser) Alignment.End else Alignment.Start)
.padding(start = if (isCurrentUser) 0.dp else 40.dp, end = if (isCurrentUser) 6.dp else 0.dp, top = 2.dp)
)
}
if (showTimestamp) {
val time = formatClockTime(message.createdAt)
Text(
text = if (showSeen) "Seen · $time" else time,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.55f),
modifier = Modifier
.align(if (isCurrentUser) Alignment.End else Alignment.Start)
.padding(start = if (isCurrentUser) 0.dp else 40.dp, end = if (isCurrentUser) 6.dp else 0.dp, top = 2.dp)
)
}
}
}
@Composable
private fun DeletedBubble(shape: Shape, isCurrentUser: Boolean) {
Surface(
shape = shape,
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
modifier = Modifier.widthIn(max = 264.dp)
) {
Text(
text = "This message was deleted",
style = MaterialTheme.typography.bodyMedium,
fontStyle = androidx.compose.ui.text.font.FontStyle.Italic,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
modifier = Modifier.padding(horizontal = 14.dp, vertical = 9.dp)
)
}
}
@Composable
private fun ReactionChip(emojis: List<String>, modifier: Modifier = Modifier) {
Surface(
shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.surfaceVariant,
modifier = modifier
) {
Text(
text = emojis.distinct().joinToString("") + if (emojis.size > 1) " ${emojis.size}" else "",
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp)
)
}
}
@Composable
private fun MessageActionMenu(
expanded: Boolean,
onDismiss: () -> Unit,
canCopy: Boolean,
canDelete: Boolean,
onReact: (String) -> Unit,
onCopy: () -> Unit,
onDelete: () -> Unit
) {
DropdownMenu(expanded = expanded, onDismissRequest = onDismiss) {
Row(
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.spacedBy(2.dp)
) {
REACTION_EMOJIS.forEach { emoji ->
Text(
text = emoji,
style = MaterialTheme.typography.titleLarge,
modifier = Modifier
.clip(CircleShape)
.clickable { onReact(emoji) }
.padding(6.dp)
)
}
}
if (canCopy) {
DropdownMenuItem(
text = { Text("Copy") },
onClick = onCopy,
leadingIcon = { Icon(Icons.Filled.ContentCopy, contentDescription = null) }
)
}
if (canDelete) {
DropdownMenuItem(
text = { Text("Delete") },
onClick = onDelete,
leadingIcon = { Icon(Icons.Filled.Delete, contentDescription = null) }
)
}
}
}
/** A centered date pill shown between messages from different days. */
@Composable
fun ChatDaySeparator(epochMillis: Long) {
Box(modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp), contentAlignment = Alignment.Center) {
Surface(
shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.7f)
) {
Text(
text = chatDayLabel(epochMillis),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 3.dp)
)
}
}
}
@ -250,6 +411,8 @@ private fun EncryptedVoiceMessage(
var tempFile by remember { mutableStateOf<File?>(null) }
var playing by remember { mutableStateOf(false) }
var loading by remember { mutableStateOf(false) }
var positionMs by remember { mutableLongStateOf(0L) }
val totalMs = durationMs.coerceAtLeast(1L)
DisposableEffect(Unit) {
onDispose {
@ -258,6 +421,14 @@ private fun EncryptedVoiceMessage(
}
}
// Poll the player position so the progress bar advances during playback.
LaunchedEffect(playing) {
while (playing) {
positionMs = player?.currentPosition?.toLong() ?: positionMs
delay(100)
}
}
fun toggle() {
val p = player
if (p != null) {
@ -276,7 +447,7 @@ private fun EncryptedVoiceMessage(
val mp = MediaPlayer()
runCatching {
mp.setDataSource(f.absolutePath)
mp.setOnCompletionListener { playing = false; runCatching { mp.seekTo(0) } }
mp.setOnCompletionListener { playing = false; positionMs = 0L; runCatching { mp.seekTo(0) } }
mp.prepare()
mp.start()
}.onSuccess { player = mp; playing = true }.onFailure { mp.release() }
@ -306,8 +477,17 @@ private fun EncryptedVoiceMessage(
)
}
}
Icon(Icons.Filled.Mic, contentDescription = null, tint = tint.copy(alpha = 0.7f), modifier = Modifier.size(16.dp))
Text(text = formatDuration(durationMs), style = MaterialTheme.typography.bodyMedium, color = tint)
LinearProgressIndicator(
progress = { if (totalMs > 0) (positionMs.toFloat() / totalMs).coerceIn(0f, 1f) else 0f },
modifier = Modifier.width(92.dp),
color = tint,
trackColor = tint.copy(alpha = 0.25f)
)
Text(
text = formatDuration(if (playing || positionMs > 0) positionMs else durationMs),
style = MaterialTheme.typography.bodyMedium,
color = tint
)
}
}
}
@ -347,15 +527,20 @@ fun ChatComposer(
onSend: () -> Unit,
onSendImage: (ByteArray) -> Unit,
onSendVoice: (ByteArray, Long) -> Unit,
canSendMedia: Boolean = true,
onUpgrade: () -> Unit = {},
modifier: Modifier = Modifier
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
fun readAndSend(uri: Uri) {
// compress = true for real photos (gallery/camera); false for keyboard GIFs/stickers/Bitmoji
// (compressing those would kill animation/transparency).
fun readAndSend(uri: Uri, compress: Boolean) {
scope.launch {
val bytes = withContext(Dispatchers.IO) {
runCatching { context.contentResolver.openInputStream(uri)?.use { it.readBytes() } }.getOrNull()
val raw = runCatching { context.contentResolver.openInputStream(uri)?.use { it.readBytes() } }.getOrNull()
raw?.let { if (compress) MediaCompressor.compressPhoto(it) else it }
}
bytes?.takeIf { it.isNotEmpty() }?.let(onSendImage)
}
@ -363,12 +548,12 @@ fun ChatComposer(
val galleryLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.PickVisualMedia()
) { uri: Uri? -> uri?.let { readAndSend(it) } }
) { uri: Uri? -> uri?.let { readAndSend(it, compress = true) } }
var pendingCameraUri by remember { mutableStateOf<Uri?>(null) }
val cameraLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.TakePicture()
) { success: Boolean -> if (success) pendingCameraUri?.let { readAndSend(it) } }
) { success: Boolean -> if (success) pendingCameraUri?.let { readAndSend(it, compress = true) } }
fun launchCamera() {
val file = File(context.cacheDir, "chat_capture_${System.currentTimeMillis()}.jpg")
@ -427,7 +612,11 @@ fun ChatComposer(
) { granted: Boolean -> if (granted) startRecording() }
LaunchedEffect(isRecording) {
while (isRecording) { elapsedMs = System.currentTimeMillis() - recordStart; delay(200) }
while (isRecording) {
elapsedMs = System.currentTimeMillis() - recordStart
if (elapsedMs >= MAX_RECORDING_MS) { finishRecording(send = true); break } // auto-send at the cap
delay(200)
}
}
DisposableEffect(Unit) {
onDispose { runCatching { recorder.value?.release() }; recordFile.value?.delete() }
@ -461,21 +650,23 @@ fun ChatComposer(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(2.dp)
) {
IconButton(
ComposerMediaButton(
icon = Icons.Filled.Image,
description = "Send a photo",
locked = !canSendMedia,
onClick = { galleryLauncher.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)) },
modifier = Modifier.size(40.dp)
) {
Icon(Icons.Filled.Image, contentDescription = "Send a photo", tint = MaterialTheme.colorScheme.primary)
}
IconButton(
onUpgrade = onUpgrade
)
ComposerMediaButton(
icon = Icons.Filled.PhotoCamera,
description = "Take a photo",
locked = !canSendMedia,
onClick = {
if (ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED)
launchCamera() else cameraPermissionLauncher.launch(Manifest.permission.CAMERA)
},
modifier = Modifier.size(40.dp)
) {
Icon(Icons.Filled.PhotoCamera, contentDescription = "Take a photo", tint = MaterialTheme.colorScheme.primary)
}
onUpgrade = onUpgrade
)
// Field that accepts GIFs / stickers / Bitmoji inserted from the keyboard (rich content).
Surface(
@ -487,23 +678,26 @@ fun ChatComposer(
RichContentTextField(
value = value,
onValueChange = onValueChange,
onImageReceived = { uri -> readAndSend(uri) },
// Keyboard GIF/sticker/Bitmoji: gated → route to paywall instead of sending.
onImageReceived = { uri -> if (canSendMedia) readAndSend(uri, compress = false) else onUpgrade() },
modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp),
hint = "Message…"
)
}
if (value.isBlank()) {
// Mic when there's nothing typed (tap to record a voice note).
IconButton(
// Mic when there's nothing typed (tap to record a voice note) — gated behind premium.
ComposerMediaButton(
icon = Icons.Filled.Mic,
description = "Record a voice note",
locked = !canSendMedia,
size = 48.dp,
onClick = {
if (ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED)
startRecording() else micPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
},
modifier = Modifier.size(48.dp)
) {
Icon(Icons.Filled.Mic, contentDescription = "Record a voice note", tint = MaterialTheme.colorScheme.primary)
}
onUpgrade = onUpgrade
)
} else {
IconButton(onClick = onSend, modifier = Modifier.size(48.dp)) {
Icon(Icons.AutoMirrored.Filled.Send, contentDescription = "Send", tint = MaterialTheme.colorScheme.primary)
@ -512,9 +706,72 @@ fun ChatComposer(
}
}
/** Cap voice notes at 2 minutes; recording auto-stops and sends at this length. */
private const val MAX_RECORDING_MS = 120_000L
/** A composer media button with a lock badge + paywall routing when the couple isn't premium. */
@Composable
private fun ComposerMediaButton(
icon: androidx.compose.ui.graphics.vector.ImageVector,
description: String,
locked: Boolean,
onClick: () -> Unit,
onUpgrade: () -> Unit,
size: androidx.compose.ui.unit.Dp = 40.dp
) {
Box {
IconButton(onClick = { if (locked) onUpgrade() else onClick() }, modifier = Modifier.size(size)) {
Icon(
imageVector = icon,
contentDescription = description,
tint = if (locked) MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.45f)
else MaterialTheme.colorScheme.primary
)
}
if (locked) {
Icon(
imageVector = Icons.Filled.Lock,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(12.dp).align(Alignment.BottomEnd)
)
}
}
}
private fun formatDuration(ms: Long): String {
val totalSec = (ms / 1000).toInt()
val m = totalSec / 60
val s = totalSec % 60
return "%d:%02d".format(m, s)
}
/** "3:45 PM" — falls back to now for a just-sent message whose server timestamp hasn't resolved. */
private fun formatClockTime(ms: Long): String {
val t = if (ms > 0) ms else System.currentTimeMillis()
return java.text.SimpleDateFormat("h:mm a", java.util.Locale.getDefault()).format(java.util.Date(t))
}
/** "Today" / "Yesterday" / "March 4" for day separators. */
fun chatDayLabel(ms: Long): String {
val t = if (ms > 0) ms else System.currentTimeMillis()
val cal = java.util.Calendar.getInstance().apply { timeInMillis = t }
val today = java.util.Calendar.getInstance()
val yesterday = (today.clone() as java.util.Calendar).apply { add(java.util.Calendar.DAY_OF_YEAR, -1) }
return when {
isSameCalendarDay(cal, today) -> "Today"
isSameCalendarDay(cal, yesterday) -> "Yesterday"
else -> java.text.SimpleDateFormat("MMMM d", java.util.Locale.getDefault()).format(java.util.Date(t))
}
}
/** True when two epoch millis fall on the same calendar day (0 is treated as now). */
fun isSameChatDay(a: Long, b: Long): Boolean {
val ca = java.util.Calendar.getInstance().apply { timeInMillis = if (a > 0) a else System.currentTimeMillis() }
val cb = java.util.Calendar.getInstance().apply { timeInMillis = if (b > 0) b else System.currentTimeMillis() }
return isSameCalendarDay(ca, cb)
}
private fun isSameCalendarDay(a: java.util.Calendar, b: java.util.Calendar): Boolean =
a.get(java.util.Calendar.YEAR) == b.get(java.util.Calendar.YEAR) &&
a.get(java.util.Calendar.DAY_OF_YEAR) == b.get(java.util.Calendar.DAY_OF_YEAR)

View File

@ -2,7 +2,7 @@ package app.closer.ui.play
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import app.closer.core.billing.EntitlementChecker
import app.closer.core.billing.CouplePremiumChecker
import app.closer.domain.usecase.GameSessionManager
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
@ -15,10 +15,10 @@ import kotlinx.coroutines.launch
@HiltViewModel
class PlayHubViewModel @Inject constructor(
entitlementChecker: EntitlementChecker,
premiumChecker: CouplePremiumChecker,
private val gameSessionManager: GameSessionManager
) : ViewModel() {
val hasPremium: StateFlow<Boolean> = entitlementChecker.isPremium()
val hasPremium: StateFlow<Boolean> = premiumChecker.isPremium()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false)
// Default true so paired users never see an invite redirect flash while this loads.

View File

@ -2,7 +2,7 @@ package app.closer.ui.questions
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import app.closer.core.billing.EntitlementChecker
import app.closer.core.billing.CouplePremiumChecker
import app.closer.domain.model.QuestionCategory
import app.closer.domain.repository.QuestionRepository
import dagger.hilt.android.lifecycle.HiltViewModel
@ -29,7 +29,7 @@ data class QuestionPackLibraryUiState(
@HiltViewModel
class QuestionPackLibraryViewModel @Inject constructor(
private val repository: QuestionRepository,
private val entitlementChecker: EntitlementChecker
private val premiumChecker: CouplePremiumChecker
) : ViewModel() {
private val _uiState = MutableStateFlow(QuestionPackLibraryUiState())
@ -43,7 +43,7 @@ class QuestionPackLibraryViewModel @Inject constructor(
viewModelScope.launch {
_uiState.value = QuestionPackLibraryUiState(isLoading = true)
try {
val hasPremium = entitlementChecker.isPremium().first()
val hasPremium = premiumChecker.isPremium().first()
val packs = repository.getCategories().map { category ->
val isPremium = category.access == "premium"
QuestionPackItem(

View File

@ -46,7 +46,6 @@ import app.closer.ui.components.ErrorState
import app.closer.ui.components.LoadingState
import app.closer.ui.questions.components.AnswerBubble
import app.closer.ui.questions.components.QuestionAnswerInput
import app.closer.ui.questions.components.QuestionDiscussionThread
import app.closer.ui.questions.components.QuestionHeader
import app.closer.ui.theme.closerBackgroundBrush
@ -235,17 +234,19 @@ private fun RevealedPhase(
)
}
QuestionDiscussionThread(
messages = state.messages,
currentUserId = viewModel.currentUserId,
partnerPhotoUrl = state.partnerPhotoUrl,
messageInput = state.messageInput,
onMessageInputChanged = viewModel::updateMessageInput,
onSendMessage = viewModel::sendMessage,
isRevealed = true,
onSendImage = viewModel::sendImage,
loadDecryptedMedia = viewModel::loadDecryptedMedia
)
// Discussion now lives in the unified Messages conversation (q_<questionId>) so it's notified
// and appears in the inbox — same as the daily flow.
Button(
onClick = { onNavigate(AppRoute.conversation(coupleId, "q_$questionId")) },
modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp),
shape = RoundedCornerShape(16.dp),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.secondaryContainer,
contentColor = MaterialTheme.colorScheme.onSecondaryContainer
)
) {
Text("Discuss this together")
}
// Navigation out of the thread
Row(

View File

@ -2,7 +2,7 @@ package app.closer.ui.wheel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import app.closer.core.billing.EntitlementChecker
import app.closer.core.billing.CouplePremiumChecker
import app.closer.core.navigation.AppRoute
import app.closer.domain.model.GameType
import app.closer.domain.model.QuestionCategory
@ -33,7 +33,7 @@ data class CategoryPickerUiState(
@HiltViewModel
class CategoryPickerViewModel @Inject constructor(
private val repository: QuestionRepository,
private val entitlementChecker: EntitlementChecker,
private val premiumChecker: CouplePremiumChecker,
private val gameSessionManager: GameSessionManager
) : ViewModel() {
@ -78,7 +78,7 @@ class CategoryPickerViewModel @Inject constructor(
// checkActiveSession isn't clobbered by the category load finishing.
_uiState.update { it.copy(isLoading = true, error = null) }
try {
val hasPremium = entitlementChecker.isPremium().first()
val hasPremium = premiumChecker.isPremium().first()
val items = repository.getCategories().map { category ->
CategoryPickerItem(
category = category,

View File

@ -4,7 +4,7 @@ import android.util.Log
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import app.closer.core.billing.EntitlementChecker
import app.closer.core.billing.CouplePremiumChecker
import app.closer.core.navigation.AppRoute
import app.closer.domain.model.GameType
import app.closer.domain.model.QuestionCategory
@ -38,7 +38,7 @@ class SpinWheelViewModel @Inject constructor(
private val repository: QuestionRepository,
private val sessionStore: LocalWheelSessionStore,
private val gameSessionManager: GameSessionManager,
private val entitlementChecker: EntitlementChecker
private val premiumChecker: CouplePremiumChecker
) : ViewModel() {
private val categoryId: String = savedStateHandle["categoryId"] ?: ""
@ -56,7 +56,7 @@ class SpinWheelViewModel @Inject constructor(
private fun loadPremiumStatus() {
viewModelScope.launch {
val hasPremium = runCatching { entitlementChecker.isPremium().first() }.getOrDefault(false)
val hasPremium = runCatching { premiumChecker.isPremium().first() }.getOrDefault(false)
_uiState.update { it.copy(hasPremium = hasPremium) }
}
}

View File

@ -2,7 +2,7 @@ package app.closer.ui.wheel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import app.closer.core.billing.EntitlementChecker
import app.closer.core.billing.CouplePremiumChecker
import app.closer.data.challenges.ChallengesCatalog
import app.closer.data.remote.FirestoreCapsuleDataSource
import app.closer.data.remote.FirestoreChallengeDataSource
@ -45,14 +45,14 @@ class GameHistoryViewModel @Inject constructor(
private val coupleRepository: CoupleRepository,
private val challengeDataSource: FirestoreChallengeDataSource,
private val capsuleDataSource: FirestoreCapsuleDataSource,
entitlementChecker: EntitlementChecker
premiumChecker: CouplePremiumChecker
) : ViewModel() {
private val _uiState = MutableStateFlow(GameHistoryUiState())
val uiState: StateFlow<GameHistoryUiState> = _uiState.asStateFlow()
init {
entitlementChecker.isPremium()
premiumChecker.isPremium()
.onEach { premium ->
_uiState.update { it.copy(hasPremium = premium) }
if (premium) load()

View File

@ -176,7 +176,17 @@ service cloud.firestore {
// Entitlements written server-side only (RevenueCat webhook via Admin SDK).
// Client needs read access so FirestoreEntitlementChecker can observe premium state.
match /entitlements/{entitlementDoc} {
allow read: if isOwner(uid);
// Owner reads their own; a paired partner may also read it so premium can be shared
// across the couple (chat media unlocks if EITHER partner is premium).
allow read: if isOwner(uid)
|| (
request.auth != null
&& exists(/databases/$(database)/documents/users/$(uid))
&& get(/databases/$(database)/documents/users/$(uid)).data.coupleId != null
&& exists(/databases/$(database)/documents/users/$(request.auth.uid))
&& get(/databases/$(database)/documents/users/$(request.auth.uid)).data.coupleId
== get(/databases/$(database)/documents/users/$(uid)).data.coupleId
);
allow write: if false;
}
@ -422,7 +432,7 @@ service cloud.firestore {
// last-message preview must be ciphertext (encrypted on-device before write).
allow write: if isCouplesMember(coupleId)
&& request.resource.data.keys().hasOnly(
['type', 'questionId', 'createdAt', 'lastMessageAt', 'lastMessagePreview', 'lastMessageSenderId', 'reads'])
['type', 'questionId', 'createdAt', 'lastMessageAt', 'lastMessagePreview', 'lastMessageSenderId', 'reads', 'typing'])
&& (!('lastMessagePreview' in request.resource.data)
|| isCiphertext(request.resource.data.lastMessagePreview));
@ -433,7 +443,7 @@ service cloud.firestore {
allow create: if isCouplesMember(coupleId)
&& coupleEncryptionEnabled(coupleId)
&& request.resource.data.authorUserId == request.auth.uid
&& request.resource.data.keys().hasOnly(['authorUserId', 'text', 'createdAt', 'type', 'mediaUrl', 'durationMs'])
&& request.resource.data.keys().hasOnly(['authorUserId', 'text', 'createdAt', 'type', 'mediaUrl', 'durationMs', 'reactions', 'deleted'])
&& (
(request.resource.data.get('type', 'text') in ['image', 'voice']
&& request.resource.data.mediaUrl is string
@ -442,7 +452,15 @@ service cloud.firestore {
(request.resource.data.get('type', 'text') == 'text'
&& isCiphertext(request.resource.data.text))
);
allow update, delete: if false;
// Reactions: any couple member may change ONLY the reactions map.
// Unsend: only the author may set the `deleted` tombstone.
allow update: if isCouplesMember(coupleId) && (
request.resource.data.diff(resource.data).affectedKeys().hasOnly(['reactions'])
||
(resource.data.authorUserId == request.auth.uid
&& request.resource.data.diff(resource.data).affectedKeys().hasOnly(['deleted']))
);
allow delete: if false;
}
}