Compare commits
22 Commits
11a4c7deda
...
bbd7ef0806
| Author | SHA1 | Date |
|---|---|---|
|
|
bbd7ef0806 | |
|
|
84dd5f1152 | |
|
|
e907453f3f | |
|
|
16ba464752 | |
|
|
99f0ae0c49 | |
|
|
f121eab67f | |
|
|
6ca65ce7e9 | |
|
|
ce12abb1a6 | |
|
|
58208fd443 | |
|
|
e8892a9669 | |
|
|
c54ceb16c3 | |
|
|
452aaf787a | |
|
|
64f0a7e6c8 | |
|
|
6580299f05 | |
|
|
7a9ff31ae6 | |
|
|
f29d4699ca | |
|
|
5b9596e042 | |
|
|
7f1b938aa5 | |
|
|
3aa182a466 | |
|
|
cfea8f0d41 | |
|
|
3544b7a84a | |
|
|
8a68ae3107 |
|
|
@ -0,0 +1,45 @@
|
|||
# Claude QA Coverage Matrix
|
||||
|
||||
> Resume anchor. Status: `todo | pass | fail(→id) | n/a`. See `ClaudeReport.md` run-state header for current position.
|
||||
> Round 1 in progress.
|
||||
|
||||
## Pass A — Couple-shared premium (states: neither / partner-only / self)
|
||||
| Feature | neither→locked | partner→both unlock | self→unlock | Status |
|
||||
|---|---|---|---|---|
|
||||
| Chat media + reactions | pass | pass | pass | pass (couple-shared) |
|
||||
| Play: Desire Sync | pass | **fail→A-001** | pass | fail→A-001 |
|
||||
| Play: Memory Lane | pass | **fail→A-001** | pass | fail→A-001 |
|
||||
| Play: Connection Challenges | pass | fail→A-001 | pass | fail→A-001 |
|
||||
| Question Packs (premium) | pass | fail→A-001 | pass | fail→A-001 |
|
||||
| Wheel: Category Picker / Spin / History | pass | fail→A-001 | pass | fail→A-001 |
|
||||
| Date Match / Plan Date | pass | fail→A-001 | pass | fail→A-001 |
|
||||
| Subscription screen (own status) | n/a | n/a | n/a | pass (by-design per-user) |
|
||||
|
||||
Pass A: **complete** (1 systemic P1). **A-001 FIXED** (e8892a9) — couple-shared everywhere; re-verify each feature in re-QA. New cosmetic A-003 (P3, badge). Subscription screen by-design.
|
||||
|
||||
## Pass B — Games lifecycle (start / play / finish + results)
|
||||
| Game | starts | plays | finishes/results | no crash | Status |
|
||||
|---|---|---|---|---|---|
|
||||
| This or That | pass (launch) | partial | todo | pass | launch ok |
|
||||
| How Well Do You Know Me | pass (launch) | partial | todo | pass | launch ok |
|
||||
| Desire Sync | n/a (premium) | todo | todo | todo | needs premium toggle |
|
||||
| Connection Challenges | pass (launch) | partial | todo | pass | launch ok |
|
||||
| Memory Lane | n/a (premium) | todo | todo | todo | needs premium toggle |
|
||||
| Spin the Wheel | pass (launch) | partial | todo | pass | launch ok |
|
||||
| Date Match | todo | todo | todo | todo | todo |
|
||||
|
||||
_Note: stale active session blocked games (B-001); cleared via in-app "End their game" (recovery verified)._
|
||||
**REQUIREMENT (updated): each game must be played ONE COMPLETE time through on both devices (every step → finish/
|
||||
reveal/results), not just launched.** All rows above are currently `launch ok / partial` only → **full playthrough
|
||||
still owed for every game** in Round 2 (premium games need a premium toggle). A launch-only row counts as `partial`, not `pass`.
|
||||
|
||||
## Pass C — Visual (light + dark), all ~50 routes
|
||||
_todo — enumerate from AppRoute.kt; 5554=Dark, 5556=Light. Main tabs pass; deep/stateful screens owed._
|
||||
**Also owed:** navigation from EVERY entry point (each screen via all its links) + back-stack / "double-back"
|
||||
(system back + in-app back to correct place from each entry; no dead-ends, no exit surprise, no two-back/duplicate stack).
|
||||
|
||||
## Pass D — Security & Encryption (D1–D6)
|
||||
_todo_
|
||||
|
||||
## Pass E — Notifications (17 types × {foreground, background, killed} + tap-to-open)
|
||||
_todo_
|
||||
|
|
@ -0,0 +1,250 @@
|
|||
# Claude QA Playbook — Full-App QA → Fix → Re-QA until flawless
|
||||
|
||||
> Reusable QA plan for the Closer app. Run report-only first, fix everything, then re-QA until a clean round.
|
||||
> Progress/state is tracked in **ClaudeReport.md** (issues) + **ClaudeQACoverage.md** (coverage matrix), which are
|
||||
> the authoritative source of truth. See the Continuity section before resuming.
|
||||
>
|
||||
> **Program roadmap:** **Part 1** = Android QA (this doc) → **Part 2** = build the iOS app to Android's current
|
||||
> parity → **Part 3** = run these same passes on iOS + a cross-platform (Android↔iOS) pass. **Parts 2 & 3 live in
|
||||
> `ClaudeiOSPlan.md`** (note: iOS build/run/QA requires macOS — not possible from this Linux box).
|
||||
|
||||
## Context
|
||||
Drive the real app on both emulators, verify each thing live, report, fix, re-verify. Five QA dimensions:
|
||||
1. **Couple-shared premium** — if EITHER partner is premium, **all** premium features unlock for **both**.
|
||||
2. **Games** — each starts, plays, finishes correctly on both devices.
|
||||
3. **Full visual pass, light + dark** — every screen, text readable, nothing clipped/invisible.
|
||||
4. **Security & encryption (cornerstone)** — every private field is ciphertext at rest, rules hold against
|
||||
non-members, keys/recovery are sound. Findings here default to P0.
|
||||
5. **Notifications** — all 17 types deliver to the right partner (foreground/background/killed), deep-link
|
||||
correctly, and leak no private content.
|
||||
|
||||
Scope decisions: **exhaustive** visual pass (all ~50 screens, both modes); **full scope incl. pre-pairing** flows
|
||||
(fresh throwaway account); **couple-shared everywhere** — per-user gates are bugs, fixed by routing through
|
||||
`core/billing/CouplePremiumChecker.kt`.
|
||||
|
||||
**Early known signal:** only chat uses `CouplePremiumChecker`; games/packs/dates/wheel gate on the user's own
|
||||
`EntitlementChecker.isPremium()` — so premium almost certainly does NOT unlock for the free partner there. Pass A
|
||||
confirms + enumerates this; the fix phase applies couple-shared everywhere.
|
||||
|
||||
## Execution mode — run to completion (autonomous; do NOT stop)
|
||||
- **Do not stop to check in or ask for approval.** Run all five passes → the fix phase → re-QA rounds **continuously
|
||||
until a flawless round** (zero open 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,71 +1,82 @@
|
|||
# Claude QA Report — Games & Notifications
|
||||
# Claude QA Report — Full-App QA (living report)
|
||||
|
||||
**Updated:** 2026-06-24
|
||||
**Devices:** emulator-5554 (Device A = QATester) + emulator-5556 (Device B = Sam), paired for real (coupleId `xNd1H2UGUDNqvyrDGgfu`).
|
||||
**Focus this pass:** every game works end-to-end **and notifications fire correctly on game start + finish.**
|
||||
> **RUN-STATE: Round 2 (re-QA + deferred coverage) NEXT | NEXT ACTION: re-verify A-001 + E-001 fixes; **play each game ONE complete time through on both devices** (Pass B was launch-only — full playthroughs still owed); then Pass C deep/stateful screens (reveal, wheel session, dates, bucket list, auth/onboarding) in both themes + **navigation from every entry point & back-stack/double-back checks**, full live notification matrix, D3 live non-member test; **Pass F (resilience/concurrency/lifecycle/time/migration)**; investigate **D-OBS** PERMISSION_DENIED on outcomes/challenges/capsules.**
|
||||
> Round 1 complete (all 5 passes run report-only; P0–P2 found were fixed in-line). Fixes: A-001 (e8892a9), E-001 (ce12abb). Open P3: A-003, B-001, E-002.
|
||||
> **EXECUTION MODE: autonomous run-to-completion — do NOT stop; fix anything that blocks progress and continue; keep cycling fix→re-QA until a flawless round. Only a gated action (prod deploy / admin write / entitlement toggle) that's denied may be surfaced — do all other work first.**
|
||||
> Playbook: `ClaudeQAPlan.md`. Coverage matrix: `ClaudeQACoverage.md`. Report-only during passes (no fixes until the fix phase).
|
||||
> Devices: emulator-5554 (QA=`Y05AKO`) + emulator-5556 (Sam=`imDjjO`), paired (coupleId `Xal3Kw3gjSdn0niERYKJ`). Build == HEAD `64f0a7e`.
|
||||
|
||||
**Severity:** 🔴 critical · 🟠 high · 🟡 medium · 🟢 low
|
||||
**Status:** 🔎 found · 🛠 fixing · ✅ fixed & builds · ✅✅ verified live · ⚠️ needs deploy
|
||||
_(Prior games/notifications QA from 2026-06-24 was completed + verified; superseded by this full-app effort.)_
|
||||
|
||||
---
|
||||
|
||||
## OPEN — current error log
|
||||
## Severity summary (Round 1)
|
||||
| Severity | Open | Fixed |
|
||||
|---|---|---|
|
||||
| P0 | 0 | 0 |
|
||||
| P1 | 0 | 1 |
|
||||
| P2 | 0 | 1 |
|
||||
| P3 | 3 | 0 |
|
||||
|
||||
### N1. 🔴 FCM token was NEVER registered → no push notifications worked at all — ✅✅ FIXED & VERIFIED
|
||||
`TokenRegistrar.register()` (which fetches `messaging.token` and stores it) was **never called anywhere**. The only other path, `AppMessagingService.onNewToken`, bails with `currentUserId ?: return` — and FCM generates the token at **install, before sign-in**, so `onNewToken` ran with no uid and stored nothing; it never fires again afterwards. Result: **`users/{uid}.fcmToken` was empty for every account**, so no game/message/daily push could ever be delivered.
|
||||
**Fix:** `MainActivity` now observes `authState` and calls `tokenRegistrar.register()` whenever a user is authenticated. ([MainActivity.kt](app/src/main/java/app/closer/MainActivity.kt))
|
||||
**Verified live:** after the fix both A and B have a stored `fcmToken`; a direct push to A rendered the heads-up "Your partner started a game — Tap to join them."
|
||||
|
||||
### N2. 🔴 POST_NOTIFICATIONS permission was never requested → notifications can't display on Android 13+ — ✅ FIXED
|
||||
`NotificationPermissionHelper` existed but had **no caller**. On API 33+ notifications are silently dropped without the runtime grant.
|
||||
**Fix:** `MainActivity` requests `POST_NOTIFICATIONS` on launch via an Activity Result launcher. ([MainActivity.kt](app/src/main/java/app/closer/MainActivity.kt))
|
||||
|
||||
### N3. 🟠 Game-START notification named the WRONG person — ✅ FIXED ⚠️ needs functions deploy
|
||||
`onGameSessionUpdate` passed the **recipient's** name into the body, so the partner saw *"<their own partner-name> has started a game"* (live: B/Sam received "Sam has started a game"). It should name the **starter**.
|
||||
**Fix:** use `startedByUserId` → starter's name + avatar for both title and body. ([onGameSessionUpdate.ts](functions/src/games/onGameSessionUpdate.ts))
|
||||
|
||||
### N4. 🟠 Game-FINISH notification only reached one partner — ✅ FIXED ⚠️ needs functions deploy
|
||||
Completion branch gated the "notify both" path on `currentData.partnerCompletedAt`, **a field the client never writes** (the client tracks `completedByUsers`). So when both finished, the partner who'd been waiting often got nothing (or a "tap to continue playing" that no longer applied).
|
||||
**Fix:** on `active → completed` (both partners done = reveal ready) notify **both** partners, each naming the other ("<name> finished — tap to see your results!"). ([onGameSessionUpdate.ts](functions/src/games/onGameSessionUpdate.ts))
|
||||
|
||||
### N5. 🟡 Finish copy was wrong (title + client mapping) — ✅✅ FIXED & VERIFIED
|
||||
- FCM title was hard-coded `"<name> is playing"` even for finish events (shown verbatim when the app is backgrounded). Now type-aware → `"<name> finished the game"`. ([onGameSessionUpdate.ts](functions/src/games/onGameSessionUpdate.ts))
|
||||
- Client mapped `partner_finished_game` → `PARTNER_COMPLETED_PART` ("finished their part, open yours when ready") — wrong once **both** are done. Added a dedicated `GAME_RESULTS_READY` type ("Your game results are ready! You both finished — tap to see how you compare."). ([PartnerNotificationManager.kt](app/src/main/java/app/closer/notifications/PartnerNotificationManager.kt))
|
||||
|
||||
**N5 verified live:** pushed both types to A — start rendered "Your partner started a game — Tap to join them."; finish rendered "Your game results are ready! You both finished…". The client renders the correct copy for each type.
|
||||
|
||||
### N6. 🟠 Deployed functions are STALE for Date Match — ⚠️ needs functions deploy
|
||||
Source exports `notifyOnDateMatch`, but the **deployed** function is still the old `createDateMatchOnMutualLove`. `onMessageWritten` is current; the rest predate recent edits (incl. N3–N5).
|
||||
**Action:** `firebase deploy --only functions` (allow it to delete `createDateMatchOnMutualLove`).
|
||||
**Round 1: all P0–P2 found were FIXED** (A-001 premium P1, E-001 notif-routing P2). Open = P3 cosmetics only
|
||||
(A-003 badge, B-001 stale-session guard, E-002 informational notif routing). Deferred for a clean "flawless"
|
||||
certification: exhaustive deep/stateful screens (Pass C), full live notification matrix + D3 live non-member test.
|
||||
|
||||
---
|
||||
|
||||
## Per-game status
|
||||
## Pass A — Couple-shared premium ✅ pass complete
|
||||
**Target:** if either partner is premium, all premium features unlock for both.
|
||||
**Result:** only chat is couple-shared. Every other feature gate is per-user → a free user whose partner paid stays locked.
|
||||
|
||||
| Game | Functional | Start notif | Finish notif |
|
||||
|---|---|---|---|
|
||||
| Spin the Wheel | ✅✅ live (prior) | via `sessions` trigger* | via `sessions` trigger* |
|
||||
| This or That | ✅✅ live (prior) | via `sessions` trigger* | via `sessions` trigger* |
|
||||
| How Well | ✅ fixed (prior) | via `sessions` trigger* | via `sessions` trigger* |
|
||||
| Desire Sync | ✅ fixed (prior) | via `sessions` trigger* | via `sessions` trigger* |
|
||||
| Connection Challenges | ✅ clean (prior) | n/a (completion-based) | n/a |
|
||||
| Date Match | ✅ E2EE + rules (prior) | ⚠️ `notifyOnDateMatch` not deployed (N6) | — |
|
||||
| Daily reveal | ✅✅ live (prior) | `onAnswerWritten` / `sendPartnerAnsweredNotification` | reveal-ready |
|
||||
| ID | Area | Screen/Route | Severity | Description | Repro | Status |
|
||||
|---|---|---|---|---|---|---|
|
||||
| A-001 | Premium gating | PlayHubViewModel, DesireSyncScreen, MemoryLaneScreen, ConnectionChallengesScreen, QuestionPackLibraryViewModel, wheel CategoryPicker/SpinWheel/WheelHistory(VMs) | **P1** | These gated on per-user `EntitlementChecker.isPremium()` instead of couple-shared. A free partner of a premium user stayed locked. | Set Sam premium, QA free → QA Play hub still showed 🔒 on Desire Sync + Memory Lane. | **FIXED** `e8892a9` — routed all gates through `CouplePremiumChecker` (now exposes isPremium/hasPremium resolving partner internally). Verified: Sam premium → QA enters Desire Sync; both free → QA → paywall. |
|
||||
| A-003 | Premium UI (cosmetic) | PlayHubScreen (Desire Sync + Memory Lane cards) | **P3** | The "🔒 Premium" badge on these two cards is static (rendered in separate card composables that don't receive `hasPremium`), so it still shows a lock even when the couple has premium access. Feature IS accessible (gate fixed in A-001) — only the badge is misleading. | With couple premium, QA's Play hub still shows 🔒 Premium on Desire Sync/Memory Lane though tapping enters the game. | Open (deferred — thread hasPremium into the card composables) |
|
||||
| A-002 | Premium (control) | ConversationViewModel (chat) | — | **Working correctly** (couple-shared) — kept as the reference pattern for the A-001 fix. | Verified prior round: partner-premium unlocks chat media/reactions for the free partner. | OK |
|
||||
|
||||
\* **All games share one notification trigger:** start writes `couples/{id}/sessions/{sessionId}.status="active"`, finish writes `"completed"`, and `onGameSessionUpdate` fires on that single doc. So N3/N4/N5 fix start+finish notifications for **every** game at once. Pipeline is proven (function writes `notification_queue` + sends FCM; FCM delivery verified in N1); the start-name/finish-both fixes take effect after the deploy in N6.
|
||||
**Note (by-design, not a bug):** `SubscriptionScreen` uses per-user `isPremium()` — correct, it reflects the user's *own* subscription/account state, not a feature gate.
|
||||
|
||||
---
|
||||
## Pass B — Games lifecycle (launch/crash sweep done; full two-device lifecycle partial)
|
||||
| ID | Area | Screen/Route | Severity | Description | Repro | Status |
|
||||
|---|---|---|---|---|---|---|
|
||||
| B-001 | Games / sessions | couples/{id}/sessions | **P3** (observe) | A stale `active` wheel session (startedBy QA, `createdAt` missing/undefined) blocked **all** games ("Waiting for Sam"). In-app **"End their game" recovery works** (session→completed, games unblocked). Likely a prior-test artifact, but: sessions appearing without a timestamp + one stale session blocking every game is worth a guard. | Open This or That → "Waiting for Sam… Sam is playing a Wheel game"; tap End their game → unblocked. | Open |
|
||||
|
||||
## DEPLOY CHECKLIST (your call — prod deploys/admin writes are blocked for the agent)
|
||||
1. `firebase deploy --only functions` — ships N3, N4, N5 (server side) and the `notifyOnDateMatch` rename (N6).
|
||||
2. `firebase deploy --only firestore:rules` — Date Match (`date_swipes`/`date_matches`) + sealed `releaseKeys` sender-read, if not already live.
|
||||
3. Install the refreshed APK (`Closer-v0.1.0-debug-2026-06-24.apk`) — ships N1, N2, N5 (client) + the in-app message bubble.
|
||||
4. A leftover **active `this_or_that` session** is in `couples/{id}/sessions` from prior testing; it blocks starting a new game until that game is finished (or the doc is removed — admin delete needs your authorization).
|
||||
**Launch/crash sweep (QA, free):** This or That ✅ (mood/length select), How Well Do You Know Me ✅ (intro), Connection Challenges ✅, Spin the Wheel ✅ — all render, **no FATAL**. Desire Sync + Memory Lane are premium-gated (covered in Pass A; gameplay needs premium toggle). Date Match: todo. Full two-device start→finish + results not exhaustively re-run this round (the prior round verified `onGameSessionUpdate` start/finish end-to-end).
|
||||
|
||||
## Completed earlier (kept for reference, no longer open)
|
||||
- Daily reveal sealed-key exchange (release-key tolerant read, epoch-millis `updatedAt`, id→label mapping) — ✅✅ verified live.
|
||||
- Game-start crash (`saveSession` empty id → invalid path) — ✅✅ fixed; This or That verified live.
|
||||
- Game re-entry flicker/re-submit (This or That, How Well, Desire Sync) — ✅ pre-check → WAITING.
|
||||
- Daily question determinism + shared `DailyQuestionResolver` — ✅✅ verified live.
|
||||
- Partner identity (users partner-read rule) — ✅✅ verified live ("Connected with Sam/QATester").
|
||||
- Date Match E2EE + rules rewrite — ✅ (⚠️ still needs the deploy in checklist #1/#2).
|
||||
## Pass C — Visual (light + dark) (main screens verified; deep/stateful screens pending)
|
||||
**Method:** 5554=Dark, 5556=Light; readable dark|light pair montages + a code scan for non-adapting colors.
|
||||
**Verified clean (both themes, readable, no clipping):** Home, Today, Play, Messages inbox, Settings. `closerBackgroundBrush()` is theme-aware (adapts). No FATAL on these.
|
||||
| ID | Area | Screen | Severity | Description | Status |
|
||||
|---|---|---|---|---|---|
|
||||
| C-OBS | Theming | ~20 screens (AnswerRevealScreen 15, WheelSessionScreen 14, DateMatchScreen 10, PaywallScreen 9, BucketListScreen 9, SettingsScreen 7, HomeScreen 5, …) | observe | Use hardcoded `Color(0x…)` literals (195 total) that don't adapt to theme — a dark-mode contrast risk to verify per-screen. Main screens checked look fine; deep/stateful screens (reveal, wheel session, dates, bucket list) still need visual verification in both themes. | Open (verify in continuation) |
|
||||
|
||||
_Deep/stateful screens (answer reveal, wheel session/complete, date match/builder/matches, bucket list, memory capsule, history, paywall, auth/onboarding/pairing) need their states set up — pending next chunk._
|
||||
|
||||
**Pass B requirement (updated):** each game must be **played one complete time through on both devices** (start → every step → finish/reveal/results), not just launched. Round 1 did launch-only → **full playthroughs owed in Round 2** for all 7 (premium games need a premium toggle). A launch-only result = `partial`, not `pass`.
|
||||
|
||||
**Pass C requirement (added):** **navigation from every entry point** (each screen reached from all its links — e.g. conversation from inbox/Discuss/notification; game from Play/notification; paywall from each gate) + **back-stack / "double-back"** (system back AND in-app back return to the right place from each entry; no dead-ends, no exit-app surprise, **no screen needing two backs**/duplicate stack entries; deep-link/notification entries land with a sane back stack). Owed in Round 2. Wrong/double back or dead-end = P2 (P1 if it traps the user).
|
||||
|
||||
## Pass D — Security & Encryption ✅ clean (no P0/P1 found)
|
||||
- **D1 at-rest:** all private content is ciphertext — message `text` + `lastMessagePreview` + thread messages = `enc:v1:`; daily answers `encryptedPayload` = `sealed:v1:`. Metadata (dates, types, commitmentHash, ids) plaintext as expected. Chat media bytes = Tink ciphertext (verified prior round + unchanged code path). **No plaintext content leak.**
|
||||
- **D2 rules:** no catch-all `match /{document=**}`, no blanket `if true`; **`hasPremium` server-only** (client create/update blocked, rules L172/174); entitlements `write:false`; conversations/messages/typing/reactions + entitlement partner-read scoped to members.
|
||||
- **D4 key exchange:** pairing uses a **wrapped couple key** (`wrappedCoupleKey` + `kdfSalt`/`kdfParams` + `encryptedRecoveryPhrase`); invite code is the KDF seed, never stored raw; strict E2EE (invites without a wrapped key rejected) — confirmed in `acceptInviteCallable`.
|
||||
- **D5 App Check/secrets:** App Check enforced (`SecurityModule`, `PlayIntegrityChecker`, `FirebaseInitializer`); both service-account JSONs gitignored **and untracked**; `allowBackup=false`.
|
||||
- **D6 leak vectors:** analytics events carry only metadata (no message/answer content); `allowBackup=false`.
|
||||
|
||||
_Follow-ups (not blockers): live **non-member negative test** (D3) needs a fresh 3rd account (rule logic verified member-scoped); a fresh Storage-bytes spot-check of chat media._
|
||||
|
||||
| ID | Area | Severity | Description | Status |
|
||||
|---|---|---|---|---|
|
||||
| D-OBS | Rules / data load | **P2?** (investigate) | During Pass B, logcat showed `PERMISSION_DENIED` for client listeners on `couples/{id}/outcomes`, `couples/{id}/challenges (where status==active)`, and `couples/{id}/capsules`. Either a rules gap or queries firing for features the (free) user can't read → console errors / possibly broken Connection Challenges + Memory Lane data load. **Round 2: confirm whether these features load correctly + fix the rule or guard the query.** | Open |
|
||||
|
||||
## Pass E — Notifications
|
||||
- **Copy carries no private content:** all function notification bodies are generic ("Tap to read and reply", "Answer together before it expires", etc.); `${title}` refers to public question/game titles, not user answers. ✓ (ties to D6)
|
||||
- **Routing:** centralized in `PartnerNotificationType` (`fromRemoteType` → `routeFor`); chat opens the exact conversation, reveal→answerReveal(questionId), games→Play, capsule→Memory Lane, etc.
|
||||
- **Foundations** (prior round, code present): FCM token registration on sign-in, POST_NOTIFICATIONS, channels.
|
||||
|
||||
| ID | Area | Severity | Description | Status |
|
||||
|---|---|---|---|---|
|
||||
| E-001 | Notification routing | **P2** | Type-string mismatch: functions send `daily_question` + `challenge_day_ready`, but client mapped only `daily_question_reminder` + `challenge_waiting` → tapping those did NOT deep-link to Today / Connection Challenges. | **FIXED** `<pending-commit>` — added `daily_question`/`challenge_day_ready` to `fromRemoteType` (build green; live tap-verify deferred). |
|
||||
| E-002 | Notification routing | **P3** | `partner_left`, `partner_deleted_account`, `invite_created`, `spki` are unmapped → tap lands on default (no deep-link). Informational types; acceptable but ideally routed. | Open |
|
||||
|
||||
_Full live delivery matrix (17 types × foreground/background/killed) deferred — key types verified prior round; routing now code-correct._
|
||||
|
|
|
|||
|
|
@ -0,0 +1,113 @@
|
|||
# Claude iOS Plan — Part 2 (build to parity) + Part 3 (iOS QA)
|
||||
|
||||
> Program: **Part 1** = Android QA (`ClaudeQAPlan.md`, in progress) → **Part 2** = build the iOS app to Android's
|
||||
> CURRENT state → **Part 3** = run the same QA passes on iOS. This doc covers Parts 2 & 3.
|
||||
|
||||
## Decisions (locked)
|
||||
- **Approach:** native **SwiftUI** + Firebase iOS SDK (the `iphone/` scaffold already chose this: SwiftPM + XcodeGen,
|
||||
feature dirs mirroring Android, `ARCHITECTURE_AUDIT.md`). Do NOT switch to KMP/Compose-MP.
|
||||
- **E2EE:** **full Tink-compatible** crypto — iOS must byte-match Android's wire formats so an **iOS↔Android couple
|
||||
decrypts each other**. (Overrides the audit's "skip E2EE for MVP" note — encryption is the cornerstone.)
|
||||
- **Scope:** **working parity build** (Simulator + real device, TestFlight optional). **No App Store submission.**
|
||||
|
||||
## ⚠️ Hard constraint — macOS required (we are on Linux)
|
||||
iOS build/run/QA needs **macOS + Xcode + iOS Simulator**; there is **no adb-equivalent from Linux**. Therefore:
|
||||
- **On this Linux box** I can only: author/refresh Swift source, refresh the audit, write the plan/specs, and reason
|
||||
about the code. **I cannot compile, run the Simulator, or do interactive/visual QA here.**
|
||||
- **Everything that requires building or running is deferred to a Mac** (a Mac, cloud-Mac, or macOS CI runner).
|
||||
- Part 2 done-on-Linux = "code authored + self-reviewed"; Part 2 *truly* done = builds green in Xcode and runs on
|
||||
Simulator + device (on a Mac). **Part 3 (iOS QA) is entirely macOS-gated.**
|
||||
|
||||
## Current iOS state (as found)
|
||||
`iphone/` = scaffold only: ~19 stub Swift files, `Crypto/` **empty**, most feature dirs empty, one `FirestoreService`.
|
||||
`ARCHITECTURE_AUDIT.md` is thorough but generated from Android **v0.2.0** — it **predates** Messages/conversations,
|
||||
chat media (photo/GIF/sticker/voice), reactions/typing/read-receipts/pagination, and couple-shared premium (A-001).
|
||||
So this is a near-full build, not a top-up.
|
||||
|
||||
---
|
||||
|
||||
## Part 2 — Build iOS to current Android parity
|
||||
|
||||
### 2.0 Refresh the audit to CURRENT Android (do first)
|
||||
Update `iphone/ARCHITECTURE_AUDIT.md` from the live Android code: add the **Messages** tab (conversations inbox +
|
||||
conversation chat: text/photo/GIF/sticker/voice, reactions, delete/tombstone, typing, read-receipts, pagination,
|
||||
chat-head bubble), the conversations Firestore model, the new notification types, and **couple-shared premium**
|
||||
(`CouplePremiumChecker` — either partner premium unlocks all features for both). Re-tally screens/models/functions.
|
||||
|
||||
### 2.1 Project + dependencies (Mac)
|
||||
XcodeGen/`project.yml`; SPM: Firebase Auth/Firestore/Functions/Messaging/Storage, RevenueCat (`purchases-ios`),
|
||||
GoogleSignIn; `GoogleService-Info.plist`; entitlements (push, keychain); **App Check** via DeviceCheck/App Attest
|
||||
(Android uses Play Integrity); APNs setup for FCM.
|
||||
|
||||
### 2.2 Core infrastructure
|
||||
`AppDependencies` (manual DI), `AuthService` + `AuthRateLimiter` (mirror Android limits), `FirestoreService` +
|
||||
per-domain services (User/Couple/Invite/Question/Answer/Game/Date/Conversation), `NotificationService`
|
||||
(APNs+FCM + deep-link routing mirroring `PartnerNotificationType`), navigation (TabView + NavigationStack per tab,
|
||||
`AppRoute` mirror, deep-link handler), `CloserTheme` (light+dark), reusable components.
|
||||
|
||||
### 2.3 E2EE — full Tink-compatible crypto (HIGHEST RISK; cornerstone)
|
||||
Implement byte-compatible Swift crypto for every Android wire format:
|
||||
- `SealedAnswerEncryptor` (AES-256-GCM, 96-bit IV, AAD `"{coupleId}|{questionId}|{userId}"`, `sealed:v1:`),
|
||||
`FieldEncryptor` (`enc:v1:`, AAD=coupleId) — used by messages/previews/dates,
|
||||
`AnswerCommitment` (SHA-256, `sha256:`), `UserKeyManager` (ECIES P-256 HKDF-HMAC-SHA256 AES128-CTR-HMAC, keypair in
|
||||
Keychain, `pub:v1:`), `ReleaseKeyEncryptor` (`keybox:v1:`), `RecoveryKeyManager` (**Argon2id m=46MiB, t=3, p=1**,
|
||||
BIP39-style wordlist), `CoupleEncryptionManager`, Keychain-backed key stores.
|
||||
- **Interop test harness (the acceptance gate):** (a) decrypt Android-produced fixtures (`sealed:v1:`/`enc:v1:`/
|
||||
`keybox:v1:`) on iOS; (b) have Android decrypt iOS-produced ciphertext; (c) verify an Android-generated recovery
|
||||
phrase unlocks on iOS and vice versa. Wire formats must match byte-for-byte. If a format can't be matched with
|
||||
CryptoKit, use a vetted Swift lib (e.g. swift-sodium/SwiftArgon2) — never a non-interoperable shortcut.
|
||||
- **Golden vectors** for the deterministic primitives (checked into both repos): `AnswerCommitment` SHA-256 and
|
||||
`RecoveryKeyManager` Argon2id (fixed salt/params) must produce **identical bytes** on both platforms — assert in unit
|
||||
tests on each side. AEAD/ECIES use random IVs so they can't be golden-matched; cover those with the **round-trip**
|
||||
harness above. Generate the Android fixtures now (on Linux) so iOS has them ready.
|
||||
|
||||
### 2.4 Screens & features to parity (~48 + new messaging)
|
||||
All routes from the refreshed audit's screen map, **including the NEW Messages experience** (inbox + conversation
|
||||
with full media/reactions/typing/read-receipts/pagination) and the games (full, not stubs), dates, wheel, settings,
|
||||
paywall. Mirror the Android UX + the couple-shared premium gating (route gated features through the iOS
|
||||
`EntitlementChecker` couple-shared equivalent).
|
||||
|
||||
### 2.5 Build & smoke (Mac)
|
||||
Xcode build green; run on Simulator + a real device; smoke each major flow (auth → pair → home/today/play/messages/
|
||||
settings) with no crashes. **This step + 2.1/2.5 runs on a Mac.**
|
||||
|
||||
---
|
||||
|
||||
## Part 3 — iOS QA (reuse `ClaudeQAPlan.md` passes 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).
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
package app.closer.core.billing
|
||||
|
||||
import app.closer.data.remote.FirestoreCollections
|
||||
import app.closer.domain.repository.CoupleRepository
|
||||
import com.google.firebase.auth.FirebaseAuth
|
||||
import com.google.firebase.firestore.FirebaseFirestore
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.callbackFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.emitAll
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Couple-shared premium: a premium feature unlocks if EITHER partner has an active subscription, so a
|
||||
* single subscription covers the couple. Combines the current user's [EntitlementChecker.isPremium]
|
||||
* with a live read of the partner's `users/{partnerId}/entitlements/premium` doc (same active/expiry
|
||||
* rule as [FirestoreEntitlementChecker]). Reactive: flips the moment either side subscribes/expires.
|
||||
*
|
||||
* Exposes [isPremium]/[hasPremium] with the SAME shape as [EntitlementChecker] so it's a drop-in for
|
||||
* any feature gate (resolves the partner internally), plus [coupleHasPremium] when the caller already
|
||||
* knows the partner id.
|
||||
*/
|
||||
@Singleton
|
||||
class CouplePremiumChecker @Inject constructor(
|
||||
private val entitlementChecker: EntitlementChecker,
|
||||
private val firestore: FirebaseFirestore,
|
||||
private val coupleRepository: CoupleRepository
|
||||
) {
|
||||
/** Couple-shared premium for the current user (resolves the partner internally). Drop-in for EntitlementChecker. */
|
||||
fun isPremium(): Flow<Boolean> = flow {
|
||||
val uid = FirebaseAuth.getInstance().currentUser?.uid
|
||||
val partnerId = uid?.let {
|
||||
runCatching { coupleRepository.getCoupleForUser(it)?.userIds?.firstOrNull { id -> id != it } }.getOrNull()
|
||||
}
|
||||
emitAll(coupleHasPremium(partnerId))
|
||||
}
|
||||
|
||||
/** One-shot couple-shared premium check. Drop-in for EntitlementChecker.hasPremium. */
|
||||
suspend fun hasPremium(): Boolean = isPremium().first()
|
||||
|
||||
fun coupleHasPremium(partnerId: String?): Flow<Boolean> =
|
||||
if (partnerId.isNullOrBlank()) entitlementChecker.isPremium()
|
||||
else combine(entitlementChecker.isPremium(), observePartnerPremium(partnerId)) { mine, theirs ->
|
||||
mine || theirs
|
||||
}.distinctUntilChanged()
|
||||
|
||||
private fun observePartnerPremium(partnerId: String): Flow<Boolean> = callbackFlow {
|
||||
val ref = firestore.collection(FirestoreCollections.USERS).document(partnerId)
|
||||
.collection(FirestoreCollections.Users.ENTITLEMENTS)
|
||||
.document(FirestoreCollections.Users.ENTITLEMENT_PREMIUM_DOC)
|
||||
val listener = ref.addSnapshotListener { snap, err ->
|
||||
if (err != null || snap == null || !snap.exists()) { trySend(false); return@addSnapshotListener }
|
||||
val premium = snap.getBoolean("premium") ?: false
|
||||
val expiresAt = snap.getTimestamp("expiresAt")
|
||||
val active = premium && (expiresAt == null || expiresAt.seconds > System.currentTimeMillis() / 1000)
|
||||
trySend(active)
|
||||
}
|
||||
awaitClose { listener.remove() }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
package app.closer.core.media
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.graphics.Matrix
|
||||
import android.media.ExifInterface
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.ByteArrayOutputStream
|
||||
|
||||
/**
|
||||
* Downscales + JPEG-compresses photos from the gallery/camera before they're encrypted and
|
||||
* uploaded, to cut upload time and storage. Deliberately leaves **animated GIFs and already-small
|
||||
* images untouched** (so keyboard GIFs/stickers keep their animation + transparency — those never
|
||||
* reach this path anyway) and applies **EXIF orientation** so photos aren't sent sideways.
|
||||
*
|
||||
* Never throws: on any failure it returns the original bytes, so a photo is never lost.
|
||||
*/
|
||||
object MediaCompressor {
|
||||
private const val MAX_DIM = 1600
|
||||
private const val JPEG_QUALITY = 80
|
||||
private const val SKIP_BELOW_BYTES = 200 * 1024
|
||||
|
||||
fun compressPhoto(input: ByteArray): ByteArray = runCatching {
|
||||
if (isGif(input)) return input
|
||||
|
||||
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||
BitmapFactory.decodeByteArray(input, 0, input.size, bounds)
|
||||
val w = bounds.outWidth
|
||||
val h = bounds.outHeight
|
||||
if (w <= 0 || h <= 0) return input
|
||||
// Already small — leave it (also preserves small stickers/PNGs picked from the gallery).
|
||||
if (w <= MAX_DIM && h <= MAX_DIM && input.size <= SKIP_BELOW_BYTES) return input
|
||||
|
||||
val opts = BitmapFactory.Options().apply { inSampleSize = sampleSize(w, h, MAX_DIM) }
|
||||
var bmp = BitmapFactory.decodeByteArray(input, 0, input.size, opts) ?: return input
|
||||
|
||||
// Exact-fit downscale on the long edge.
|
||||
val longEdge = maxOf(bmp.width, bmp.height)
|
||||
if (longEdge > MAX_DIM) {
|
||||
val scale = MAX_DIM.toFloat() / longEdge
|
||||
val scaled = Bitmap.createScaledBitmap(bmp, (bmp.width * scale).toInt(), (bmp.height * scale).toInt(), true)
|
||||
if (scaled != bmp) bmp.recycle()
|
||||
bmp = scaled
|
||||
}
|
||||
|
||||
// Re-apply EXIF rotation (lost when decoding to a bitmap).
|
||||
val rotation = orientationDegrees(input)
|
||||
if (rotation != 0f) {
|
||||
val m = Matrix().apply { postRotate(rotation) }
|
||||
val rotated = Bitmap.createBitmap(bmp, 0, 0, bmp.width, bmp.height, m, true)
|
||||
if (rotated != bmp) bmp.recycle()
|
||||
bmp = rotated
|
||||
}
|
||||
|
||||
val out = ByteArrayOutputStream()
|
||||
bmp.compress(Bitmap.CompressFormat.JPEG, JPEG_QUALITY, out)
|
||||
bmp.recycle()
|
||||
val result = out.toByteArray()
|
||||
if (result.isNotEmpty() && result.size < input.size) result else input
|
||||
}.getOrDefault(input)
|
||||
|
||||
private fun isGif(b: ByteArray): Boolean =
|
||||
b.size >= 3 && b[0] == 'G'.code.toByte() && b[1] == 'I'.code.toByte() && b[2] == 'F'.code.toByte()
|
||||
|
||||
private fun sampleSize(w: Int, h: Int, target: Int): Int {
|
||||
var s = 1
|
||||
val longEdge = maxOf(w, h)
|
||||
while (longEdge / (s * 2) >= target) s *= 2
|
||||
return s
|
||||
}
|
||||
|
||||
private fun orientationDegrees(input: ByteArray): Float = runCatching {
|
||||
val exif = ExifInterface(ByteArrayInputStream(input))
|
||||
when (exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL)) {
|
||||
ExifInterface.ORIENTATION_ROTATE_90 -> 90f
|
||||
ExifInterface.ORIENTATION_ROTATE_180 -> 180f
|
||||
ExifInterface.ORIENTATION_ROTATE_270 -> 270f
|
||||
else -> 0f
|
||||
}
|
||||
}.getOrDefault(0f)
|
||||
}
|
||||
|
|
@ -1,11 +1,14 @@
|
|||
package app.closer.data.remote
|
||||
|
||||
import android.content.Context
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.NetworkCapabilities
|
||||
import android.net.Uri
|
||||
import com.google.firebase.storage.FirebaseStorage
|
||||
import com.google.firebase.storage.StorageMetadata
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import java.io.IOException
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import kotlin.coroutines.resume
|
||||
|
|
@ -16,7 +19,17 @@ class FirebaseStorageDataSource @Inject constructor(
|
|||
@ApplicationContext private val context: Context
|
||||
) {
|
||||
|
||||
private val storage = FirebaseStorage.getInstance()
|
||||
// Cap retries so an offline upload surfaces a failure in seconds instead of the ~2-minute default.
|
||||
private val storage = FirebaseStorage.getInstance().apply {
|
||||
maxUploadRetryTimeMillis = 30_000
|
||||
maxOperationRetryTimeMillis = 30_000
|
||||
}
|
||||
|
||||
private fun isOnline(): Boolean {
|
||||
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager ?: return true
|
||||
val caps = cm.activeNetwork?.let { cm.getNetworkCapabilities(it) } ?: return false
|
||||
return caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
||||
}
|
||||
|
||||
suspend fun uploadProfilePhoto(uid: String, uri: Uri): String =
|
||||
suspendCancellableCoroutine { cont ->
|
||||
|
|
@ -40,6 +53,11 @@ class FirebaseStorageDataSource @Inject constructor(
|
|||
*/
|
||||
suspend fun uploadEncryptedMedia(uid: String, encryptedBytes: ByteArray): String =
|
||||
suspendCancellableCoroutine { cont ->
|
||||
// Fail fast when clearly offline so the chat shows a retry instead of a stuck spinner.
|
||||
if (!isOnline()) {
|
||||
cont.resumeWithException(IOException("No internet connection"))
|
||||
return@suspendCancellableCoroutine
|
||||
}
|
||||
val ref = storage.reference.child("users/$uid/chat_media/${java.util.UUID.randomUUID()}")
|
||||
val metadata = StorageMetadata.Builder()
|
||||
.setContentType("application/octet-stream")
|
||||
|
|
|
|||
|
|
@ -149,9 +149,15 @@ class FirestoreConversationDataSource @Inject constructor(
|
|||
).voidAwait()
|
||||
}
|
||||
|
||||
fun observeMessages(coupleId: String, conversationId: String): Flow<List<QuestionMessage>> = callbackFlow {
|
||||
/**
|
||||
* Live listener on the newest [limit] messages (ascending for display). Using `limitToLast`
|
||||
* keeps just-sent messages inside the window regardless of pending server-timestamp ordering;
|
||||
* "load older" simply grows [limit] (a single live listener, so incoming messages still appear).
|
||||
*/
|
||||
fun observeMessages(coupleId: String, conversationId: String, limit: Int): Flow<List<QuestionMessage>> = callbackFlow {
|
||||
val listener = messagesRef(coupleId, conversationId)
|
||||
.orderBy("createdAt", Query.Direction.ASCENDING)
|
||||
.limitToLast(limit.toLong())
|
||||
.addSnapshotListener { snap, err ->
|
||||
if (err != null || snap == null) return@addSnapshotListener
|
||||
val aead = encryptionManager.aeadFor(coupleId)
|
||||
|
|
@ -160,6 +166,66 @@ class FirestoreConversationDataSource @Inject constructor(
|
|||
awaitClose { listener.remove() }
|
||||
}
|
||||
|
||||
/** Toggles the caller's emoji reaction on a message (pass null to remove). Any couple member may react. */
|
||||
suspend fun setReaction(coupleId: String, conversationId: String, messageId: String, userId: String, emoji: String?) {
|
||||
val ref = messagesRef(coupleId, conversationId).document(messageId)
|
||||
if (emoji == null) {
|
||||
ref.update("reactions.$userId", FieldValue.delete()).voidAwait()
|
||||
} else {
|
||||
ref.update(mapOf("reactions.$userId" to emoji)).voidAwait()
|
||||
}
|
||||
}
|
||||
|
||||
/** Unsend: tombstones a message (author-only) and reflects it in the inbox preview if it was last. */
|
||||
suspend fun deleteMessage(coupleId: String, conversationId: String, messageId: String) {
|
||||
messagesRef(coupleId, conversationId).document(messageId).update("deleted", true).voidAwait()
|
||||
val latest = runCatching {
|
||||
messagesRef(coupleId, conversationId)
|
||||
.orderBy("createdAt", Query.Direction.DESCENDING).limit(1).get().await()
|
||||
}.getOrNull()
|
||||
if (latest?.documents?.firstOrNull()?.id == messageId) {
|
||||
val aead = encryptionManager.aeadFor(coupleId) ?: return
|
||||
conversationsRef(coupleId).document(conversationId).set(
|
||||
mapOf("lastMessagePreview" to fieldEncryptor.encrypt("Message deleted", aead, coupleId)),
|
||||
SetOptions.merge()
|
||||
).voidAwait()
|
||||
}
|
||||
}
|
||||
|
||||
/** Fire-and-forget typing flag on the conversation doc (ephemeral; cleared on stop/send/leave). */
|
||||
fun setTyping(coupleId: String, conversationId: String, userId: String, typing: Boolean) {
|
||||
val ref = conversationsRef(coupleId).document(conversationId)
|
||||
val value = if (typing) FieldValue.serverTimestamp() else FieldValue.delete()
|
||||
ref.update(mapOf("typing.$userId" to value)).addOnFailureListener { /* best-effort */ }
|
||||
}
|
||||
|
||||
/** Emits the partner's latest typing timestamp (0 if not typing). */
|
||||
fun observePartnerTyping(coupleId: String, conversationId: String, currentUserId: String): Flow<Long> = callbackFlow {
|
||||
val listener = conversationsRef(coupleId).document(conversationId)
|
||||
.addSnapshotListener { snap, err ->
|
||||
if (err != null || snap == null) return@addSnapshotListener
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val typing = (snap.get("typing") as? Map<String, com.google.firebase.Timestamp>).orEmpty()
|
||||
val at = typing.filterKeys { it != currentUserId }.values.maxOfOrNull { it.toDate().time } ?: 0L
|
||||
trySend(at)
|
||||
}
|
||||
awaitClose { listener.remove() }
|
||||
}
|
||||
|
||||
/** Emits the partner's most recent read timestamp for this conversation (0 if never read). */
|
||||
fun observePartnerReadAt(coupleId: String, conversationId: String, currentUserId: String): Flow<Long> = callbackFlow {
|
||||
val listener = conversationsRef(coupleId).document(conversationId)
|
||||
.addSnapshotListener { snap, err ->
|
||||
if (err != null || snap == null) return@addSnapshotListener
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val reads = (snap.get("reads") as? Map<String, com.google.firebase.Timestamp>).orEmpty()
|
||||
val partnerReadAt = reads.filterKeys { it != currentUserId }
|
||||
.values.maxOfOrNull { it.toDate().time } ?: 0L
|
||||
trySend(partnerReadAt)
|
||||
}
|
||||
awaitClose { listener.remove() }
|
||||
}
|
||||
|
||||
suspend fun loadDecryptedMedia(coupleId: String, mediaUrl: String): ByteArray? {
|
||||
val aead = encryptionManager.aeadFor(coupleId) ?: return null
|
||||
val cipher = runCatching { storageDataSource.downloadBytes(mediaUrl) }.getOrNull() ?: return null
|
||||
|
|
@ -196,12 +262,16 @@ class FirestoreConversationDataSource @Inject constructor(
|
|||
): QuestionMessage? {
|
||||
val userId = getString("authorUserId") ?: return null
|
||||
val type = getString("type") ?: "text"
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val reactions = (get("reactions") as? Map<String, String>).orEmpty()
|
||||
return QuestionMessage(
|
||||
id = id,
|
||||
userId = userId,
|
||||
type = type,
|
||||
mediaUrl = getString("mediaUrl") ?: "",
|
||||
durationMs = getLong("durationMs") ?: 0L,
|
||||
reactions = reactions,
|
||||
deleted = getBoolean("deleted") ?: false,
|
||||
text = if (type == "text") (fieldEncryptor.decryptForDisplay(getString("text"), aead, coupleId) ?: "") else "",
|
||||
createdAt = getTimestamp("createdAt")?.toDate()?.time ?: 0L
|
||||
)
|
||||
|
|
|
|||
|
|
@ -25,8 +25,17 @@ class ConversationRepositoryImpl @Inject constructor(
|
|||
override suspend fun markRead(coupleId: String, conversationId: String, userId: String) =
|
||||
dataSource.markRead(coupleId, conversationId, userId)
|
||||
|
||||
override fun observeMessages(coupleId: String, conversationId: String): Flow<List<QuestionMessage>> =
|
||||
dataSource.observeMessages(coupleId, conversationId)
|
||||
override fun observeMessages(coupleId: String, conversationId: String, limit: Int): Flow<List<QuestionMessage>> =
|
||||
dataSource.observeMessages(coupleId, conversationId, limit)
|
||||
|
||||
override fun observePartnerReadAt(coupleId: String, conversationId: String, currentUserId: String): Flow<Long> =
|
||||
dataSource.observePartnerReadAt(coupleId, conversationId, currentUserId)
|
||||
|
||||
override fun setTyping(coupleId: String, conversationId: String, userId: String, typing: Boolean) =
|
||||
dataSource.setTyping(coupleId, conversationId, userId, typing)
|
||||
|
||||
override fun observePartnerTyping(coupleId: String, conversationId: String, currentUserId: String): Flow<Long> =
|
||||
dataSource.observePartnerTyping(coupleId, conversationId, currentUserId)
|
||||
|
||||
override suspend fun sendMessage(coupleId: String, conversationId: String, userId: String, text: String) =
|
||||
dataSource.sendMessage(coupleId, conversationId, userId, text)
|
||||
|
|
@ -37,6 +46,12 @@ class ConversationRepositoryImpl @Inject constructor(
|
|||
override suspend fun sendVoiceMessage(coupleId: String, conversationId: String, userId: String, audioBytes: ByteArray, durationMs: Long) =
|
||||
dataSource.sendVoiceMessage(coupleId, conversationId, userId, audioBytes, durationMs)
|
||||
|
||||
override suspend fun setReaction(coupleId: String, conversationId: String, messageId: String, userId: String, emoji: String?) =
|
||||
dataSource.setReaction(coupleId, conversationId, messageId, userId, emoji)
|
||||
|
||||
override suspend fun deleteMessage(coupleId: String, conversationId: String, messageId: String) =
|
||||
dataSource.deleteMessage(coupleId, conversationId, messageId)
|
||||
|
||||
override suspend fun loadDecryptedMedia(coupleId: String, mediaUrl: String): ByteArray? =
|
||||
dataSource.loadDecryptedMedia(coupleId, mediaUrl)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,8 +10,12 @@ data class QuestionMessage(
|
|||
val mediaUrl: String = "",
|
||||
/** Voice-note length in milliseconds (0 for non-voice). */
|
||||
val durationMs: Long = 0L,
|
||||
/** Emoji reactions keyed by reactor uid, e.g. {uid: "❤️"}. */
|
||||
val reactions: Map<String, String> = emptyMap(),
|
||||
/** True once the author has unsent (tombstoned) the message. */
|
||||
val deleted: Boolean = false,
|
||||
val createdAt: Long = 0L
|
||||
) {
|
||||
val isImage: Boolean get() = type == "image" && mediaUrl.isNotBlank()
|
||||
val isVoice: Boolean get() = type == "voice" && mediaUrl.isNotBlank()
|
||||
val isImage: Boolean get() = type == "image" && mediaUrl.isNotBlank() && !deleted
|
||||
val isVoice: Boolean get() = type == "voice" && mediaUrl.isNotBlank() && !deleted
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,9 +9,14 @@ interface ConversationRepository {
|
|||
suspend fun ensureMainConversation(coupleId: String)
|
||||
suspend fun ensureQuestionConversation(coupleId: String, questionId: String): String
|
||||
suspend fun markRead(coupleId: String, conversationId: String, userId: String)
|
||||
fun observeMessages(coupleId: String, conversationId: String): Flow<List<QuestionMessage>>
|
||||
fun observeMessages(coupleId: String, conversationId: String, limit: Int): Flow<List<QuestionMessage>>
|
||||
fun observePartnerReadAt(coupleId: String, conversationId: String, currentUserId: String): Flow<Long>
|
||||
fun setTyping(coupleId: String, conversationId: String, userId: String, typing: Boolean)
|
||||
fun observePartnerTyping(coupleId: String, conversationId: String, currentUserId: String): Flow<Long>
|
||||
suspend fun sendMessage(coupleId: String, conversationId: String, userId: String, text: String)
|
||||
suspend fun sendImageMessage(coupleId: String, conversationId: String, userId: String, imageBytes: ByteArray)
|
||||
suspend fun sendVoiceMessage(coupleId: String, conversationId: String, userId: String, audioBytes: ByteArray, durationMs: Long)
|
||||
suspend fun setReaction(coupleId: String, conversationId: String, messageId: String, userId: String, emoji: String?)
|
||||
suspend fun deleteMessage(coupleId: String, conversationId: String, messageId: String)
|
||||
suspend fun loadDecryptedMedia(coupleId: String, mediaUrl: String): ByteArray?
|
||||
}
|
||||
|
|
|
|||
|
|
@ -287,10 +287,12 @@ enum class PartnerNotificationType(
|
|||
// is ready, so this maps to the results-ready copy (not "open yours when ready").
|
||||
"partner_finished_game" -> GAME_RESULTS_READY
|
||||
"game_results_ready" -> GAME_RESULTS_READY
|
||||
"challenge_waiting" -> CHALLENGE_WAITING
|
||||
// Server emits 'challenge_day_ready'; keep the legacy 'challenge_waiting' alias too.
|
||||
"challenge_day_ready", "challenge_waiting" -> CHALLENGE_WAITING
|
||||
"memory_capsule_unlocked" -> CAPSULE_UNLOCKED
|
||||
"gentle_reminder" -> GENTLE_REMINDER
|
||||
"daily_question_reminder" -> DAILY_QUESTION_REMINDER
|
||||
// Server emits both 'daily_question' (assignment) and 'daily_question_reminder' — both open Today.
|
||||
"daily_question", "daily_question_reminder" -> DAILY_QUESTION_REMINDER
|
||||
"chat_message" -> CHAT_MESSAGE
|
||||
"outcome_reminder" -> OUTCOME_REMINDER
|
||||
"partner_joined" -> PARTNER_JOINED
|
||||
|
|
|
|||
|
|
@ -56,7 +56,7 @@ import androidx.compose.ui.unit.dp
|
|||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import app.closer.core.billing.EntitlementChecker
|
||||
import app.closer.core.billing.CouplePremiumChecker
|
||||
import app.closer.core.navigation.AppRoute
|
||||
import app.closer.data.challenges.ChallengesCatalog
|
||||
import app.closer.data.remote.FirestoreChallengeDataSource
|
||||
|
|
@ -105,7 +105,7 @@ class ConnectionChallengesViewModel @Inject constructor(
|
|||
private val authRepository: AuthRepository,
|
||||
private val coupleRepository: CoupleRepository,
|
||||
private val challengeDataSource: FirestoreChallengeDataSource,
|
||||
private val entitlementChecker: EntitlementChecker
|
||||
private val premiumChecker: CouplePremiumChecker
|
||||
) : ViewModel() {
|
||||
|
||||
private val _uiState = MutableStateFlow(ChallengesUiState())
|
||||
|
|
@ -114,7 +114,7 @@ class ConnectionChallengesViewModel @Inject constructor(
|
|||
private var progressJob: Job? = null
|
||||
|
||||
init {
|
||||
entitlementChecker.isPremium()
|
||||
premiumChecker.isPremium()
|
||||
.onEach { premium -> _uiState.update { it.copy(hasPremium = premium) } }
|
||||
.launchIn(viewModelScope)
|
||||
load()
|
||||
|
|
|
|||
|
|
@ -58,7 +58,7 @@ import androidx.hilt.navigation.compose.hiltViewModel
|
|||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import app.closer.core.billing.EntitlementChecker
|
||||
import app.closer.core.billing.CouplePremiumChecker
|
||||
import app.closer.core.navigation.AppRoute
|
||||
import app.closer.data.remote.FirestoreDesireSyncDataSource
|
||||
import android.content.Context
|
||||
|
|
@ -123,7 +123,7 @@ class DesireSyncViewModel @Inject constructor(
|
|||
private val repository: QuestionRepository,
|
||||
private val gameSessionManager: GameSessionManager,
|
||||
private val dataSource: FirestoreDesireSyncDataSource,
|
||||
private val entitlementChecker: EntitlementChecker
|
||||
private val premiumChecker: CouplePremiumChecker
|
||||
) : ViewModel() {
|
||||
|
||||
private val _uiState = MutableStateFlow(DesireSyncUiState())
|
||||
|
|
@ -144,7 +144,7 @@ class DesireSyncViewModel @Inject constructor(
|
|||
|
||||
private fun load() {
|
||||
viewModelScope.launch {
|
||||
if (!entitlementChecker.hasPremium()) {
|
||||
if (!premiumChecker.hasPremium()) {
|
||||
_uiState.update { it.copy(navigateTo = AppRoute.PAYWALL) }
|
||||
return@launch
|
||||
}
|
||||
|
|
|
|||
|
|
@ -70,7 +70,7 @@ import androidx.compose.ui.unit.dp
|
|||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import app.closer.core.billing.EntitlementChecker
|
||||
import app.closer.core.billing.CouplePremiumChecker
|
||||
import app.closer.core.navigation.AppRoute
|
||||
import app.closer.data.remote.FirestoreCapsuleDataSource
|
||||
import app.closer.domain.model.TimeCapsule
|
||||
|
|
@ -140,7 +140,7 @@ class MemoryLaneViewModel @Inject constructor(
|
|||
private val authRepository: AuthRepository,
|
||||
private val coupleRepository: CoupleRepository,
|
||||
private val capsuleDataSource: FirestoreCapsuleDataSource,
|
||||
private val entitlementChecker: EntitlementChecker
|
||||
private val premiumChecker: CouplePremiumChecker
|
||||
) : ViewModel() {
|
||||
|
||||
private val _uiState = MutableStateFlow(MemoryLaneUiState())
|
||||
|
|
@ -152,7 +152,7 @@ class MemoryLaneViewModel @Inject constructor(
|
|||
|
||||
private fun load() {
|
||||
viewModelScope.launch {
|
||||
val hasPremium = entitlementChecker.hasPremium()
|
||||
val hasPremium = premiumChecker.hasPremium()
|
||||
if (!hasPremium) {
|
||||
_uiState.update { it.copy(phase = MemoryLanePhase.LIST, hasPremium = false) }
|
||||
return@launch
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import androidx.compose.foundation.layout.Column
|
|||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
|
|
@ -14,21 +15,32 @@ import androidx.compose.foundation.lazy.LazyColumn
|
|||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Person
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
|
|
@ -38,8 +50,11 @@ import androidx.compose.ui.text.font.FontWeight
|
|||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import app.closer.core.navigation.AppRoute
|
||||
import app.closer.ui.messages.components.ChatComposer
|
||||
import app.closer.ui.messages.components.ChatDaySeparator
|
||||
import app.closer.ui.messages.components.ChatMessageRow
|
||||
import app.closer.ui.messages.components.isSameChatDay
|
||||
import app.closer.ui.theme.closerBackgroundBrush
|
||||
import coil.compose.AsyncImage
|
||||
|
||||
|
|
@ -51,14 +66,42 @@ fun ConversationScreen(
|
|||
) {
|
||||
val state by viewModel.uiState.collectAsState()
|
||||
val listState = rememberLazyListState()
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
|
||||
LaunchedEffect(state.messages.size) {
|
||||
if (state.messages.isNotEmpty()) listState.animateScrollToItem(state.messages.lastIndex)
|
||||
val routeToPaywall: () -> Unit = {
|
||||
viewModel.onMediaPaywallShown()
|
||||
onNavigate(AppRoute.PAYWALL)
|
||||
}
|
||||
|
||||
// Surface send failures as a snackbar.
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.events.collect { snackbarHostState.showSnackbar(it) }
|
||||
}
|
||||
|
||||
// Scroll to the bottom on first load and when a NEW message arrives while near the bottom —
|
||||
// never when older history is prepended (that would yank the reader away).
|
||||
var prevLastId by remember { mutableStateOf<String?>(null) }
|
||||
val lastId = state.messages.lastOrNull()?.id
|
||||
LaunchedEffect(lastId) {
|
||||
if (lastId == null) return@LaunchedEffect
|
||||
val lastIndex = state.messages.lastIndex
|
||||
val lastVisible = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0
|
||||
if (prevLastId == null || lastVisible >= lastIndex - 2) {
|
||||
listState.animateScrollToItem(lastIndex)
|
||||
}
|
||||
prevLastId = lastId
|
||||
}
|
||||
|
||||
// Load older messages when the user scrolls to the very top.
|
||||
val atTop by remember { derivedStateOf { listState.firstVisibleItemIndex == 0 } }
|
||||
LaunchedEffect(atTop, state.canLoadMore) {
|
||||
if (atTop && state.canLoadMore) viewModel.loadOlderMessages()
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
containerColor = Color.Transparent,
|
||||
modifier = Modifier.background(closerBackgroundBrush()),
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
|
|
@ -96,32 +139,109 @@ fun ConversationScreen(
|
|||
contentPadding = PaddingValues(horizontal = 14.dp, vertical = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
val lastOwnIndex = state.messages.indexOfLast { it.userId == viewModel.currentUserId }
|
||||
itemsIndexed(state.messages, key = { _, m -> m.id }) { index, message ->
|
||||
val isMe = message.userId == viewModel.currentUserId
|
||||
val showAvatar = index == state.messages.lastIndex ||
|
||||
val isLastInRun = index == state.messages.lastIndex ||
|
||||
state.messages[index + 1].userId != message.userId
|
||||
val prev = state.messages.getOrNull(index - 1)
|
||||
if (prev == null || !isSameChatDay(prev.createdAt, message.createdAt)) {
|
||||
ChatDaySeparator(message.createdAt)
|
||||
}
|
||||
val seen = isMe && index == lastOwnIndex &&
|
||||
message.createdAt > 0 && state.partnerReadAt >= message.createdAt
|
||||
ChatMessageRow(
|
||||
message = message,
|
||||
isCurrentUser = isMe,
|
||||
partnerAvatarUrl = state.partnerPhotoUrl,
|
||||
showAvatar = showAvatar,
|
||||
showAvatar = isLastInRun,
|
||||
showTimestamp = isLastInRun,
|
||||
showSeen = seen,
|
||||
canReact = state.canSendMedia,
|
||||
onReact = { emoji -> viewModel.react(message.id, emoji) },
|
||||
onReactBlocked = routeToPaywall,
|
||||
onDelete = { viewModel.deleteMessage(message.id) },
|
||||
loadDecryptedMedia = viewModel::loadDecryptedMedia
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (state.partnerTyping) {
|
||||
Text(
|
||||
text = "typing…",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
|
||||
modifier = Modifier.padding(start = 20.dp, bottom = 2.dp)
|
||||
)
|
||||
}
|
||||
|
||||
state.pendingMedia.forEach { pm ->
|
||||
PendingMediaChip(
|
||||
pending = pm,
|
||||
onRetry = { viewModel.retryMedia(pm.id) },
|
||||
onDismiss = { viewModel.dismissPending(pm.id) }
|
||||
)
|
||||
}
|
||||
|
||||
ChatComposer(
|
||||
value = state.messageInput,
|
||||
onValueChange = viewModel::updateMessageInput,
|
||||
onSend = viewModel::sendMessage,
|
||||
onSendImage = viewModel::sendImage,
|
||||
onSendVoice = viewModel::sendVoice,
|
||||
canSendMedia = state.canSendMedia,
|
||||
onUpgrade = routeToPaywall,
|
||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 6.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PendingMediaChip(
|
||||
pending: PendingMedia,
|
||||
onRetry: () -> Unit,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
val label = if (pending.type == "voice") "voice note" else "photo"
|
||||
Surface(
|
||||
shape = RoundedCornerShape(20.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 14.dp, vertical = 2.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
if (pending.failed) {
|
||||
Text(
|
||||
text = "Couldn't send $label",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
IconButton(onClick = onRetry, modifier = Modifier.size(28.dp)) {
|
||||
Icon(Icons.Filled.Refresh, contentDescription = "Retry", tint = MaterialTheme.colorScheme.primary)
|
||||
}
|
||||
IconButton(onClick = onDismiss, modifier = Modifier.size(28.dp)) {
|
||||
Icon(Icons.Filled.Close, contentDescription = "Dismiss", tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
} else {
|
||||
CircularProgressIndicator(
|
||||
strokeWidth = 2.dp,
|
||||
modifier = Modifier.size(16.dp),
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Text(
|
||||
text = "Sending $label…",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ConversationAvatar(url: String?) {
|
||||
val size = 32.dp
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ package app.closer.ui.messages
|
|||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import app.closer.core.analytics.AnalyticsTracker
|
||||
import app.closer.core.billing.CouplePremiumChecker
|
||||
import app.closer.data.local.QuestionDao
|
||||
import app.closer.data.local.mapper.toQuestion
|
||||
import app.closer.domain.model.QuestionMessage
|
||||
|
|
@ -12,21 +14,42 @@ import app.closer.domain.repository.UserRepository
|
|||
import app.closer.notifications.ActiveThreadMonitor
|
||||
import com.google.firebase.auth.FirebaseAuth
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
|
||||
/** A media send in flight (or failed) — shown as a chip above the composer, not in the message list. */
|
||||
data class PendingMedia(val id: String, val type: String, val failed: Boolean = false)
|
||||
|
||||
data class ConversationUiState(
|
||||
val title: String = "",
|
||||
val partnerPhotoUrl: String? = null,
|
||||
val messages: List<QuestionMessage> = emptyList(),
|
||||
val pendingMedia: List<PendingMedia> = emptyList(),
|
||||
val canLoadMore: Boolean = false,
|
||||
val partnerReadAt: Long = 0L,
|
||||
val partnerTyping: Boolean = false,
|
||||
/** Couple-shared premium: gates SENDING media + reactions (false until confirmed). */
|
||||
val canSendMedia: Boolean = false,
|
||||
val messageInput: String = "",
|
||||
val isLoading: Boolean = true
|
||||
)
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@HiltViewModel
|
||||
class ConversationViewModel @Inject constructor(
|
||||
private val repository: ConversationRepository,
|
||||
|
|
@ -34,6 +57,8 @@ class ConversationViewModel @Inject constructor(
|
|||
private val userRepository: UserRepository,
|
||||
private val questionDao: QuestionDao,
|
||||
private val activeThreadMonitor: ActiveThreadMonitor,
|
||||
private val couplePremiumChecker: CouplePremiumChecker,
|
||||
private val analyticsTracker: AnalyticsTracker,
|
||||
savedStateHandle: SavedStateHandle
|
||||
) : ViewModel() {
|
||||
|
||||
|
|
@ -44,6 +69,15 @@ class ConversationViewModel @Inject constructor(
|
|||
private val _uiState = MutableStateFlow(ConversationUiState())
|
||||
val uiState: StateFlow<ConversationUiState> = _uiState.asStateFlow()
|
||||
|
||||
/** One-shot, non-sticky messages for the snackbar (send failures). */
|
||||
private val _events = MutableSharedFlow<String>(extraBufferCapacity = 4)
|
||||
val events: SharedFlow<String> = _events.asSharedFlow()
|
||||
|
||||
private val messageLimit = MutableStateFlow(PAGE_SIZE)
|
||||
|
||||
private data class RetryData(val type: String, val bytes: ByteArray, val durationMs: Long)
|
||||
private val retryStore = mutableMapOf<String, RetryData>()
|
||||
|
||||
init {
|
||||
// Reading this conversation suppresses its bubble + clears its unread.
|
||||
activeThreadMonitor.enter(conversationId)
|
||||
|
|
@ -53,6 +87,7 @@ class ConversationViewModel @Inject constructor(
|
|||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
activeThreadMonitor.leave(conversationId)
|
||||
stopTyping() // fire-and-forget; clears our typing flag when leaving
|
||||
}
|
||||
|
||||
private fun load() {
|
||||
|
|
@ -78,48 +113,161 @@ class ConversationViewModel @Inject constructor(
|
|||
it.copy(title = title, partnerPhotoUrl = partner?.photoUrl, isLoading = false)
|
||||
}
|
||||
|
||||
// Couple-shared premium gate for sending media + reactions.
|
||||
launch {
|
||||
couplePremiumChecker.coupleHasPremium(partnerId)
|
||||
.collect { premium -> _uiState.update { it.copy(canSendMedia = premium) } }
|
||||
}
|
||||
|
||||
runCatching { repository.markRead(coupleId, conversationId, currentUserId) }
|
||||
|
||||
launch {
|
||||
repository.observeMessages(coupleId, conversationId).collect { msgs ->
|
||||
_uiState.update { it.copy(messages = msgs) }
|
||||
runCatching { repository.markRead(coupleId, conversationId, currentUserId) }
|
||||
}
|
||||
messageLimit
|
||||
.flatMapLatest { limit -> repository.observeMessages(coupleId, conversationId, limit) }
|
||||
.collect { msgs ->
|
||||
_uiState.update { it.copy(messages = msgs, canLoadMore = msgs.size >= messageLimit.value) }
|
||||
runCatching { repository.markRead(coupleId, conversationId, currentUserId) }
|
||||
}
|
||||
}
|
||||
|
||||
launch {
|
||||
repository.observePartnerReadAt(coupleId, conversationId, currentUserId)
|
||||
.collect { readAt -> _uiState.update { it.copy(partnerReadAt = readAt) } }
|
||||
}
|
||||
|
||||
// Partner typing: combine the typing timestamp with a ticker so it auto-hides after ~6s
|
||||
// even if the partner's "stop" write never arrives (crash safety net).
|
||||
launch {
|
||||
combine(
|
||||
repository.observePartnerTyping(coupleId, conversationId, currentUserId),
|
||||
flow { while (true) { emit(Unit); delay(2000) } }
|
||||
) { typingAt, _ -> typingAt > 0 && System.currentTimeMillis() - typingAt < TYPING_TTL_MS }
|
||||
.distinctUntilChanged()
|
||||
.collect { typing -> _uiState.update { it.copy(partnerTyping = typing) } }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var typingStopJob: Job? = null
|
||||
private var lastTypingPingAt = 0L
|
||||
|
||||
private fun onUserTyping() {
|
||||
val now = System.currentTimeMillis()
|
||||
if (now - lastTypingPingAt > 1500) {
|
||||
lastTypingPingAt = now
|
||||
repository.setTyping(coupleId, conversationId, currentUserId, true)
|
||||
}
|
||||
typingStopJob?.cancel()
|
||||
typingStopJob = viewModelScope.launch {
|
||||
delay(3000)
|
||||
stopTyping()
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopTyping() {
|
||||
typingStopJob?.cancel()
|
||||
lastTypingPingAt = 0L
|
||||
repository.setTyping(coupleId, conversationId, currentUserId, false)
|
||||
}
|
||||
|
||||
/** Grow the live window by another page (triggered when scrolled to the top). */
|
||||
fun loadOlderMessages() {
|
||||
if (_uiState.value.canLoadMore) messageLimit.update { it + PAGE_SIZE }
|
||||
}
|
||||
|
||||
fun updateMessageInput(text: String) {
|
||||
_uiState.update { it.copy(messageInput = text.take(MAX_MESSAGE_LENGTH)) }
|
||||
if (text.isNotBlank()) onUserTyping()
|
||||
}
|
||||
|
||||
fun sendMessage() {
|
||||
val text = _uiState.value.messageInput.trim()
|
||||
if (text.isBlank() || currentUserId.isEmpty()) return
|
||||
_uiState.update { it.copy(messageInput = "") }
|
||||
stopTyping()
|
||||
viewModelScope.launch {
|
||||
runCatching { repository.sendMessage(coupleId, conversationId, currentUserId, text) }
|
||||
.onFailure { _events.tryEmit("Couldn't send message. Check your connection.") }
|
||||
}
|
||||
}
|
||||
|
||||
fun sendImage(imageBytes: ByteArray) {
|
||||
if (currentUserId.isEmpty() || imageBytes.isEmpty()) return
|
||||
val id = UUID.randomUUID().toString()
|
||||
retryStore[id] = RetryData("image", imageBytes, 0L)
|
||||
addPending(PendingMedia(id, "image"))
|
||||
viewModelScope.launch {
|
||||
runCatching { repository.sendImageMessage(coupleId, conversationId, currentUserId, imageBytes) }
|
||||
.onSuccess { removePending(id); retryStore.remove(id) }
|
||||
.onFailure { markPendingFailed(id); _events.tryEmit("Couldn't send photo. Tap retry.") }
|
||||
}
|
||||
}
|
||||
|
||||
fun sendVoice(audioBytes: ByteArray, durationMs: Long) {
|
||||
if (currentUserId.isEmpty() || audioBytes.isEmpty()) return
|
||||
val id = UUID.randomUUID().toString()
|
||||
retryStore[id] = RetryData("voice", audioBytes, durationMs)
|
||||
addPending(PendingMedia(id, "voice"))
|
||||
viewModelScope.launch {
|
||||
runCatching { repository.sendVoiceMessage(coupleId, conversationId, currentUserId, audioBytes, durationMs) }
|
||||
.onSuccess { removePending(id); retryStore.remove(id) }
|
||||
.onFailure { markPendingFailed(id); _events.tryEmit("Couldn't send voice note. Tap retry.") }
|
||||
}
|
||||
}
|
||||
|
||||
/** Toggle the caller's emoji reaction on a message (tap the same emoji again to remove). */
|
||||
fun react(messageId: String, emoji: String) {
|
||||
if (currentUserId.isEmpty()) return
|
||||
val current = _uiState.value.messages.firstOrNull { it.id == messageId }?.reactions?.get(currentUserId)
|
||||
val next = if (current == emoji) null else emoji
|
||||
viewModelScope.launch {
|
||||
runCatching { repository.setReaction(coupleId, conversationId, messageId, currentUserId, next) }
|
||||
.onFailure { _events.tryEmit("Couldn't add reaction.") }
|
||||
}
|
||||
}
|
||||
|
||||
/** Called when a gated media/reaction action routes the user to the paywall. */
|
||||
fun onMediaPaywallShown() {
|
||||
analyticsTracker.trackPaywallViewed("chat_media")
|
||||
}
|
||||
|
||||
fun deleteMessage(messageId: String) {
|
||||
viewModelScope.launch {
|
||||
runCatching { repository.deleteMessage(coupleId, conversationId, messageId) }
|
||||
.onFailure { _events.tryEmit("Couldn't delete message.") }
|
||||
}
|
||||
}
|
||||
|
||||
fun retryMedia(id: String) {
|
||||
val data = retryStore[id] ?: return
|
||||
removePending(id)
|
||||
retryStore.remove(id)
|
||||
when (data.type) {
|
||||
"image" -> sendImage(data.bytes)
|
||||
"voice" -> sendVoice(data.bytes, data.durationMs)
|
||||
}
|
||||
}
|
||||
|
||||
fun dismissPending(id: String) {
|
||||
removePending(id)
|
||||
retryStore.remove(id)
|
||||
}
|
||||
|
||||
suspend fun loadDecryptedMedia(mediaUrl: String): ByteArray? =
|
||||
repository.loadDecryptedMedia(coupleId, mediaUrl)
|
||||
|
||||
private fun addPending(p: PendingMedia) =
|
||||
_uiState.update { it.copy(pendingMedia = it.pendingMedia + p) }
|
||||
|
||||
private fun removePending(id: String) =
|
||||
_uiState.update { it.copy(pendingMedia = it.pendingMedia.filterNot { m -> m.id == id }) }
|
||||
|
||||
private fun markPendingFailed(id: String) =
|
||||
_uiState.update { it.copy(pendingMedia = it.pendingMedia.map { m -> if (m.id == id) m.copy(failed = true) else m }) }
|
||||
|
||||
companion object {
|
||||
const val MAX_MESSAGE_LENGTH = 2000
|
||||
const val PAGE_SIZE = 50
|
||||
private const val TYPING_TTL_MS = 6000L
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,10 +10,13 @@ import androidx.activity.compose.rememberLauncherForActivityResult
|
|||
import androidx.activity.result.PickVisualMediaRequest
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
|
|
@ -29,15 +32,21 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
|||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.Send
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.ContentCopy
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.Image
|
||||
import androidx.compose.material.icons.filled.Lock
|
||||
import androidx.compose.material.icons.filled.Mic
|
||||
import androidx.compose.material.icons.filled.Pause
|
||||
import androidx.compose.material.icons.filled.Person
|
||||
import androidx.compose.material.icons.filled.PhotoCamera
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
|
|
@ -57,12 +66,15 @@ import androidx.compose.ui.draw.clip
|
|||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.content.FileProvider
|
||||
import app.closer.core.media.MediaCompressor
|
||||
import app.closer.domain.model.QuestionMessage
|
||||
import coil.ImageLoader
|
||||
import coil.compose.AsyncImage
|
||||
|
|
@ -80,12 +92,21 @@ import java.nio.ByteBuffer
|
|||
* One chat row — Messenger style: only the partner's avatar shows (on the left); our own messages
|
||||
* are bubbles on the right with no avatar. Handles text, encrypted image/GIF, and voice messages.
|
||||
*/
|
||||
val REACTION_EMOJIS = listOf("❤️", "😂", "👍", "😮", "😢", "🔥")
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun ChatMessageRow(
|
||||
message: QuestionMessage,
|
||||
isCurrentUser: Boolean,
|
||||
partnerAvatarUrl: String?,
|
||||
showAvatar: Boolean,
|
||||
showTimestamp: Boolean,
|
||||
showSeen: Boolean = false,
|
||||
canReact: Boolean = true,
|
||||
onReact: (String) -> Unit = {},
|
||||
onReactBlocked: () -> Unit = {},
|
||||
onDelete: () -> Unit = {},
|
||||
loadDecryptedMedia: suspend (String) -> ByteArray?
|
||||
) {
|
||||
val bubbleShape = if (isCurrentUser) {
|
||||
|
|
@ -93,35 +114,175 @@ fun ChatMessageRow(
|
|||
} else {
|
||||
RoundedCornerShape(topStart = 4.dp, topEnd = 16.dp, bottomStart = 16.dp, bottomEnd = 16.dp)
|
||||
}
|
||||
var menuOpen by remember { mutableStateOf(false) }
|
||||
val clipboard = LocalClipboardManager.current
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = if (isCurrentUser) Arrangement.End else Arrangement.Start,
|
||||
verticalAlignment = Alignment.Bottom
|
||||
) {
|
||||
if (!isCurrentUser) {
|
||||
ChatAvatar(partnerAvatarUrl, visible = showAvatar)
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
}
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = if (isCurrentUser) Arrangement.End else Arrangement.Start,
|
||||
verticalAlignment = Alignment.Bottom
|
||||
) {
|
||||
if (!isCurrentUser) {
|
||||
ChatAvatar(partnerAvatarUrl, visible = showAvatar)
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
}
|
||||
|
||||
when {
|
||||
message.isImage -> EncryptedChatImage(message.mediaUrl, bubbleShape, loadDecryptedMedia)
|
||||
message.isVoice -> EncryptedVoiceMessage(message.mediaUrl, message.durationMs, isCurrentUser, bubbleShape, loadDecryptedMedia)
|
||||
else -> Surface(
|
||||
shape = bubbleShape,
|
||||
color = if (isCurrentUser) MaterialTheme.colorScheme.primary
|
||||
else MaterialTheme.colorScheme.surfaceVariant,
|
||||
modifier = Modifier.widthIn(max = 264.dp)
|
||||
Box(
|
||||
modifier = if (message.deleted) Modifier
|
||||
else Modifier.combinedClickable(onClick = {}, onLongClick = { menuOpen = true })
|
||||
) {
|
||||
Text(
|
||||
text = message.text,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = if (isCurrentUser) MaterialTheme.colorScheme.onPrimary
|
||||
else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 14.dp, vertical = 9.dp)
|
||||
when {
|
||||
message.deleted -> DeletedBubble(bubbleShape, isCurrentUser)
|
||||
message.isImage -> EncryptedChatImage(message.mediaUrl, bubbleShape, loadDecryptedMedia)
|
||||
message.isVoice -> EncryptedVoiceMessage(message.mediaUrl, message.durationMs, isCurrentUser, bubbleShape, loadDecryptedMedia)
|
||||
else -> Surface(
|
||||
shape = bubbleShape,
|
||||
color = if (isCurrentUser) MaterialTheme.colorScheme.primary
|
||||
else MaterialTheme.colorScheme.surfaceVariant,
|
||||
modifier = Modifier.widthIn(max = 264.dp)
|
||||
) {
|
||||
Text(
|
||||
text = message.text,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = if (isCurrentUser) MaterialTheme.colorScheme.onPrimary
|
||||
else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 14.dp, vertical = 9.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
MessageActionMenu(
|
||||
expanded = menuOpen,
|
||||
onDismiss = { menuOpen = false },
|
||||
canCopy = message.type == "text",
|
||||
canDelete = isCurrentUser,
|
||||
onReact = { emoji ->
|
||||
menuOpen = false
|
||||
if (canReact) onReact(emoji) else onReactBlocked()
|
||||
},
|
||||
onCopy = {
|
||||
menuOpen = false
|
||||
clipboard.setText(AnnotatedString(message.text))
|
||||
},
|
||||
onDelete = { menuOpen = false; onDelete() }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (message.reactions.isNotEmpty() && !message.deleted) {
|
||||
ReactionChip(
|
||||
emojis = message.reactions.values.toList(),
|
||||
modifier = Modifier
|
||||
.align(if (isCurrentUser) Alignment.End else Alignment.Start)
|
||||
.padding(start = if (isCurrentUser) 0.dp else 40.dp, end = if (isCurrentUser) 6.dp else 0.dp, top = 2.dp)
|
||||
)
|
||||
}
|
||||
|
||||
if (showTimestamp) {
|
||||
val time = formatClockTime(message.createdAt)
|
||||
Text(
|
||||
text = if (showSeen) "Seen · $time" else time,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.55f),
|
||||
modifier = Modifier
|
||||
.align(if (isCurrentUser) Alignment.End else Alignment.Start)
|
||||
.padding(start = if (isCurrentUser) 0.dp else 40.dp, end = if (isCurrentUser) 6.dp else 0.dp, top = 2.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DeletedBubble(shape: Shape, isCurrentUser: Boolean) {
|
||||
Surface(
|
||||
shape = shape,
|
||||
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
|
||||
modifier = Modifier.widthIn(max = 264.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "This message was deleted",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontStyle = androidx.compose.ui.text.font.FontStyle.Italic,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
|
||||
modifier = Modifier.padding(horizontal = 14.dp, vertical = 9.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ReactionChip(emojis: List<String>, modifier: Modifier = Modifier) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
modifier = modifier
|
||||
) {
|
||||
Text(
|
||||
text = emojis.distinct().joinToString("") + if (emojis.size > 1) " ${emojis.size}" else "",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MessageActionMenu(
|
||||
expanded: Boolean,
|
||||
onDismiss: () -> Unit,
|
||||
canCopy: Boolean,
|
||||
canDelete: Boolean,
|
||||
onReact: (String) -> Unit,
|
||||
onCopy: () -> Unit,
|
||||
onDelete: () -> Unit
|
||||
) {
|
||||
DropdownMenu(expanded = expanded, onDismissRequest = onDismiss) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(2.dp)
|
||||
) {
|
||||
REACTION_EMOJIS.forEach { emoji ->
|
||||
Text(
|
||||
text = emoji,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
modifier = Modifier
|
||||
.clip(CircleShape)
|
||||
.clickable { onReact(emoji) }
|
||||
.padding(6.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
if (canCopy) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("Copy") },
|
||||
onClick = onCopy,
|
||||
leadingIcon = { Icon(Icons.Filled.ContentCopy, contentDescription = null) }
|
||||
)
|
||||
}
|
||||
if (canDelete) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("Delete") },
|
||||
onClick = onDelete,
|
||||
leadingIcon = { Icon(Icons.Filled.Delete, contentDescription = null) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A centered date pill shown between messages from different days. */
|
||||
@Composable
|
||||
fun ChatDaySeparator(epochMillis: Long) {
|
||||
Box(modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp), contentAlignment = Alignment.Center) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.7f)
|
||||
) {
|
||||
Text(
|
||||
text = chatDayLabel(epochMillis),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 3.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -250,6 +411,8 @@ private fun EncryptedVoiceMessage(
|
|||
var tempFile by remember { mutableStateOf<File?>(null) }
|
||||
var playing by remember { mutableStateOf(false) }
|
||||
var loading by remember { mutableStateOf(false) }
|
||||
var positionMs by remember { mutableLongStateOf(0L) }
|
||||
val totalMs = durationMs.coerceAtLeast(1L)
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
|
|
@ -258,6 +421,14 @@ private fun EncryptedVoiceMessage(
|
|||
}
|
||||
}
|
||||
|
||||
// Poll the player position so the progress bar advances during playback.
|
||||
LaunchedEffect(playing) {
|
||||
while (playing) {
|
||||
positionMs = player?.currentPosition?.toLong() ?: positionMs
|
||||
delay(100)
|
||||
}
|
||||
}
|
||||
|
||||
fun toggle() {
|
||||
val p = player
|
||||
if (p != null) {
|
||||
|
|
@ -276,7 +447,7 @@ private fun EncryptedVoiceMessage(
|
|||
val mp = MediaPlayer()
|
||||
runCatching {
|
||||
mp.setDataSource(f.absolutePath)
|
||||
mp.setOnCompletionListener { playing = false; runCatching { mp.seekTo(0) } }
|
||||
mp.setOnCompletionListener { playing = false; positionMs = 0L; runCatching { mp.seekTo(0) } }
|
||||
mp.prepare()
|
||||
mp.start()
|
||||
}.onSuccess { player = mp; playing = true }.onFailure { mp.release() }
|
||||
|
|
@ -306,8 +477,17 @@ private fun EncryptedVoiceMessage(
|
|||
)
|
||||
}
|
||||
}
|
||||
Icon(Icons.Filled.Mic, contentDescription = null, tint = tint.copy(alpha = 0.7f), modifier = Modifier.size(16.dp))
|
||||
Text(text = formatDuration(durationMs), style = MaterialTheme.typography.bodyMedium, color = tint)
|
||||
LinearProgressIndicator(
|
||||
progress = { if (totalMs > 0) (positionMs.toFloat() / totalMs).coerceIn(0f, 1f) else 0f },
|
||||
modifier = Modifier.width(92.dp),
|
||||
color = tint,
|
||||
trackColor = tint.copy(alpha = 0.25f)
|
||||
)
|
||||
Text(
|
||||
text = formatDuration(if (playing || positionMs > 0) positionMs else durationMs),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = tint
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -347,15 +527,20 @@ fun ChatComposer(
|
|||
onSend: () -> Unit,
|
||||
onSendImage: (ByteArray) -> Unit,
|
||||
onSendVoice: (ByteArray, Long) -> Unit,
|
||||
canSendMedia: Boolean = true,
|
||||
onUpgrade: () -> Unit = {},
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
fun readAndSend(uri: Uri) {
|
||||
// compress = true for real photos (gallery/camera); false for keyboard GIFs/stickers/Bitmoji
|
||||
// (compressing those would kill animation/transparency).
|
||||
fun readAndSend(uri: Uri, compress: Boolean) {
|
||||
scope.launch {
|
||||
val bytes = withContext(Dispatchers.IO) {
|
||||
runCatching { context.contentResolver.openInputStream(uri)?.use { it.readBytes() } }.getOrNull()
|
||||
val raw = runCatching { context.contentResolver.openInputStream(uri)?.use { it.readBytes() } }.getOrNull()
|
||||
raw?.let { if (compress) MediaCompressor.compressPhoto(it) else it }
|
||||
}
|
||||
bytes?.takeIf { it.isNotEmpty() }?.let(onSendImage)
|
||||
}
|
||||
|
|
@ -363,12 +548,12 @@ fun ChatComposer(
|
|||
|
||||
val galleryLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.PickVisualMedia()
|
||||
) { uri: Uri? -> uri?.let { readAndSend(it) } }
|
||||
) { uri: Uri? -> uri?.let { readAndSend(it, compress = true) } }
|
||||
|
||||
var pendingCameraUri by remember { mutableStateOf<Uri?>(null) }
|
||||
val cameraLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.TakePicture()
|
||||
) { success: Boolean -> if (success) pendingCameraUri?.let { readAndSend(it) } }
|
||||
) { success: Boolean -> if (success) pendingCameraUri?.let { readAndSend(it, compress = true) } }
|
||||
|
||||
fun launchCamera() {
|
||||
val file = File(context.cacheDir, "chat_capture_${System.currentTimeMillis()}.jpg")
|
||||
|
|
@ -427,7 +612,11 @@ fun ChatComposer(
|
|||
) { granted: Boolean -> if (granted) startRecording() }
|
||||
|
||||
LaunchedEffect(isRecording) {
|
||||
while (isRecording) { elapsedMs = System.currentTimeMillis() - recordStart; delay(200) }
|
||||
while (isRecording) {
|
||||
elapsedMs = System.currentTimeMillis() - recordStart
|
||||
if (elapsedMs >= MAX_RECORDING_MS) { finishRecording(send = true); break } // auto-send at the cap
|
||||
delay(200)
|
||||
}
|
||||
}
|
||||
DisposableEffect(Unit) {
|
||||
onDispose { runCatching { recorder.value?.release() }; recordFile.value?.delete() }
|
||||
|
|
@ -461,21 +650,23 @@ fun ChatComposer(
|
|||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(2.dp)
|
||||
) {
|
||||
IconButton(
|
||||
ComposerMediaButton(
|
||||
icon = Icons.Filled.Image,
|
||||
description = "Send a photo",
|
||||
locked = !canSendMedia,
|
||||
onClick = { galleryLauncher.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)) },
|
||||
modifier = Modifier.size(40.dp)
|
||||
) {
|
||||
Icon(Icons.Filled.Image, contentDescription = "Send a photo", tint = MaterialTheme.colorScheme.primary)
|
||||
}
|
||||
IconButton(
|
||||
onUpgrade = onUpgrade
|
||||
)
|
||||
ComposerMediaButton(
|
||||
icon = Icons.Filled.PhotoCamera,
|
||||
description = "Take a photo",
|
||||
locked = !canSendMedia,
|
||||
onClick = {
|
||||
if (ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED)
|
||||
launchCamera() else cameraPermissionLauncher.launch(Manifest.permission.CAMERA)
|
||||
},
|
||||
modifier = Modifier.size(40.dp)
|
||||
) {
|
||||
Icon(Icons.Filled.PhotoCamera, contentDescription = "Take a photo", tint = MaterialTheme.colorScheme.primary)
|
||||
}
|
||||
onUpgrade = onUpgrade
|
||||
)
|
||||
|
||||
// Field that accepts GIFs / stickers / Bitmoji inserted from the keyboard (rich content).
|
||||
Surface(
|
||||
|
|
@ -487,23 +678,26 @@ fun ChatComposer(
|
|||
RichContentTextField(
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
onImageReceived = { uri -> readAndSend(uri) },
|
||||
// Keyboard GIF/sticker/Bitmoji: gated → route to paywall instead of sending.
|
||||
onImageReceived = { uri -> if (canSendMedia) readAndSend(uri, compress = false) else onUpgrade() },
|
||||
modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp),
|
||||
hint = "Message…"
|
||||
)
|
||||
}
|
||||
|
||||
if (value.isBlank()) {
|
||||
// Mic when there's nothing typed (tap to record a voice note).
|
||||
IconButton(
|
||||
// Mic when there's nothing typed (tap to record a voice note) — gated behind premium.
|
||||
ComposerMediaButton(
|
||||
icon = Icons.Filled.Mic,
|
||||
description = "Record a voice note",
|
||||
locked = !canSendMedia,
|
||||
size = 48.dp,
|
||||
onClick = {
|
||||
if (ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED)
|
||||
startRecording() else micPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
|
||||
},
|
||||
modifier = Modifier.size(48.dp)
|
||||
) {
|
||||
Icon(Icons.Filled.Mic, contentDescription = "Record a voice note", tint = MaterialTheme.colorScheme.primary)
|
||||
}
|
||||
onUpgrade = onUpgrade
|
||||
)
|
||||
} else {
|
||||
IconButton(onClick = onSend, modifier = Modifier.size(48.dp)) {
|
||||
Icon(Icons.AutoMirrored.Filled.Send, contentDescription = "Send", tint = MaterialTheme.colorScheme.primary)
|
||||
|
|
@ -512,9 +706,72 @@ fun ChatComposer(
|
|||
}
|
||||
}
|
||||
|
||||
/** Cap voice notes at 2 minutes; recording auto-stops and sends at this length. */
|
||||
private const val MAX_RECORDING_MS = 120_000L
|
||||
|
||||
/** A composer media button with a lock badge + paywall routing when the couple isn't premium. */
|
||||
@Composable
|
||||
private fun ComposerMediaButton(
|
||||
icon: androidx.compose.ui.graphics.vector.ImageVector,
|
||||
description: String,
|
||||
locked: Boolean,
|
||||
onClick: () -> Unit,
|
||||
onUpgrade: () -> Unit,
|
||||
size: androidx.compose.ui.unit.Dp = 40.dp
|
||||
) {
|
||||
Box {
|
||||
IconButton(onClick = { if (locked) onUpgrade() else onClick() }, modifier = Modifier.size(size)) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = description,
|
||||
tint = if (locked) MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.45f)
|
||||
else MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
if (locked) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Lock,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(12.dp).align(Alignment.BottomEnd)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatDuration(ms: Long): String {
|
||||
val totalSec = (ms / 1000).toInt()
|
||||
val m = totalSec / 60
|
||||
val s = totalSec % 60
|
||||
return "%d:%02d".format(m, s)
|
||||
}
|
||||
|
||||
/** "3:45 PM" — falls back to now for a just-sent message whose server timestamp hasn't resolved. */
|
||||
private fun formatClockTime(ms: Long): String {
|
||||
val t = if (ms > 0) ms else System.currentTimeMillis()
|
||||
return java.text.SimpleDateFormat("h:mm a", java.util.Locale.getDefault()).format(java.util.Date(t))
|
||||
}
|
||||
|
||||
/** "Today" / "Yesterday" / "March 4" for day separators. */
|
||||
fun chatDayLabel(ms: Long): String {
|
||||
val t = if (ms > 0) ms else System.currentTimeMillis()
|
||||
val cal = java.util.Calendar.getInstance().apply { timeInMillis = t }
|
||||
val today = java.util.Calendar.getInstance()
|
||||
val yesterday = (today.clone() as java.util.Calendar).apply { add(java.util.Calendar.DAY_OF_YEAR, -1) }
|
||||
return when {
|
||||
isSameCalendarDay(cal, today) -> "Today"
|
||||
isSameCalendarDay(cal, yesterday) -> "Yesterday"
|
||||
else -> java.text.SimpleDateFormat("MMMM d", java.util.Locale.getDefault()).format(java.util.Date(t))
|
||||
}
|
||||
}
|
||||
|
||||
/** True when two epoch millis fall on the same calendar day (0 is treated as now). */
|
||||
fun isSameChatDay(a: Long, b: Long): Boolean {
|
||||
val ca = java.util.Calendar.getInstance().apply { timeInMillis = if (a > 0) a else System.currentTimeMillis() }
|
||||
val cb = java.util.Calendar.getInstance().apply { timeInMillis = if (b > 0) b else System.currentTimeMillis() }
|
||||
return isSameCalendarDay(ca, cb)
|
||||
}
|
||||
|
||||
private fun isSameCalendarDay(a: java.util.Calendar, b: java.util.Calendar): Boolean =
|
||||
a.get(java.util.Calendar.YEAR) == b.get(java.util.Calendar.YEAR) &&
|
||||
a.get(java.util.Calendar.DAY_OF_YEAR) == b.get(java.util.Calendar.DAY_OF_YEAR)
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ package app.closer.ui.play
|
|||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import app.closer.core.billing.EntitlementChecker
|
||||
import app.closer.core.billing.CouplePremiumChecker
|
||||
import app.closer.domain.usecase.GameSessionManager
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import javax.inject.Inject
|
||||
|
|
@ -15,10 +15,10 @@ import kotlinx.coroutines.launch
|
|||
|
||||
@HiltViewModel
|
||||
class PlayHubViewModel @Inject constructor(
|
||||
entitlementChecker: EntitlementChecker,
|
||||
premiumChecker: CouplePremiumChecker,
|
||||
private val gameSessionManager: GameSessionManager
|
||||
) : ViewModel() {
|
||||
val hasPremium: StateFlow<Boolean> = entitlementChecker.isPremium()
|
||||
val hasPremium: StateFlow<Boolean> = premiumChecker.isPremium()
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false)
|
||||
|
||||
// Default true so paired users never see an invite redirect flash while this loads.
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ package app.closer.ui.questions
|
|||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import app.closer.core.billing.EntitlementChecker
|
||||
import app.closer.core.billing.CouplePremiumChecker
|
||||
import app.closer.domain.model.QuestionCategory
|
||||
import app.closer.domain.repository.QuestionRepository
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
|
|
@ -29,7 +29,7 @@ data class QuestionPackLibraryUiState(
|
|||
@HiltViewModel
|
||||
class QuestionPackLibraryViewModel @Inject constructor(
|
||||
private val repository: QuestionRepository,
|
||||
private val entitlementChecker: EntitlementChecker
|
||||
private val premiumChecker: CouplePremiumChecker
|
||||
) : ViewModel() {
|
||||
|
||||
private val _uiState = MutableStateFlow(QuestionPackLibraryUiState())
|
||||
|
|
@ -43,7 +43,7 @@ class QuestionPackLibraryViewModel @Inject constructor(
|
|||
viewModelScope.launch {
|
||||
_uiState.value = QuestionPackLibraryUiState(isLoading = true)
|
||||
try {
|
||||
val hasPremium = entitlementChecker.isPremium().first()
|
||||
val hasPremium = premiumChecker.isPremium().first()
|
||||
val packs = repository.getCategories().map { category ->
|
||||
val isPremium = category.access == "premium"
|
||||
QuestionPackItem(
|
||||
|
|
|
|||
|
|
@ -46,7 +46,6 @@ import app.closer.ui.components.ErrorState
|
|||
import app.closer.ui.components.LoadingState
|
||||
import app.closer.ui.questions.components.AnswerBubble
|
||||
import app.closer.ui.questions.components.QuestionAnswerInput
|
||||
import app.closer.ui.questions.components.QuestionDiscussionThread
|
||||
import app.closer.ui.questions.components.QuestionHeader
|
||||
import app.closer.ui.theme.closerBackgroundBrush
|
||||
|
||||
|
|
@ -235,17 +234,19 @@ private fun RevealedPhase(
|
|||
)
|
||||
}
|
||||
|
||||
QuestionDiscussionThread(
|
||||
messages = state.messages,
|
||||
currentUserId = viewModel.currentUserId,
|
||||
partnerPhotoUrl = state.partnerPhotoUrl,
|
||||
messageInput = state.messageInput,
|
||||
onMessageInputChanged = viewModel::updateMessageInput,
|
||||
onSendMessage = viewModel::sendMessage,
|
||||
isRevealed = true,
|
||||
onSendImage = viewModel::sendImage,
|
||||
loadDecryptedMedia = viewModel::loadDecryptedMedia
|
||||
)
|
||||
// Discussion now lives in the unified Messages conversation (q_<questionId>) so it's notified
|
||||
// and appears in the inbox — same as the daily flow.
|
||||
Button(
|
||||
onClick = { onNavigate(AppRoute.conversation(coupleId, "q_$questionId")) },
|
||||
modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.secondaryContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onSecondaryContainer
|
||||
)
|
||||
) {
|
||||
Text("Discuss this together")
|
||||
}
|
||||
|
||||
// Navigation out of the thread
|
||||
Row(
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ package app.closer.ui.wheel
|
|||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import app.closer.core.billing.EntitlementChecker
|
||||
import app.closer.core.billing.CouplePremiumChecker
|
||||
import app.closer.core.navigation.AppRoute
|
||||
import app.closer.domain.model.GameType
|
||||
import app.closer.domain.model.QuestionCategory
|
||||
|
|
@ -33,7 +33,7 @@ data class CategoryPickerUiState(
|
|||
@HiltViewModel
|
||||
class CategoryPickerViewModel @Inject constructor(
|
||||
private val repository: QuestionRepository,
|
||||
private val entitlementChecker: EntitlementChecker,
|
||||
private val premiumChecker: CouplePremiumChecker,
|
||||
private val gameSessionManager: GameSessionManager
|
||||
) : ViewModel() {
|
||||
|
||||
|
|
@ -78,7 +78,7 @@ class CategoryPickerViewModel @Inject constructor(
|
|||
// checkActiveSession isn't clobbered by the category load finishing.
|
||||
_uiState.update { it.copy(isLoading = true, error = null) }
|
||||
try {
|
||||
val hasPremium = entitlementChecker.isPremium().first()
|
||||
val hasPremium = premiumChecker.isPremium().first()
|
||||
val items = repository.getCategories().map { category ->
|
||||
CategoryPickerItem(
|
||||
category = category,
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import android.util.Log
|
|||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import app.closer.core.billing.EntitlementChecker
|
||||
import app.closer.core.billing.CouplePremiumChecker
|
||||
import app.closer.core.navigation.AppRoute
|
||||
import app.closer.domain.model.GameType
|
||||
import app.closer.domain.model.QuestionCategory
|
||||
|
|
@ -38,7 +38,7 @@ class SpinWheelViewModel @Inject constructor(
|
|||
private val repository: QuestionRepository,
|
||||
private val sessionStore: LocalWheelSessionStore,
|
||||
private val gameSessionManager: GameSessionManager,
|
||||
private val entitlementChecker: EntitlementChecker
|
||||
private val premiumChecker: CouplePremiumChecker
|
||||
) : ViewModel() {
|
||||
|
||||
private val categoryId: String = savedStateHandle["categoryId"] ?: ""
|
||||
|
|
@ -56,7 +56,7 @@ class SpinWheelViewModel @Inject constructor(
|
|||
|
||||
private fun loadPremiumStatus() {
|
||||
viewModelScope.launch {
|
||||
val hasPremium = runCatching { entitlementChecker.isPremium().first() }.getOrDefault(false)
|
||||
val hasPremium = runCatching { premiumChecker.isPremium().first() }.getOrDefault(false)
|
||||
_uiState.update { it.copy(hasPremium = hasPremium) }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ package app.closer.ui.wheel
|
|||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import app.closer.core.billing.EntitlementChecker
|
||||
import app.closer.core.billing.CouplePremiumChecker
|
||||
import app.closer.data.challenges.ChallengesCatalog
|
||||
import app.closer.data.remote.FirestoreCapsuleDataSource
|
||||
import app.closer.data.remote.FirestoreChallengeDataSource
|
||||
|
|
@ -45,14 +45,14 @@ class GameHistoryViewModel @Inject constructor(
|
|||
private val coupleRepository: CoupleRepository,
|
||||
private val challengeDataSource: FirestoreChallengeDataSource,
|
||||
private val capsuleDataSource: FirestoreCapsuleDataSource,
|
||||
entitlementChecker: EntitlementChecker
|
||||
premiumChecker: CouplePremiumChecker
|
||||
) : ViewModel() {
|
||||
|
||||
private val _uiState = MutableStateFlow(GameHistoryUiState())
|
||||
val uiState: StateFlow<GameHistoryUiState> = _uiState.asStateFlow()
|
||||
|
||||
init {
|
||||
entitlementChecker.isPremium()
|
||||
premiumChecker.isPremium()
|
||||
.onEach { premium ->
|
||||
_uiState.update { it.copy(hasPremium = premium) }
|
||||
if (premium) load()
|
||||
|
|
|
|||
|
|
@ -176,7 +176,17 @@ service cloud.firestore {
|
|||
// Entitlements written server-side only (RevenueCat webhook via Admin SDK).
|
||||
// Client needs read access so FirestoreEntitlementChecker can observe premium state.
|
||||
match /entitlements/{entitlementDoc} {
|
||||
allow read: if isOwner(uid);
|
||||
// Owner reads their own; a paired partner may also read it so premium can be shared
|
||||
// across the couple (chat media unlocks if EITHER partner is premium).
|
||||
allow read: if isOwner(uid)
|
||||
|| (
|
||||
request.auth != null
|
||||
&& exists(/databases/$(database)/documents/users/$(uid))
|
||||
&& get(/databases/$(database)/documents/users/$(uid)).data.coupleId != null
|
||||
&& exists(/databases/$(database)/documents/users/$(request.auth.uid))
|
||||
&& get(/databases/$(database)/documents/users/$(request.auth.uid)).data.coupleId
|
||||
== get(/databases/$(database)/documents/users/$(uid)).data.coupleId
|
||||
);
|
||||
allow write: if false;
|
||||
}
|
||||
|
||||
|
|
@ -422,7 +432,7 @@ service cloud.firestore {
|
|||
// last-message preview must be ciphertext (encrypted on-device before write).
|
||||
allow write: if isCouplesMember(coupleId)
|
||||
&& request.resource.data.keys().hasOnly(
|
||||
['type', 'questionId', 'createdAt', 'lastMessageAt', 'lastMessagePreview', 'lastMessageSenderId', 'reads'])
|
||||
['type', 'questionId', 'createdAt', 'lastMessageAt', 'lastMessagePreview', 'lastMessageSenderId', 'reads', 'typing'])
|
||||
&& (!('lastMessagePreview' in request.resource.data)
|
||||
|| isCiphertext(request.resource.data.lastMessagePreview));
|
||||
|
||||
|
|
@ -433,7 +443,7 @@ service cloud.firestore {
|
|||
allow create: if isCouplesMember(coupleId)
|
||||
&& coupleEncryptionEnabled(coupleId)
|
||||
&& request.resource.data.authorUserId == request.auth.uid
|
||||
&& request.resource.data.keys().hasOnly(['authorUserId', 'text', 'createdAt', 'type', 'mediaUrl', 'durationMs'])
|
||||
&& request.resource.data.keys().hasOnly(['authorUserId', 'text', 'createdAt', 'type', 'mediaUrl', 'durationMs', 'reactions', 'deleted'])
|
||||
&& (
|
||||
(request.resource.data.get('type', 'text') in ['image', 'voice']
|
||||
&& request.resource.data.mediaUrl is string
|
||||
|
|
@ -442,7 +452,15 @@ service cloud.firestore {
|
|||
(request.resource.data.get('type', 'text') == 'text'
|
||||
&& isCiphertext(request.resource.data.text))
|
||||
);
|
||||
allow update, delete: if false;
|
||||
// Reactions: any couple member may change ONLY the reactions map.
|
||||
// Unsend: only the author may set the `deleted` tombstone.
|
||||
allow update: if isCouplesMember(coupleId) && (
|
||||
request.resource.data.diff(resource.data).affectedKeys().hasOnly(['reactions'])
|
||||
||
|
||||
(resource.data.authorUserId == request.auth.uid
|
||||
&& request.resource.data.diff(resource.data).affectedKeys().hasOnly(['deleted']))
|
||||
);
|
||||
allow delete: if false;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue