Compare commits
No commits in common. "bbd7ef080691c54b90896710281ae837a728f678" and "11a4c7dedac09974ad3751e7ddc48c6ddda7c184" have entirely different histories.
bbd7ef0806
...
11a4c7deda
|
|
@ -1,45 +0,0 @@
|
||||||
# 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 (D1–D6)
|
|
||||||
_todo_
|
|
||||||
|
|
||||||
## Pass E — Notifications (17 types × {foreground, background, killed} + tap-to-open)
|
|
||||||
_todo_
|
|
||||||
250
ClaudeQAPlan.md
250
ClaudeQAPlan.md
|
|
@ -1,250 +0,0 @@
|
||||||
# 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 P0–P2, 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) | 1–2 |
|
|
||||||
| B Games | **one game per chunk** — full two-device playthrough + edges + commit | 7 |
|
|
||||||
| C Visual | **one screen-group per chunk** (both themes, ~6–10 screens, montage-reviewed + nav/back for that group) — never "all screens" at once (heaviest, image-bound) | 6–8 |
|
|
||||||
| D Security | D1 at-rest · D2 rules + D3 negative · D4 keys/recovery · D5–D7 appcheck/secrets/leaks/migration | ~4 |
|
|
||||||
| E Notifications | **3–5 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 P0–P2 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 P0–P2 and Passes D+E fully clean.
|
|
||||||
125
ClaudeReport.md
125
ClaudeReport.md
|
|
@ -1,82 +1,71 @@
|
||||||
# Claude QA Report — Full-App QA (living report)
|
# Claude QA Report — Games & Notifications
|
||||||
|
|
||||||
> **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.**
|
**Updated:** 2026-06-24
|
||||||
> Round 1 complete (all 5 passes run report-only; P0–P2 found were fixed in-line). Fixes: A-001 (e8892a9), E-001 (ce12abb). Open P3: A-003, B-001, E-002.
|
**Devices:** emulator-5554 (Device A = QATester) + emulator-5556 (Device B = Sam), paired for real (coupleId `xNd1H2UGUDNqvyrDGgfu`).
|
||||||
> **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.**
|
**Focus this pass:** every game works end-to-end **and notifications fire correctly on game start + finish.**
|
||||||
> 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`.
|
|
||||||
|
|
||||||
_(Prior games/notifications QA from 2026-06-24 was completed + verified; superseded by this full-app effort.)_
|
**Severity:** 🔴 critical · 🟠 high · 🟡 medium · 🟢 low
|
||||||
|
**Status:** 🔎 found · 🛠 fixing · ✅ fixed & builds · ✅✅ verified live · ⚠️ needs deploy
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Severity summary (Round 1)
|
## OPEN — current error log
|
||||||
| Severity | Open | Fixed |
|
|
||||||
|---|---|---|
|
|
||||||
| P0 | 0 | 0 |
|
|
||||||
| P1 | 0 | 1 |
|
|
||||||
| P2 | 0 | 1 |
|
|
||||||
| P3 | 3 | 0 |
|
|
||||||
|
|
||||||
**Round 1: all P0–P2 found were FIXED** (A-001 premium P1, E-001 notif-routing P2). Open = P3 cosmetics only
|
### N1. 🔴 FCM token was NEVER registered → no push notifications worked at all — ✅✅ FIXED & VERIFIED
|
||||||
(A-003 badge, B-001 stale-session guard, E-002 informational notif routing). Deferred for a clean "flawless"
|
`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.
|
||||||
certification: exhaustive deep/stateful screens (Pass C), full live notification matrix + D3 live non-member test.
|
**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. N3–N5).
|
||||||
|
**Action:** `firebase deploy --only functions` (allow it to delete `createDateMatchOnMutualLove`).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Pass A — Couple-shared premium ✅ pass complete
|
## Per-game status
|
||||||
**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.
|
|
||||||
|
|
||||||
| ID | Area | Screen/Route | Severity | Description | Repro | Status |
|
| Game | Functional | Start notif | Finish notif |
|
||||||
|---|---|---|---|---|---|---|
|
|---|---|---|---|
|
||||||
| 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. |
|
| Spin the Wheel | ✅✅ live (prior) | via `sessions` trigger* | via `sessions` trigger* |
|
||||||
| 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) |
|
| This or That | ✅✅ live (prior) | via `sessions` trigger* | via `sessions` trigger* |
|
||||||
| 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 |
|
| 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 |
|
||||||
|
|
||||||
**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.
|
\* **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.
|
||||||
|
|
||||||
## 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 |
|
|
||||||
|
|
||||||
**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).
|
## 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).
|
||||||
|
|
||||||
## Pass C — Visual (light + dark) (main screens verified; deep/stateful screens pending)
|
## Completed earlier (kept for reference, no longer open)
|
||||||
**Method:** 5554=Dark, 5556=Light; readable dark|light pair montages + a code scan for non-adapting colors.
|
- Daily reveal sealed-key exchange (release-key tolerant read, epoch-millis `updatedAt`, id→label mapping) — ✅✅ verified live.
|
||||||
**Verified clean (both themes, readable, no clipping):** Home, Today, Play, Messages inbox, Settings. `closerBackgroundBrush()` is theme-aware (adapts). No FATAL on these.
|
- Game-start crash (`saveSession` empty id → invalid path) — ✅✅ fixed; This or That verified live.
|
||||||
| ID | Area | Screen | Severity | Description | Status |
|
- Game re-entry flicker/re-submit (This or That, How Well, Desire Sync) — ✅ pre-check → WAITING.
|
||||||
|---|---|---|---|---|---|
|
- Daily question determinism + shared `DailyQuestionResolver` — ✅✅ verified live.
|
||||||
| 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) |
|
- 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).
|
||||||
_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
113
ClaudeiOSPlan.md
|
|
@ -1,113 +0,0 @@
|
||||||
# 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 A–E on iOS)
|
|
||||||
Same methodology, severity scale (P0–P3), 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 P0–P2, 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).
|
|
||||||
|
|
@ -1,65 +0,0 @@
|
||||||
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() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,81 +0,0 @@
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
@ -1,14 +1,11 @@
|
||||||
package app.closer.data.remote
|
package app.closer.data.remote
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.net.ConnectivityManager
|
|
||||||
import android.net.NetworkCapabilities
|
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import com.google.firebase.storage.FirebaseStorage
|
import com.google.firebase.storage.FirebaseStorage
|
||||||
import com.google.firebase.storage.StorageMetadata
|
import com.google.firebase.storage.StorageMetadata
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||||
import java.io.IOException
|
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
import kotlin.coroutines.resume
|
import kotlin.coroutines.resume
|
||||||
|
|
@ -19,17 +16,7 @@ class FirebaseStorageDataSource @Inject constructor(
|
||||||
@ApplicationContext private val context: Context
|
@ApplicationContext private val context: Context
|
||||||
) {
|
) {
|
||||||
|
|
||||||
// Cap retries so an offline upload surfaces a failure in seconds instead of the ~2-minute default.
|
private val storage = FirebaseStorage.getInstance()
|
||||||
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 =
|
suspend fun uploadProfilePhoto(uid: String, uri: Uri): String =
|
||||||
suspendCancellableCoroutine { cont ->
|
suspendCancellableCoroutine { cont ->
|
||||||
|
|
@ -53,11 +40,6 @@ class FirebaseStorageDataSource @Inject constructor(
|
||||||
*/
|
*/
|
||||||
suspend fun uploadEncryptedMedia(uid: String, encryptedBytes: ByteArray): String =
|
suspend fun uploadEncryptedMedia(uid: String, encryptedBytes: ByteArray): String =
|
||||||
suspendCancellableCoroutine { cont ->
|
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 ref = storage.reference.child("users/$uid/chat_media/${java.util.UUID.randomUUID()}")
|
||||||
val metadata = StorageMetadata.Builder()
|
val metadata = StorageMetadata.Builder()
|
||||||
.setContentType("application/octet-stream")
|
.setContentType("application/octet-stream")
|
||||||
|
|
|
||||||
|
|
@ -149,15 +149,9 @@ class FirestoreConversationDataSource @Inject constructor(
|
||||||
).voidAwait()
|
).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)
|
val listener = messagesRef(coupleId, conversationId)
|
||||||
.orderBy("createdAt", Query.Direction.ASCENDING)
|
.orderBy("createdAt", Query.Direction.ASCENDING)
|
||||||
.limitToLast(limit.toLong())
|
|
||||||
.addSnapshotListener { snap, err ->
|
.addSnapshotListener { snap, err ->
|
||||||
if (err != null || snap == null) return@addSnapshotListener
|
if (err != null || snap == null) return@addSnapshotListener
|
||||||
val aead = encryptionManager.aeadFor(coupleId)
|
val aead = encryptionManager.aeadFor(coupleId)
|
||||||
|
|
@ -166,66 +160,6 @@ class FirestoreConversationDataSource @Inject constructor(
|
||||||
awaitClose { listener.remove() }
|
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? {
|
suspend fun loadDecryptedMedia(coupleId: String, mediaUrl: String): ByteArray? {
|
||||||
val aead = encryptionManager.aeadFor(coupleId) ?: return null
|
val aead = encryptionManager.aeadFor(coupleId) ?: return null
|
||||||
val cipher = runCatching { storageDataSource.downloadBytes(mediaUrl) }.getOrNull() ?: return null
|
val cipher = runCatching { storageDataSource.downloadBytes(mediaUrl) }.getOrNull() ?: return null
|
||||||
|
|
@ -262,16 +196,12 @@ class FirestoreConversationDataSource @Inject constructor(
|
||||||
): QuestionMessage? {
|
): QuestionMessage? {
|
||||||
val userId = getString("authorUserId") ?: return null
|
val userId = getString("authorUserId") ?: return null
|
||||||
val type = getString("type") ?: "text"
|
val type = getString("type") ?: "text"
|
||||||
@Suppress("UNCHECKED_CAST")
|
|
||||||
val reactions = (get("reactions") as? Map<String, String>).orEmpty()
|
|
||||||
return QuestionMessage(
|
return QuestionMessage(
|
||||||
id = id,
|
id = id,
|
||||||
userId = userId,
|
userId = userId,
|
||||||
type = type,
|
type = type,
|
||||||
mediaUrl = getString("mediaUrl") ?: "",
|
mediaUrl = getString("mediaUrl") ?: "",
|
||||||
durationMs = getLong("durationMs") ?: 0L,
|
durationMs = getLong("durationMs") ?: 0L,
|
||||||
reactions = reactions,
|
|
||||||
deleted = getBoolean("deleted") ?: false,
|
|
||||||
text = if (type == "text") (fieldEncryptor.decryptForDisplay(getString("text"), aead, coupleId) ?: "") else "",
|
text = if (type == "text") (fieldEncryptor.decryptForDisplay(getString("text"), aead, coupleId) ?: "") else "",
|
||||||
createdAt = getTimestamp("createdAt")?.toDate()?.time ?: 0L
|
createdAt = getTimestamp("createdAt")?.toDate()?.time ?: 0L
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -25,17 +25,8 @@ class ConversationRepositoryImpl @Inject constructor(
|
||||||
override suspend fun markRead(coupleId: String, conversationId: String, userId: String) =
|
override suspend fun markRead(coupleId: String, conversationId: String, userId: String) =
|
||||||
dataSource.markRead(coupleId, conversationId, userId)
|
dataSource.markRead(coupleId, conversationId, userId)
|
||||||
|
|
||||||
override fun observeMessages(coupleId: String, conversationId: String, limit: Int): Flow<List<QuestionMessage>> =
|
override fun observeMessages(coupleId: String, conversationId: String): Flow<List<QuestionMessage>> =
|
||||||
dataSource.observeMessages(coupleId, conversationId, limit)
|
dataSource.observeMessages(coupleId, conversationId)
|
||||||
|
|
||||||
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) =
|
override suspend fun sendMessage(coupleId: String, conversationId: String, userId: String, text: String) =
|
||||||
dataSource.sendMessage(coupleId, conversationId, userId, text)
|
dataSource.sendMessage(coupleId, conversationId, userId, text)
|
||||||
|
|
@ -46,12 +37,6 @@ class ConversationRepositoryImpl @Inject constructor(
|
||||||
override suspend fun sendVoiceMessage(coupleId: String, conversationId: String, userId: String, audioBytes: ByteArray, durationMs: Long) =
|
override suspend fun sendVoiceMessage(coupleId: String, conversationId: String, userId: String, audioBytes: ByteArray, durationMs: Long) =
|
||||||
dataSource.sendVoiceMessage(coupleId, conversationId, userId, audioBytes, durationMs)
|
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? =
|
override suspend fun loadDecryptedMedia(coupleId: String, mediaUrl: String): ByteArray? =
|
||||||
dataSource.loadDecryptedMedia(coupleId, mediaUrl)
|
dataSource.loadDecryptedMedia(coupleId, mediaUrl)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,12 +10,8 @@ data class QuestionMessage(
|
||||||
val mediaUrl: String = "",
|
val mediaUrl: String = "",
|
||||||
/** Voice-note length in milliseconds (0 for non-voice). */
|
/** Voice-note length in milliseconds (0 for non-voice). */
|
||||||
val durationMs: Long = 0L,
|
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 createdAt: Long = 0L
|
||||||
) {
|
) {
|
||||||
val isImage: Boolean get() = type == "image" && mediaUrl.isNotBlank() && !deleted
|
val isImage: Boolean get() = type == "image" && mediaUrl.isNotBlank()
|
||||||
val isVoice: Boolean get() = type == "voice" && mediaUrl.isNotBlank() && !deleted
|
val isVoice: Boolean get() = type == "voice" && mediaUrl.isNotBlank()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,14 +9,9 @@ interface ConversationRepository {
|
||||||
suspend fun ensureMainConversation(coupleId: String)
|
suspend fun ensureMainConversation(coupleId: String)
|
||||||
suspend fun ensureQuestionConversation(coupleId: String, questionId: String): String
|
suspend fun ensureQuestionConversation(coupleId: String, questionId: String): String
|
||||||
suspend fun markRead(coupleId: String, conversationId: String, userId: String)
|
suspend fun markRead(coupleId: String, conversationId: String, userId: String)
|
||||||
fun observeMessages(coupleId: String, conversationId: String, limit: Int): Flow<List<QuestionMessage>>
|
fun observeMessages(coupleId: String, conversationId: String): 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 sendMessage(coupleId: String, conversationId: String, userId: String, text: String)
|
||||||
suspend fun sendImageMessage(coupleId: String, conversationId: String, userId: String, imageBytes: ByteArray)
|
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 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?
|
suspend fun loadDecryptedMedia(coupleId: String, mediaUrl: String): ByteArray?
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -287,12 +287,10 @@ enum class PartnerNotificationType(
|
||||||
// is ready, so this maps to the results-ready copy (not "open yours when ready").
|
// is ready, so this maps to the results-ready copy (not "open yours when ready").
|
||||||
"partner_finished_game" -> GAME_RESULTS_READY
|
"partner_finished_game" -> GAME_RESULTS_READY
|
||||||
"game_results_ready" -> GAME_RESULTS_READY
|
"game_results_ready" -> GAME_RESULTS_READY
|
||||||
// Server emits 'challenge_day_ready'; keep the legacy 'challenge_waiting' alias too.
|
"challenge_waiting" -> CHALLENGE_WAITING
|
||||||
"challenge_day_ready", "challenge_waiting" -> CHALLENGE_WAITING
|
|
||||||
"memory_capsule_unlocked" -> CAPSULE_UNLOCKED
|
"memory_capsule_unlocked" -> CAPSULE_UNLOCKED
|
||||||
"gentle_reminder" -> GENTLE_REMINDER
|
"gentle_reminder" -> GENTLE_REMINDER
|
||||||
// Server emits both 'daily_question' (assignment) and 'daily_question_reminder' — both open Today.
|
"daily_question_reminder" -> DAILY_QUESTION_REMINDER
|
||||||
"daily_question", "daily_question_reminder" -> DAILY_QUESTION_REMINDER
|
|
||||||
"chat_message" -> CHAT_MESSAGE
|
"chat_message" -> CHAT_MESSAGE
|
||||||
"outcome_reminder" -> OUTCOME_REMINDER
|
"outcome_reminder" -> OUTCOME_REMINDER
|
||||||
"partner_joined" -> PARTNER_JOINED
|
"partner_joined" -> PARTNER_JOINED
|
||||||
|
|
|
||||||
|
|
@ -56,7 +56,7 @@ import androidx.compose.ui.unit.dp
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import app.closer.core.billing.CouplePremiumChecker
|
import app.closer.core.billing.EntitlementChecker
|
||||||
import app.closer.core.navigation.AppRoute
|
import app.closer.core.navigation.AppRoute
|
||||||
import app.closer.data.challenges.ChallengesCatalog
|
import app.closer.data.challenges.ChallengesCatalog
|
||||||
import app.closer.data.remote.FirestoreChallengeDataSource
|
import app.closer.data.remote.FirestoreChallengeDataSource
|
||||||
|
|
@ -105,7 +105,7 @@ class ConnectionChallengesViewModel @Inject constructor(
|
||||||
private val authRepository: AuthRepository,
|
private val authRepository: AuthRepository,
|
||||||
private val coupleRepository: CoupleRepository,
|
private val coupleRepository: CoupleRepository,
|
||||||
private val challengeDataSource: FirestoreChallengeDataSource,
|
private val challengeDataSource: FirestoreChallengeDataSource,
|
||||||
private val premiumChecker: CouplePremiumChecker
|
private val entitlementChecker: EntitlementChecker
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val _uiState = MutableStateFlow(ChallengesUiState())
|
private val _uiState = MutableStateFlow(ChallengesUiState())
|
||||||
|
|
@ -114,7 +114,7 @@ class ConnectionChallengesViewModel @Inject constructor(
|
||||||
private var progressJob: Job? = null
|
private var progressJob: Job? = null
|
||||||
|
|
||||||
init {
|
init {
|
||||||
premiumChecker.isPremium()
|
entitlementChecker.isPremium()
|
||||||
.onEach { premium -> _uiState.update { it.copy(hasPremium = premium) } }
|
.onEach { premium -> _uiState.update { it.copy(hasPremium = premium) } }
|
||||||
.launchIn(viewModelScope)
|
.launchIn(viewModelScope)
|
||||||
load()
|
load()
|
||||||
|
|
|
||||||
|
|
@ -58,7 +58,7 @@ import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.lifecycle.SavedStateHandle
|
import androidx.lifecycle.SavedStateHandle
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import app.closer.core.billing.CouplePremiumChecker
|
import app.closer.core.billing.EntitlementChecker
|
||||||
import app.closer.core.navigation.AppRoute
|
import app.closer.core.navigation.AppRoute
|
||||||
import app.closer.data.remote.FirestoreDesireSyncDataSource
|
import app.closer.data.remote.FirestoreDesireSyncDataSource
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
|
@ -123,7 +123,7 @@ class DesireSyncViewModel @Inject constructor(
|
||||||
private val repository: QuestionRepository,
|
private val repository: QuestionRepository,
|
||||||
private val gameSessionManager: GameSessionManager,
|
private val gameSessionManager: GameSessionManager,
|
||||||
private val dataSource: FirestoreDesireSyncDataSource,
|
private val dataSource: FirestoreDesireSyncDataSource,
|
||||||
private val premiumChecker: CouplePremiumChecker
|
private val entitlementChecker: EntitlementChecker
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val _uiState = MutableStateFlow(DesireSyncUiState())
|
private val _uiState = MutableStateFlow(DesireSyncUiState())
|
||||||
|
|
@ -144,7 +144,7 @@ class DesireSyncViewModel @Inject constructor(
|
||||||
|
|
||||||
private fun load() {
|
private fun load() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
if (!premiumChecker.hasPremium()) {
|
if (!entitlementChecker.hasPremium()) {
|
||||||
_uiState.update { it.copy(navigateTo = AppRoute.PAYWALL) }
|
_uiState.update { it.copy(navigateTo = AppRoute.PAYWALL) }
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -70,7 +70,7 @@ import androidx.compose.ui.unit.dp
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import app.closer.core.billing.CouplePremiumChecker
|
import app.closer.core.billing.EntitlementChecker
|
||||||
import app.closer.core.navigation.AppRoute
|
import app.closer.core.navigation.AppRoute
|
||||||
import app.closer.data.remote.FirestoreCapsuleDataSource
|
import app.closer.data.remote.FirestoreCapsuleDataSource
|
||||||
import app.closer.domain.model.TimeCapsule
|
import app.closer.domain.model.TimeCapsule
|
||||||
|
|
@ -140,7 +140,7 @@ class MemoryLaneViewModel @Inject constructor(
|
||||||
private val authRepository: AuthRepository,
|
private val authRepository: AuthRepository,
|
||||||
private val coupleRepository: CoupleRepository,
|
private val coupleRepository: CoupleRepository,
|
||||||
private val capsuleDataSource: FirestoreCapsuleDataSource,
|
private val capsuleDataSource: FirestoreCapsuleDataSource,
|
||||||
private val premiumChecker: CouplePremiumChecker
|
private val entitlementChecker: EntitlementChecker
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val _uiState = MutableStateFlow(MemoryLaneUiState())
|
private val _uiState = MutableStateFlow(MemoryLaneUiState())
|
||||||
|
|
@ -152,7 +152,7 @@ class MemoryLaneViewModel @Inject constructor(
|
||||||
|
|
||||||
private fun load() {
|
private fun load() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val hasPremium = premiumChecker.hasPremium()
|
val hasPremium = entitlementChecker.hasPremium()
|
||||||
if (!hasPremium) {
|
if (!hasPremium) {
|
||||||
_uiState.update { it.copy(phase = MemoryLanePhase.LIST, hasPremium = false) }
|
_uiState.update { it.copy(phase = MemoryLanePhase.LIST, hasPremium = false) }
|
||||||
return@launch
|
return@launch
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@ import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.imePadding
|
import androidx.compose.foundation.layout.imePadding
|
||||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
|
@ -15,32 +14,21 @@ import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.itemsIndexed
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
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.Person
|
||||||
import androidx.compose.material.icons.filled.Refresh
|
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Scaffold
|
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.Text
|
||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.material3.TopAppBarDefaults
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.derivedStateOf
|
|
||||||
import androidx.compose.runtime.getValue
|
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.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
|
|
@ -50,11 +38,8 @@ import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
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.ChatComposer
|
||||||
import app.closer.ui.messages.components.ChatDaySeparator
|
|
||||||
import app.closer.ui.messages.components.ChatMessageRow
|
import app.closer.ui.messages.components.ChatMessageRow
|
||||||
import app.closer.ui.messages.components.isSameChatDay
|
|
||||||
import app.closer.ui.theme.closerBackgroundBrush
|
import app.closer.ui.theme.closerBackgroundBrush
|
||||||
import coil.compose.AsyncImage
|
import coil.compose.AsyncImage
|
||||||
|
|
||||||
|
|
@ -66,42 +51,14 @@ fun ConversationScreen(
|
||||||
) {
|
) {
|
||||||
val state by viewModel.uiState.collectAsState()
|
val state by viewModel.uiState.collectAsState()
|
||||||
val listState = rememberLazyListState()
|
val listState = rememberLazyListState()
|
||||||
val snackbarHostState = remember { SnackbarHostState() }
|
|
||||||
|
|
||||||
val routeToPaywall: () -> Unit = {
|
LaunchedEffect(state.messages.size) {
|
||||||
viewModel.onMediaPaywallShown()
|
if (state.messages.isNotEmpty()) listState.animateScrollToItem(state.messages.lastIndex)
|
||||||
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(
|
Scaffold(
|
||||||
containerColor = Color.Transparent,
|
containerColor = Color.Transparent,
|
||||||
modifier = Modifier.background(closerBackgroundBrush()),
|
modifier = Modifier.background(closerBackgroundBrush()),
|
||||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
|
||||||
topBar = {
|
topBar = {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = {
|
title = {
|
||||||
|
|
@ -139,109 +96,32 @@ fun ConversationScreen(
|
||||||
contentPadding = PaddingValues(horizontal = 14.dp, vertical = 8.dp),
|
contentPadding = PaddingValues(horizontal = 14.dp, vertical = 8.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(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 ->
|
itemsIndexed(state.messages, key = { _, m -> m.id }) { index, message ->
|
||||||
val isMe = message.userId == viewModel.currentUserId
|
val isMe = message.userId == viewModel.currentUserId
|
||||||
val isLastInRun = index == state.messages.lastIndex ||
|
val showAvatar = index == state.messages.lastIndex ||
|
||||||
state.messages[index + 1].userId != message.userId
|
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(
|
ChatMessageRow(
|
||||||
message = message,
|
message = message,
|
||||||
isCurrentUser = isMe,
|
isCurrentUser = isMe,
|
||||||
partnerAvatarUrl = state.partnerPhotoUrl,
|
partnerAvatarUrl = state.partnerPhotoUrl,
|
||||||
showAvatar = isLastInRun,
|
showAvatar = showAvatar,
|
||||||
showTimestamp = isLastInRun,
|
|
||||||
showSeen = seen,
|
|
||||||
canReact = state.canSendMedia,
|
|
||||||
onReact = { emoji -> viewModel.react(message.id, emoji) },
|
|
||||||
onReactBlocked = routeToPaywall,
|
|
||||||
onDelete = { viewModel.deleteMessage(message.id) },
|
|
||||||
loadDecryptedMedia = viewModel::loadDecryptedMedia
|
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(
|
ChatComposer(
|
||||||
value = state.messageInput,
|
value = state.messageInput,
|
||||||
onValueChange = viewModel::updateMessageInput,
|
onValueChange = viewModel::updateMessageInput,
|
||||||
onSend = viewModel::sendMessage,
|
onSend = viewModel::sendMessage,
|
||||||
onSendImage = viewModel::sendImage,
|
onSendImage = viewModel::sendImage,
|
||||||
onSendVoice = viewModel::sendVoice,
|
onSendVoice = viewModel::sendVoice,
|
||||||
canSendMedia = state.canSendMedia,
|
|
||||||
onUpgrade = routeToPaywall,
|
|
||||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 6.dp)
|
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
|
@Composable
|
||||||
private fun ConversationAvatar(url: String?) {
|
private fun ConversationAvatar(url: String?) {
|
||||||
val size = 32.dp
|
val size = 32.dp
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,6 @@ package app.closer.ui.messages
|
||||||
import androidx.lifecycle.SavedStateHandle
|
import androidx.lifecycle.SavedStateHandle
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
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.QuestionDao
|
||||||
import app.closer.data.local.mapper.toQuestion
|
import app.closer.data.local.mapper.toQuestion
|
||||||
import app.closer.domain.model.QuestionMessage
|
import app.closer.domain.model.QuestionMessage
|
||||||
|
|
@ -14,42 +12,21 @@ import app.closer.domain.repository.UserRepository
|
||||||
import app.closer.notifications.ActiveThreadMonitor
|
import app.closer.notifications.ActiveThreadMonitor
|
||||||
import com.google.firebase.auth.FirebaseAuth
|
import com.google.firebase.auth.FirebaseAuth
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
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.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.SharedFlow
|
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asSharedFlow
|
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
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.flow.update
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import java.util.UUID
|
|
||||||
import javax.inject.Inject
|
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(
|
data class ConversationUiState(
|
||||||
val title: String = "",
|
val title: String = "",
|
||||||
val partnerPhotoUrl: String? = null,
|
val partnerPhotoUrl: String? = null,
|
||||||
val messages: List<QuestionMessage> = emptyList(),
|
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 messageInput: String = "",
|
||||||
val isLoading: Boolean = true
|
val isLoading: Boolean = true
|
||||||
)
|
)
|
||||||
|
|
||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class ConversationViewModel @Inject constructor(
|
class ConversationViewModel @Inject constructor(
|
||||||
private val repository: ConversationRepository,
|
private val repository: ConversationRepository,
|
||||||
|
|
@ -57,8 +34,6 @@ class ConversationViewModel @Inject constructor(
|
||||||
private val userRepository: UserRepository,
|
private val userRepository: UserRepository,
|
||||||
private val questionDao: QuestionDao,
|
private val questionDao: QuestionDao,
|
||||||
private val activeThreadMonitor: ActiveThreadMonitor,
|
private val activeThreadMonitor: ActiveThreadMonitor,
|
||||||
private val couplePremiumChecker: CouplePremiumChecker,
|
|
||||||
private val analyticsTracker: AnalyticsTracker,
|
|
||||||
savedStateHandle: SavedStateHandle
|
savedStateHandle: SavedStateHandle
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
|
|
@ -69,15 +44,6 @@ class ConversationViewModel @Inject constructor(
|
||||||
private val _uiState = MutableStateFlow(ConversationUiState())
|
private val _uiState = MutableStateFlow(ConversationUiState())
|
||||||
val uiState: StateFlow<ConversationUiState> = _uiState.asStateFlow()
|
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 {
|
init {
|
||||||
// Reading this conversation suppresses its bubble + clears its unread.
|
// Reading this conversation suppresses its bubble + clears its unread.
|
||||||
activeThreadMonitor.enter(conversationId)
|
activeThreadMonitor.enter(conversationId)
|
||||||
|
|
@ -87,7 +53,6 @@ class ConversationViewModel @Inject constructor(
|
||||||
override fun onCleared() {
|
override fun onCleared() {
|
||||||
super.onCleared()
|
super.onCleared()
|
||||||
activeThreadMonitor.leave(conversationId)
|
activeThreadMonitor.leave(conversationId)
|
||||||
stopTyping() // fire-and-forget; clears our typing flag when leaving
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun load() {
|
private fun load() {
|
||||||
|
|
@ -113,161 +78,48 @@ class ConversationViewModel @Inject constructor(
|
||||||
it.copy(title = title, partnerPhotoUrl = partner?.photoUrl, isLoading = false)
|
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) }
|
runCatching { repository.markRead(coupleId, conversationId, currentUserId) }
|
||||||
|
|
||||||
launch {
|
launch {
|
||||||
messageLimit
|
repository.observeMessages(coupleId, conversationId).collect { msgs ->
|
||||||
.flatMapLatest { limit -> repository.observeMessages(coupleId, conversationId, limit) }
|
_uiState.update { it.copy(messages = msgs) }
|
||||||
.collect { msgs ->
|
runCatching { repository.markRead(coupleId, conversationId, currentUserId) }
|
||||||
_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) {
|
fun updateMessageInput(text: String) {
|
||||||
_uiState.update { it.copy(messageInput = text.take(MAX_MESSAGE_LENGTH)) }
|
_uiState.update { it.copy(messageInput = text.take(MAX_MESSAGE_LENGTH)) }
|
||||||
if (text.isNotBlank()) onUserTyping()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun sendMessage() {
|
fun sendMessage() {
|
||||||
val text = _uiState.value.messageInput.trim()
|
val text = _uiState.value.messageInput.trim()
|
||||||
if (text.isBlank() || currentUserId.isEmpty()) return
|
if (text.isBlank() || currentUserId.isEmpty()) return
|
||||||
_uiState.update { it.copy(messageInput = "") }
|
_uiState.update { it.copy(messageInput = "") }
|
||||||
stopTyping()
|
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
runCatching { repository.sendMessage(coupleId, conversationId, currentUserId, text) }
|
runCatching { repository.sendMessage(coupleId, conversationId, currentUserId, text) }
|
||||||
.onFailure { _events.tryEmit("Couldn't send message. Check your connection.") }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun sendImage(imageBytes: ByteArray) {
|
fun sendImage(imageBytes: ByteArray) {
|
||||||
if (currentUserId.isEmpty() || imageBytes.isEmpty()) return
|
if (currentUserId.isEmpty() || imageBytes.isEmpty()) return
|
||||||
val id = UUID.randomUUID().toString()
|
|
||||||
retryStore[id] = RetryData("image", imageBytes, 0L)
|
|
||||||
addPending(PendingMedia(id, "image"))
|
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
runCatching { repository.sendImageMessage(coupleId, conversationId, currentUserId, imageBytes) }
|
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) {
|
fun sendVoice(audioBytes: ByteArray, durationMs: Long) {
|
||||||
if (currentUserId.isEmpty() || audioBytes.isEmpty()) return
|
if (currentUserId.isEmpty() || audioBytes.isEmpty()) return
|
||||||
val id = UUID.randomUUID().toString()
|
|
||||||
retryStore[id] = RetryData("voice", audioBytes, durationMs)
|
|
||||||
addPending(PendingMedia(id, "voice"))
|
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
runCatching { repository.sendVoiceMessage(coupleId, conversationId, currentUserId, audioBytes, durationMs) }
|
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? =
|
suspend fun loadDecryptedMedia(mediaUrl: String): ByteArray? =
|
||||||
repository.loadDecryptedMedia(coupleId, mediaUrl)
|
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 {
|
companion object {
|
||||||
const val MAX_MESSAGE_LENGTH = 2000
|
const val MAX_MESSAGE_LENGTH = 2000
|
||||||
const val PAGE_SIZE = 50
|
|
||||||
private const val TYPING_TTL_MS = 6000L
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,13 +10,10 @@ import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.result.PickVisualMediaRequest
|
import androidx.activity.result.PickVisualMediaRequest
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.compose.foundation.BorderStroke
|
import androidx.compose.foundation.BorderStroke
|
||||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.combinedClickable
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.aspectRatio
|
import androidx.compose.foundation.layout.aspectRatio
|
||||||
|
|
@ -32,21 +29,15 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.Send
|
import androidx.compose.material.icons.automirrored.filled.Send
|
||||||
import androidx.compose.material.icons.filled.Close
|
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.Image
|
||||||
import androidx.compose.material.icons.filled.Lock
|
|
||||||
import androidx.compose.material.icons.filled.Mic
|
import androidx.compose.material.icons.filled.Mic
|
||||||
import androidx.compose.material.icons.filled.Pause
|
import androidx.compose.material.icons.filled.Pause
|
||||||
import androidx.compose.material.icons.filled.Person
|
import androidx.compose.material.icons.filled.Person
|
||||||
import androidx.compose.material.icons.filled.PhotoCamera
|
import androidx.compose.material.icons.filled.PhotoCamera
|
||||||
import androidx.compose.material.icons.filled.PlayArrow
|
import androidx.compose.material.icons.filled.PlayArrow
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.DropdownMenu
|
|
||||||
import androidx.compose.material3.DropdownMenuItem
|
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.LinearProgressIndicator
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
|
@ -66,15 +57,12 @@ import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.Shape
|
import androidx.compose.ui.graphics.Shape
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.platform.LocalClipboardManager
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.text.AnnotatedString
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.window.Dialog
|
import androidx.compose.ui.window.Dialog
|
||||||
import androidx.compose.ui.window.DialogProperties
|
import androidx.compose.ui.window.DialogProperties
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.core.content.FileProvider
|
import androidx.core.content.FileProvider
|
||||||
import app.closer.core.media.MediaCompressor
|
|
||||||
import app.closer.domain.model.QuestionMessage
|
import app.closer.domain.model.QuestionMessage
|
||||||
import coil.ImageLoader
|
import coil.ImageLoader
|
||||||
import coil.compose.AsyncImage
|
import coil.compose.AsyncImage
|
||||||
|
|
@ -92,21 +80,12 @@ import java.nio.ByteBuffer
|
||||||
* One chat row — Messenger style: only the partner's avatar shows (on the left); our own messages
|
* 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.
|
* are bubbles on the right with no avatar. Handles text, encrypted image/GIF, and voice messages.
|
||||||
*/
|
*/
|
||||||
val REACTION_EMOJIS = listOf("❤️", "😂", "👍", "😮", "😢", "🔥")
|
|
||||||
|
|
||||||
@OptIn(ExperimentalFoundationApi::class)
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ChatMessageRow(
|
fun ChatMessageRow(
|
||||||
message: QuestionMessage,
|
message: QuestionMessage,
|
||||||
isCurrentUser: Boolean,
|
isCurrentUser: Boolean,
|
||||||
partnerAvatarUrl: String?,
|
partnerAvatarUrl: String?,
|
||||||
showAvatar: Boolean,
|
showAvatar: Boolean,
|
||||||
showTimestamp: Boolean,
|
|
||||||
showSeen: Boolean = false,
|
|
||||||
canReact: Boolean = true,
|
|
||||||
onReact: (String) -> Unit = {},
|
|
||||||
onReactBlocked: () -> Unit = {},
|
|
||||||
onDelete: () -> Unit = {},
|
|
||||||
loadDecryptedMedia: suspend (String) -> ByteArray?
|
loadDecryptedMedia: suspend (String) -> ByteArray?
|
||||||
) {
|
) {
|
||||||
val bubbleShape = if (isCurrentUser) {
|
val bubbleShape = if (isCurrentUser) {
|
||||||
|
|
@ -114,175 +93,35 @@ fun ChatMessageRow(
|
||||||
} else {
|
} else {
|
||||||
RoundedCornerShape(topStart = 4.dp, topEnd = 16.dp, bottomStart = 16.dp, bottomEnd = 16.dp)
|
RoundedCornerShape(topStart = 4.dp, topEnd = 16.dp, bottomStart = 16.dp, bottomEnd = 16.dp)
|
||||||
}
|
}
|
||||||
var menuOpen by remember { mutableStateOf(false) }
|
|
||||||
val clipboard = LocalClipboardManager.current
|
|
||||||
|
|
||||||
Column(modifier = Modifier.fillMaxWidth()) {
|
Row(
|
||||||
Row(
|
modifier = Modifier.fillMaxWidth(),
|
||||||
modifier = Modifier.fillMaxWidth(),
|
horizontalArrangement = if (isCurrentUser) Arrangement.End else Arrangement.Start,
|
||||||
horizontalArrangement = if (isCurrentUser) Arrangement.End else Arrangement.Start,
|
verticalAlignment = Alignment.Bottom
|
||||||
verticalAlignment = Alignment.Bottom
|
) {
|
||||||
) {
|
if (!isCurrentUser) {
|
||||||
if (!isCurrentUser) {
|
ChatAvatar(partnerAvatarUrl, visible = showAvatar)
|
||||||
ChatAvatar(partnerAvatarUrl, visible = showAvatar)
|
Spacer(modifier = Modifier.width(6.dp))
|
||||||
Spacer(modifier = Modifier.width(6.dp))
|
}
|
||||||
}
|
|
||||||
|
|
||||||
Box(
|
when {
|
||||||
modifier = if (message.deleted) Modifier
|
message.isImage -> EncryptedChatImage(message.mediaUrl, bubbleShape, loadDecryptedMedia)
|
||||||
else Modifier.combinedClickable(onClick = {}, onLongClick = { menuOpen = true })
|
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)
|
||||||
) {
|
) {
|
||||||
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(
|
||||||
text = emoji,
|
text = message.text,
|
||||||
style = MaterialTheme.typography.titleLarge,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
modifier = Modifier
|
color = if (isCurrentUser) MaterialTheme.colorScheme.onPrimary
|
||||||
.clip(CircleShape)
|
else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
.clickable { onReact(emoji) }
|
modifier = Modifier.padding(horizontal = 14.dp, vertical = 9.dp)
|
||||||
.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)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -411,8 +250,6 @@ private fun EncryptedVoiceMessage(
|
||||||
var tempFile by remember { mutableStateOf<File?>(null) }
|
var tempFile by remember { mutableStateOf<File?>(null) }
|
||||||
var playing by remember { mutableStateOf(false) }
|
var playing by remember { mutableStateOf(false) }
|
||||||
var loading by remember { mutableStateOf(false) }
|
var loading by remember { mutableStateOf(false) }
|
||||||
var positionMs by remember { mutableLongStateOf(0L) }
|
|
||||||
val totalMs = durationMs.coerceAtLeast(1L)
|
|
||||||
|
|
||||||
DisposableEffect(Unit) {
|
DisposableEffect(Unit) {
|
||||||
onDispose {
|
onDispose {
|
||||||
|
|
@ -421,14 +258,6 @@ 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() {
|
fun toggle() {
|
||||||
val p = player
|
val p = player
|
||||||
if (p != null) {
|
if (p != null) {
|
||||||
|
|
@ -447,7 +276,7 @@ private fun EncryptedVoiceMessage(
|
||||||
val mp = MediaPlayer()
|
val mp = MediaPlayer()
|
||||||
runCatching {
|
runCatching {
|
||||||
mp.setDataSource(f.absolutePath)
|
mp.setDataSource(f.absolutePath)
|
||||||
mp.setOnCompletionListener { playing = false; positionMs = 0L; runCatching { mp.seekTo(0) } }
|
mp.setOnCompletionListener { playing = false; runCatching { mp.seekTo(0) } }
|
||||||
mp.prepare()
|
mp.prepare()
|
||||||
mp.start()
|
mp.start()
|
||||||
}.onSuccess { player = mp; playing = true }.onFailure { mp.release() }
|
}.onSuccess { player = mp; playing = true }.onFailure { mp.release() }
|
||||||
|
|
@ -477,17 +306,8 @@ private fun EncryptedVoiceMessage(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
LinearProgressIndicator(
|
Icon(Icons.Filled.Mic, contentDescription = null, tint = tint.copy(alpha = 0.7f), modifier = Modifier.size(16.dp))
|
||||||
progress = { if (totalMs > 0) (positionMs.toFloat() / totalMs).coerceIn(0f, 1f) else 0f },
|
Text(text = formatDuration(durationMs), style = MaterialTheme.typography.bodyMedium, color = tint)
|
||||||
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
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -527,20 +347,15 @@ fun ChatComposer(
|
||||||
onSend: () -> Unit,
|
onSend: () -> Unit,
|
||||||
onSendImage: (ByteArray) -> Unit,
|
onSendImage: (ByteArray) -> Unit,
|
||||||
onSendVoice: (ByteArray, Long) -> Unit,
|
onSendVoice: (ByteArray, Long) -> Unit,
|
||||||
canSendMedia: Boolean = true,
|
|
||||||
onUpgrade: () -> Unit = {},
|
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
// compress = true for real photos (gallery/camera); false for keyboard GIFs/stickers/Bitmoji
|
fun readAndSend(uri: Uri) {
|
||||||
// (compressing those would kill animation/transparency).
|
|
||||||
fun readAndSend(uri: Uri, compress: Boolean) {
|
|
||||||
scope.launch {
|
scope.launch {
|
||||||
val bytes = withContext(Dispatchers.IO) {
|
val bytes = withContext(Dispatchers.IO) {
|
||||||
val raw = runCatching { context.contentResolver.openInputStream(uri)?.use { it.readBytes() } }.getOrNull()
|
runCatching { context.contentResolver.openInputStream(uri)?.use { it.readBytes() } }.getOrNull()
|
||||||
raw?.let { if (compress) MediaCompressor.compressPhoto(it) else it }
|
|
||||||
}
|
}
|
||||||
bytes?.takeIf { it.isNotEmpty() }?.let(onSendImage)
|
bytes?.takeIf { it.isNotEmpty() }?.let(onSendImage)
|
||||||
}
|
}
|
||||||
|
|
@ -548,12 +363,12 @@ fun ChatComposer(
|
||||||
|
|
||||||
val galleryLauncher = rememberLauncherForActivityResult(
|
val galleryLauncher = rememberLauncherForActivityResult(
|
||||||
contract = ActivityResultContracts.PickVisualMedia()
|
contract = ActivityResultContracts.PickVisualMedia()
|
||||||
) { uri: Uri? -> uri?.let { readAndSend(it, compress = true) } }
|
) { uri: Uri? -> uri?.let { readAndSend(it) } }
|
||||||
|
|
||||||
var pendingCameraUri by remember { mutableStateOf<Uri?>(null) }
|
var pendingCameraUri by remember { mutableStateOf<Uri?>(null) }
|
||||||
val cameraLauncher = rememberLauncherForActivityResult(
|
val cameraLauncher = rememberLauncherForActivityResult(
|
||||||
contract = ActivityResultContracts.TakePicture()
|
contract = ActivityResultContracts.TakePicture()
|
||||||
) { success: Boolean -> if (success) pendingCameraUri?.let { readAndSend(it, compress = true) } }
|
) { success: Boolean -> if (success) pendingCameraUri?.let { readAndSend(it) } }
|
||||||
|
|
||||||
fun launchCamera() {
|
fun launchCamera() {
|
||||||
val file = File(context.cacheDir, "chat_capture_${System.currentTimeMillis()}.jpg")
|
val file = File(context.cacheDir, "chat_capture_${System.currentTimeMillis()}.jpg")
|
||||||
|
|
@ -612,11 +427,7 @@ fun ChatComposer(
|
||||||
) { granted: Boolean -> if (granted) startRecording() }
|
) { granted: Boolean -> if (granted) startRecording() }
|
||||||
|
|
||||||
LaunchedEffect(isRecording) {
|
LaunchedEffect(isRecording) {
|
||||||
while (isRecording) {
|
while (isRecording) { elapsedMs = System.currentTimeMillis() - recordStart; delay(200) }
|
||||||
elapsedMs = System.currentTimeMillis() - recordStart
|
|
||||||
if (elapsedMs >= MAX_RECORDING_MS) { finishRecording(send = true); break } // auto-send at the cap
|
|
||||||
delay(200)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
DisposableEffect(Unit) {
|
DisposableEffect(Unit) {
|
||||||
onDispose { runCatching { recorder.value?.release() }; recordFile.value?.delete() }
|
onDispose { runCatching { recorder.value?.release() }; recordFile.value?.delete() }
|
||||||
|
|
@ -650,23 +461,21 @@ fun ChatComposer(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.spacedBy(2.dp)
|
horizontalArrangement = Arrangement.spacedBy(2.dp)
|
||||||
) {
|
) {
|
||||||
ComposerMediaButton(
|
IconButton(
|
||||||
icon = Icons.Filled.Image,
|
|
||||||
description = "Send a photo",
|
|
||||||
locked = !canSendMedia,
|
|
||||||
onClick = { galleryLauncher.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)) },
|
onClick = { galleryLauncher.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)) },
|
||||||
onUpgrade = onUpgrade
|
modifier = Modifier.size(40.dp)
|
||||||
)
|
) {
|
||||||
ComposerMediaButton(
|
Icon(Icons.Filled.Image, contentDescription = "Send a photo", tint = MaterialTheme.colorScheme.primary)
|
||||||
icon = Icons.Filled.PhotoCamera,
|
}
|
||||||
description = "Take a photo",
|
IconButton(
|
||||||
locked = !canSendMedia,
|
|
||||||
onClick = {
|
onClick = {
|
||||||
if (ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED)
|
if (ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED)
|
||||||
launchCamera() else cameraPermissionLauncher.launch(Manifest.permission.CAMERA)
|
launchCamera() else cameraPermissionLauncher.launch(Manifest.permission.CAMERA)
|
||||||
},
|
},
|
||||||
onUpgrade = onUpgrade
|
modifier = Modifier.size(40.dp)
|
||||||
)
|
) {
|
||||||
|
Icon(Icons.Filled.PhotoCamera, contentDescription = "Take a photo", tint = MaterialTheme.colorScheme.primary)
|
||||||
|
}
|
||||||
|
|
||||||
// Field that accepts GIFs / stickers / Bitmoji inserted from the keyboard (rich content).
|
// Field that accepts GIFs / stickers / Bitmoji inserted from the keyboard (rich content).
|
||||||
Surface(
|
Surface(
|
||||||
|
|
@ -678,26 +487,23 @@ fun ChatComposer(
|
||||||
RichContentTextField(
|
RichContentTextField(
|
||||||
value = value,
|
value = value,
|
||||||
onValueChange = onValueChange,
|
onValueChange = onValueChange,
|
||||||
// Keyboard GIF/sticker/Bitmoji: gated → route to paywall instead of sending.
|
onImageReceived = { uri -> readAndSend(uri) },
|
||||||
onImageReceived = { uri -> if (canSendMedia) readAndSend(uri, compress = false) else onUpgrade() },
|
|
||||||
modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp),
|
modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp),
|
||||||
hint = "Message…"
|
hint = "Message…"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (value.isBlank()) {
|
if (value.isBlank()) {
|
||||||
// Mic when there's nothing typed (tap to record a voice note) — gated behind premium.
|
// Mic when there's nothing typed (tap to record a voice note).
|
||||||
ComposerMediaButton(
|
IconButton(
|
||||||
icon = Icons.Filled.Mic,
|
|
||||||
description = "Record a voice note",
|
|
||||||
locked = !canSendMedia,
|
|
||||||
size = 48.dp,
|
|
||||||
onClick = {
|
onClick = {
|
||||||
if (ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED)
|
if (ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED)
|
||||||
startRecording() else micPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
|
startRecording() else micPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
|
||||||
},
|
},
|
||||||
onUpgrade = onUpgrade
|
modifier = Modifier.size(48.dp)
|
||||||
)
|
) {
|
||||||
|
Icon(Icons.Filled.Mic, contentDescription = "Record a voice note", tint = MaterialTheme.colorScheme.primary)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
IconButton(onClick = onSend, modifier = Modifier.size(48.dp)) {
|
IconButton(onClick = onSend, modifier = Modifier.size(48.dp)) {
|
||||||
Icon(Icons.AutoMirrored.Filled.Send, contentDescription = "Send", tint = MaterialTheme.colorScheme.primary)
|
Icon(Icons.AutoMirrored.Filled.Send, contentDescription = "Send", tint = MaterialTheme.colorScheme.primary)
|
||||||
|
|
@ -706,72 +512,9 @@ 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 {
|
private fun formatDuration(ms: Long): String {
|
||||||
val totalSec = (ms / 1000).toInt()
|
val totalSec = (ms / 1000).toInt()
|
||||||
val m = totalSec / 60
|
val m = totalSec / 60
|
||||||
val s = totalSec % 60
|
val s = totalSec % 60
|
||||||
return "%d:%02d".format(m, s)
|
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)
|
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ package app.closer.ui.play
|
||||||
|
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import app.closer.core.billing.CouplePremiumChecker
|
import app.closer.core.billing.EntitlementChecker
|
||||||
import app.closer.domain.usecase.GameSessionManager
|
import app.closer.domain.usecase.GameSessionManager
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
@ -15,10 +15,10 @@ import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class PlayHubViewModel @Inject constructor(
|
class PlayHubViewModel @Inject constructor(
|
||||||
premiumChecker: CouplePremiumChecker,
|
entitlementChecker: EntitlementChecker,
|
||||||
private val gameSessionManager: GameSessionManager
|
private val gameSessionManager: GameSessionManager
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
val hasPremium: StateFlow<Boolean> = premiumChecker.isPremium()
|
val hasPremium: StateFlow<Boolean> = entitlementChecker.isPremium()
|
||||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false)
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false)
|
||||||
|
|
||||||
// Default true so paired users never see an invite redirect flash while this loads.
|
// Default true so paired users never see an invite redirect flash while this loads.
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ package app.closer.ui.questions
|
||||||
|
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import app.closer.core.billing.CouplePremiumChecker
|
import app.closer.core.billing.EntitlementChecker
|
||||||
import app.closer.domain.model.QuestionCategory
|
import app.closer.domain.model.QuestionCategory
|
||||||
import app.closer.domain.repository.QuestionRepository
|
import app.closer.domain.repository.QuestionRepository
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
|
@ -29,7 +29,7 @@ data class QuestionPackLibraryUiState(
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class QuestionPackLibraryViewModel @Inject constructor(
|
class QuestionPackLibraryViewModel @Inject constructor(
|
||||||
private val repository: QuestionRepository,
|
private val repository: QuestionRepository,
|
||||||
private val premiumChecker: CouplePremiumChecker
|
private val entitlementChecker: EntitlementChecker
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val _uiState = MutableStateFlow(QuestionPackLibraryUiState())
|
private val _uiState = MutableStateFlow(QuestionPackLibraryUiState())
|
||||||
|
|
@ -43,7 +43,7 @@ class QuestionPackLibraryViewModel @Inject constructor(
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_uiState.value = QuestionPackLibraryUiState(isLoading = true)
|
_uiState.value = QuestionPackLibraryUiState(isLoading = true)
|
||||||
try {
|
try {
|
||||||
val hasPremium = premiumChecker.isPremium().first()
|
val hasPremium = entitlementChecker.isPremium().first()
|
||||||
val packs = repository.getCategories().map { category ->
|
val packs = repository.getCategories().map { category ->
|
||||||
val isPremium = category.access == "premium"
|
val isPremium = category.access == "premium"
|
||||||
QuestionPackItem(
|
QuestionPackItem(
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,7 @@ import app.closer.ui.components.ErrorState
|
||||||
import app.closer.ui.components.LoadingState
|
import app.closer.ui.components.LoadingState
|
||||||
import app.closer.ui.questions.components.AnswerBubble
|
import app.closer.ui.questions.components.AnswerBubble
|
||||||
import app.closer.ui.questions.components.QuestionAnswerInput
|
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.questions.components.QuestionHeader
|
||||||
import app.closer.ui.theme.closerBackgroundBrush
|
import app.closer.ui.theme.closerBackgroundBrush
|
||||||
|
|
||||||
|
|
@ -234,19 +235,17 @@ private fun RevealedPhase(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Discussion now lives in the unified Messages conversation (q_<questionId>) so it's notified
|
QuestionDiscussionThread(
|
||||||
// and appears in the inbox — same as the daily flow.
|
messages = state.messages,
|
||||||
Button(
|
currentUserId = viewModel.currentUserId,
|
||||||
onClick = { onNavigate(AppRoute.conversation(coupleId, "q_$questionId")) },
|
partnerPhotoUrl = state.partnerPhotoUrl,
|
||||||
modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp),
|
messageInput = state.messageInput,
|
||||||
shape = RoundedCornerShape(16.dp),
|
onMessageInputChanged = viewModel::updateMessageInput,
|
||||||
colors = ButtonDefaults.buttonColors(
|
onSendMessage = viewModel::sendMessage,
|
||||||
containerColor = MaterialTheme.colorScheme.secondaryContainer,
|
isRevealed = true,
|
||||||
contentColor = MaterialTheme.colorScheme.onSecondaryContainer
|
onSendImage = viewModel::sendImage,
|
||||||
)
|
loadDecryptedMedia = viewModel::loadDecryptedMedia
|
||||||
) {
|
)
|
||||||
Text("Discuss this together")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Navigation out of the thread
|
// Navigation out of the thread
|
||||||
Row(
|
Row(
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ package app.closer.ui.wheel
|
||||||
|
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import app.closer.core.billing.CouplePremiumChecker
|
import app.closer.core.billing.EntitlementChecker
|
||||||
import app.closer.core.navigation.AppRoute
|
import app.closer.core.navigation.AppRoute
|
||||||
import app.closer.domain.model.GameType
|
import app.closer.domain.model.GameType
|
||||||
import app.closer.domain.model.QuestionCategory
|
import app.closer.domain.model.QuestionCategory
|
||||||
|
|
@ -33,7 +33,7 @@ data class CategoryPickerUiState(
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class CategoryPickerViewModel @Inject constructor(
|
class CategoryPickerViewModel @Inject constructor(
|
||||||
private val repository: QuestionRepository,
|
private val repository: QuestionRepository,
|
||||||
private val premiumChecker: CouplePremiumChecker,
|
private val entitlementChecker: EntitlementChecker,
|
||||||
private val gameSessionManager: GameSessionManager
|
private val gameSessionManager: GameSessionManager
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
|
|
@ -78,7 +78,7 @@ class CategoryPickerViewModel @Inject constructor(
|
||||||
// checkActiveSession isn't clobbered by the category load finishing.
|
// checkActiveSession isn't clobbered by the category load finishing.
|
||||||
_uiState.update { it.copy(isLoading = true, error = null) }
|
_uiState.update { it.copy(isLoading = true, error = null) }
|
||||||
try {
|
try {
|
||||||
val hasPremium = premiumChecker.isPremium().first()
|
val hasPremium = entitlementChecker.isPremium().first()
|
||||||
val items = repository.getCategories().map { category ->
|
val items = repository.getCategories().map { category ->
|
||||||
CategoryPickerItem(
|
CategoryPickerItem(
|
||||||
category = category,
|
category = category,
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import android.util.Log
|
||||||
import androidx.lifecycle.SavedStateHandle
|
import androidx.lifecycle.SavedStateHandle
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import app.closer.core.billing.CouplePremiumChecker
|
import app.closer.core.billing.EntitlementChecker
|
||||||
import app.closer.core.navigation.AppRoute
|
import app.closer.core.navigation.AppRoute
|
||||||
import app.closer.domain.model.GameType
|
import app.closer.domain.model.GameType
|
||||||
import app.closer.domain.model.QuestionCategory
|
import app.closer.domain.model.QuestionCategory
|
||||||
|
|
@ -38,7 +38,7 @@ class SpinWheelViewModel @Inject constructor(
|
||||||
private val repository: QuestionRepository,
|
private val repository: QuestionRepository,
|
||||||
private val sessionStore: LocalWheelSessionStore,
|
private val sessionStore: LocalWheelSessionStore,
|
||||||
private val gameSessionManager: GameSessionManager,
|
private val gameSessionManager: GameSessionManager,
|
||||||
private val premiumChecker: CouplePremiumChecker
|
private val entitlementChecker: EntitlementChecker
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val categoryId: String = savedStateHandle["categoryId"] ?: ""
|
private val categoryId: String = savedStateHandle["categoryId"] ?: ""
|
||||||
|
|
@ -56,7 +56,7 @@ class SpinWheelViewModel @Inject constructor(
|
||||||
|
|
||||||
private fun loadPremiumStatus() {
|
private fun loadPremiumStatus() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val hasPremium = runCatching { premiumChecker.isPremium().first() }.getOrDefault(false)
|
val hasPremium = runCatching { entitlementChecker.isPremium().first() }.getOrDefault(false)
|
||||||
_uiState.update { it.copy(hasPremium = hasPremium) }
|
_uiState.update { it.copy(hasPremium = hasPremium) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ package app.closer.ui.wheel
|
||||||
|
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import app.closer.core.billing.CouplePremiumChecker
|
import app.closer.core.billing.EntitlementChecker
|
||||||
import app.closer.data.challenges.ChallengesCatalog
|
import app.closer.data.challenges.ChallengesCatalog
|
||||||
import app.closer.data.remote.FirestoreCapsuleDataSource
|
import app.closer.data.remote.FirestoreCapsuleDataSource
|
||||||
import app.closer.data.remote.FirestoreChallengeDataSource
|
import app.closer.data.remote.FirestoreChallengeDataSource
|
||||||
|
|
@ -45,14 +45,14 @@ class GameHistoryViewModel @Inject constructor(
|
||||||
private val coupleRepository: CoupleRepository,
|
private val coupleRepository: CoupleRepository,
|
||||||
private val challengeDataSource: FirestoreChallengeDataSource,
|
private val challengeDataSource: FirestoreChallengeDataSource,
|
||||||
private val capsuleDataSource: FirestoreCapsuleDataSource,
|
private val capsuleDataSource: FirestoreCapsuleDataSource,
|
||||||
premiumChecker: CouplePremiumChecker
|
entitlementChecker: EntitlementChecker
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val _uiState = MutableStateFlow(GameHistoryUiState())
|
private val _uiState = MutableStateFlow(GameHistoryUiState())
|
||||||
val uiState: StateFlow<GameHistoryUiState> = _uiState.asStateFlow()
|
val uiState: StateFlow<GameHistoryUiState> = _uiState.asStateFlow()
|
||||||
|
|
||||||
init {
|
init {
|
||||||
premiumChecker.isPremium()
|
entitlementChecker.isPremium()
|
||||||
.onEach { premium ->
|
.onEach { premium ->
|
||||||
_uiState.update { it.copy(hasPremium = premium) }
|
_uiState.update { it.copy(hasPremium = premium) }
|
||||||
if (premium) load()
|
if (premium) load()
|
||||||
|
|
|
||||||
|
|
@ -176,17 +176,7 @@ service cloud.firestore {
|
||||||
// Entitlements written server-side only (RevenueCat webhook via Admin SDK).
|
// Entitlements written server-side only (RevenueCat webhook via Admin SDK).
|
||||||
// Client needs read access so FirestoreEntitlementChecker can observe premium state.
|
// Client needs read access so FirestoreEntitlementChecker can observe premium state.
|
||||||
match /entitlements/{entitlementDoc} {
|
match /entitlements/{entitlementDoc} {
|
||||||
// Owner reads their own; a paired partner may also read it so premium can be shared
|
allow read: if isOwner(uid);
|
||||||
// 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;
|
allow write: if false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -432,7 +422,7 @@ service cloud.firestore {
|
||||||
// last-message preview must be ciphertext (encrypted on-device before write).
|
// last-message preview must be ciphertext (encrypted on-device before write).
|
||||||
allow write: if isCouplesMember(coupleId)
|
allow write: if isCouplesMember(coupleId)
|
||||||
&& request.resource.data.keys().hasOnly(
|
&& request.resource.data.keys().hasOnly(
|
||||||
['type', 'questionId', 'createdAt', 'lastMessageAt', 'lastMessagePreview', 'lastMessageSenderId', 'reads', 'typing'])
|
['type', 'questionId', 'createdAt', 'lastMessageAt', 'lastMessagePreview', 'lastMessageSenderId', 'reads'])
|
||||||
&& (!('lastMessagePreview' in request.resource.data)
|
&& (!('lastMessagePreview' in request.resource.data)
|
||||||
|| isCiphertext(request.resource.data.lastMessagePreview));
|
|| isCiphertext(request.resource.data.lastMessagePreview));
|
||||||
|
|
||||||
|
|
@ -443,7 +433,7 @@ service cloud.firestore {
|
||||||
allow create: if isCouplesMember(coupleId)
|
allow create: if isCouplesMember(coupleId)
|
||||||
&& coupleEncryptionEnabled(coupleId)
|
&& coupleEncryptionEnabled(coupleId)
|
||||||
&& request.resource.data.authorUserId == request.auth.uid
|
&& request.resource.data.authorUserId == request.auth.uid
|
||||||
&& request.resource.data.keys().hasOnly(['authorUserId', 'text', 'createdAt', 'type', 'mediaUrl', 'durationMs', 'reactions', 'deleted'])
|
&& request.resource.data.keys().hasOnly(['authorUserId', 'text', 'createdAt', 'type', 'mediaUrl', 'durationMs'])
|
||||||
&& (
|
&& (
|
||||||
(request.resource.data.get('type', 'text') in ['image', 'voice']
|
(request.resource.data.get('type', 'text') in ['image', 'voice']
|
||||||
&& request.resource.data.mediaUrl is string
|
&& request.resource.data.mediaUrl is string
|
||||||
|
|
@ -452,15 +442,7 @@ service cloud.firestore {
|
||||||
(request.resource.data.get('type', 'text') == 'text'
|
(request.resource.data.get('type', 'text') == 'text'
|
||||||
&& isCiphertext(request.resource.data.text))
|
&& isCiphertext(request.resource.data.text))
|
||||||
);
|
);
|
||||||
// Reactions: any couple member may change ONLY the reactions map.
|
allow update, delete: if false;
|
||||||
// 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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue