diff --git a/ClaudeQACoverage.md b/ClaudeQACoverage.md index 17392666..12e9adaa 100644 --- a/ClaudeQACoverage.md +++ b/ClaudeQACoverage.md @@ -4,6 +4,7 @@ > **R25 (2026-06-30) — full fresh run on the new R24 E2EE backup/restore surface + cornerstone regression; 0 new defects.** Cheap gates green (unit **244** · fn **38** · theme-scan CRIT **1=false-pos** [HomeScreen:829 brand count pill] · painter-xml **0** · wiring 🔴**0** · cold-start smoke **6/6 both** · render smoke **4/4**). **Pass D CLEAN (deep on backup/restore):** at-rest manifest=pointers-only, Storage snapshot=`enc:v1:` (16KB, server-blind), restore_requests=0; rules member-scoped + keybox bound to other member + immutable pubkey; **D3 live negative all-denied** (backup manifest/chunks/restore_requests + create/write = 403/400; original couple/self-grant = 403); cross-user restore via **tokenized capability URL** verified (plain GET→200+`enc:v1:`); **R24 storage.rules deploy gap RESOLVED**. **Pass E:** `restore_requested` partner push **deployed + firing** (Sam queue: 3 today) → RESTORE_CONSENT; **R24-b functions NOT deployed** (`onRestoreFulfilled` absent, no `lastRestoreSelfAlertAt`, 0 `restore_self_alert`) → self-alert + completion alert = `blocked→deploy`. **Pass M:** new Security entries live-verified this session (recovery reveal on no-lock, Copy+`IS_SENSITIVE` mask, Help-my-partner-restore + back). **Cornerstone regression (Sam 5556):** A paywall gate ✓, B Play-hub cards+badges ✓, L inbox+thread fully decrypted no-leak ✓, N daily-Q decrypted+reveal ✓, 0 FATAL. **⚠️ PROCESS LANDMINE (I caused): `connectedDebugAndroidTest` UNINSTALLS+WIPES the app-under-test → wiped QA(5554) data → QA at fresh onboarding (O-ONBOARD-001 stays fixed). NEVER run instrumented tests on 5554/5556 fixtures — use throwaway 5558.** QA fixture recovery = `blocked→user` (password/re-auth needed). **User actions to close: (1) `firebase deploy --only functions` ✅ DONE (functions deployed by user; both self-alerts live-validated R25-c). (2) restore QA fixture ✅ DONE R25-b.** > **R25-b (2026-06-30) — QA(5554) fixture RECOVERED; live 2-device partner-assisted restore verified end-to-end, 0 defects.** Password reset via admin (user-authorized) → QA signed in → **NEEDS_RECOVERY** → "Start restore" published request (code 592847) → **deployed `onRestoreRequested` fired LIVE** (Sam got "Help your partner restore 💜" push, id 40038) → Sam's **Change-1 consent live-verified** (email anchor + name **QA** decrypted locally + confirm checkbox; Approve gated on code(6) **AND** confirm) → approve → **QA auto-restored**: paired Home + "Sam/Revealed" + **full chat history decrypts**; fresh `restore_ok_R25` from restored QA **decrypts on Sam** = bidirectional round-trip = full R24 restore regression. **Deferred obs (not a defect):** warm-start restore-push tap opened Play hub not RESTORE_CONSENT (likely collapsed-notif-group artifact; cold-start routing smoke-green). > **R25-c (2026-06-30) — LIVE-FIRE of deployed owner-alerts (Change 3): both restore self-alerts observed on QA's OWN device; last user-gate CLOSED; 0 defects.** User-authorized re-wipe QA(5554) → sign in → NEEDS_RECOVERY → Start restore (code 565429). **(1) Request self-alert fired LIVE** — QA **shade** (`closer.app` id 67945, `partner_activity`, imp 4) + durable `users/{QA}/notification_queue` (`restore_self_alert`, 23:17:38 “New device is restoring your history”) + partner push to Sam (“Help your partner restore 💜”) — all from one `onRestoreRequested`. **R25-b routing obs CLOSED:** tapping Sam’s *single* restore notif → RESTORE_CONSENT (not Play hub) ⇒ earlier artifact was the collapsed 2-item group header. Consent gate re-verified (code(6) alone Approve-disabled → +confirm enabled). Sam approve → **`onRestoreFulfilled` fired (status ok, 1319ms) on REQUESTED→READY** → **(2) completion self-alert** queued to QA (`restore_self_alert`, 23:19:50 “Your history was just restored”) — not on shade only because QA was foregrounded (auto-restored); push still reached live tokens. 132s apart (>~60s dedupe → no suppression). **Robustness live:** 1 stale token (`registration-token-not-registered`) failed but `Promise.allSettled` → function ok. QA auto-restored to paired Home + content decrypts, 0 FATAL → **fixture healthy**. **Minor follow-on (not defect):** prune `not-registered` FCM tokens. +> **R25-d (2026-06-30) — implemented FCM stale-token pruning (closes R25-c follow-on); build + 47 fn tests green; NOT deployed (user-gated).** New `notifications/pruneTokens.ts` (`isDeadTokenError` prunes ONLY `registration-token-not-registered`/`invalid-registration-token` — never transient/`invalid-argument`, so a bug can't wipe all tokens; `pruneDeadTokens` best-effort, never throws, batch-deletes dead `fcmTokens` docs + legacy field, only on an actual dead token). Reuses each caller's `tokens`+`allSettled` results → 1 line wired into **all 19 push sites** (questions/dates/couples/games/notifications/billing/users/backup). `pruneTokens.test.ts` +9 tests (fn **38→47**). tsc verified every db/uid/tokens/results ref; `dist/notifications/pruneTokens.js` emitted. Takes effect on next `firebase deploy --only functions`. > **R21 (2026-06-29) — brand-voice + Home-bubble polish, then full QA re-run; 0 new defects, 0 FATAL.** Copy: `prompt→question` (~26 strings) + clinical→Closer voice (Outcome/check-in feature, "Your Progress"→"Growing together", "Private sync"→"Just for two", Home eyebrow "Your daily question", paywall "…and growth"). Home partner bubble upgraded (Coil `SubcomposeAsyncImage` + gradient ring + a11y; partner photo verified live). Cheap gates all green (210 unit · 24 fn · theme-scan CRIT 0 · painter-xml 0 · smoke 6/6 both). **Reveal-when-answered verified LIVE end-to-end** (both answer → Home "Reveal is ready / Reveal together" → AnswerReveal shows both picks). Multi-angle nav verified (daily Q via Today+Home, reveal via Home card, Settings→Growing together, Play→Question Packs "250 questions"). Cornerstones A/B/D carry from R20 (no rules/crypto/games-logic change — diff is copy + Home-bubble UI); E re-verified (smoke). Also landed this session (uncommitted): recovery-UX partner-as-backup copy + change-phrase desync guard, `SECURITY.md`, first instrumented test `FirstRunRenderSmokeTest`. See `ClaudeReport.md` R21. > **R20 (2026-06-29) — fresh full ClaudeQAPlan run; found + FIXED 2 escaped bugs.** Build HEAD `62696a6` + R20 fixes (uncommitted: `QuestionSessionRepositoryImpl.kt`, `GameSessionManager.kt`, `HomeViewModel.kt`). Cheap gates all green (unit **210** · fn **24** · theme-scan CRIT **0** · painter-xml **0** · wiring 🔴**0** · smoke **6/6 both**). Cornerstones A/B/D/E live-clean, 0 FATAL. **B-ABANDON-001 (P2)** — Quit/abandon on any game silently `PERMISSION_DENIED` (full `saveSession` set drops server-only flags → rule rejects removed `affectedKeys`) → stranded session; **fixed** (targeted `update(status,completedAt)`), verified live (quit→active=0→new game starts). **B-COPY-001 (P3)** — Home GAME_WAITING hero falsely claimed "partner already played their part"; **fixed** (neutral partner-named copy), verified live both devices. Both pending 1 confirm. See `ClaudeReport.md` R20 run-state. > Build = **R18b working tree** (uncommitted: Wheel finish-gate + `partner_joined_game`/banner-standardization client+functions+rules + portrait lock + docs — full file list in `ClaudeReport.md` run-state); **210 unit + 24 functions tests green**; debug APK rebuilt+installed both emulators. **Deploy status:** `functions/` + `firestore.rules` **DEPLOYED by user** (join push live; Tier-2 self-constraint **verified live** — member own-uid add 200, foreign-uid/removal 403). No remaining deploy gates. Position + verdict: see `ClaudeReport.md` R18b run-state. **R18b polish/hardening round (latest):** fixed **E1 (P2)** Wheel silently-swallowed submit failure → retryable error (no false reveal); modern banner/bubble feel (haptics, spring, JOINED presence dot, tap+swipe, a11y, persistent-not-clobbered); predictive back (`enableOnBackInvokedCallback`); Wheel "Quit game" abandon; Tier-2 rules self-constraint. Pass-E smoke 6/6 both. **Verdict: R18 — fixed the last open visual P2 C-DARKART-002** (uiMode-sync in `MainActivity` via `AppCompatDelegate.setDefaultNightMode` so ALL art follows the in-app theme; verified live across all 4 theme/art states) + flaky **TEST-002** (capsule determinism — injected clock) + content **P-GRAMMAR-001** (13 stress-Q subject-verb agreement errors → asset data fix). Live passes this round: **A** (premium gate, Desire Sync + premium pack → Paywall; free content reachable), **B** (Wheel playthrough end-to-end), **L** (chat E2E send/receive-decrypt/at-rest/receipt/no-leak), **E** (backgrounded FCM delivery, privacy-safe, deep-links to chat), **M-001 confirmed** (client mirror intact). **R18b (Future.md review): found+fixed a P0 — O-ONBOARD-001** onboarding/auth crash on EVERY fresh install (`painterResource` on the `` `ic_launcher_foreground`, a regression from icon-redesign commit 334cb07; invisible to logged-in QA emulators). Verified live before/after on fresh 5558 (API 34); fixed both `OnboardingScreen.CtaSlide` + `AuthVisuals.AuthLogoMark` → raster `closer_launcher_foreground`; added `scripts/painter-xml-scan.sh` guard (proven). Also closed BucketList FAB hardcoded color. **Board: 0 open P0/P1 · 1 open P2 (O-AGE-001 pre-ship age gate) · 1 open P3 (BRAND-DARK-COVERAGE); the full confirmed backlog was pruned this round** (O-ONBOARD-001, C-DARKART-002, 6× C-THEME, M-001, TEST-001/002, P-GRAMMAR-001, BucketList-FAB, BRAND-ICON-CUSTOM, and **C-ORIENT-001 → RESOLVED: portrait-locked in the manifest, verified live**). 0 FATAL. **R18b feature work (uncommitted; tests 209 unit + 24 functions green):** (1) **Game finish-gate** — no game can finish with unanswered questions (Wheel: skip allowed but Finish bounces to the first blank; the other 3 already require a pick); verified live end-to-end (full 2-player Wheel + This-or-That). (2) **"Partner joined your game" push + standardized durable in-app game banner** — new `partner_joined_game` (joiner's avatar) + all foreground game pushes routed through the themed banner (started/joined transient, your-turn/results persistent); verified live + Pass-E smoke 6/6 both emulators. **⚠ The join push needs `functions/` + `firestore.rules` deployed by the user to fire live.** diff --git a/ClaudeReport.md b/ClaudeReport.md index 7c8c4d1a..ac6d5c92 100644 --- a/ClaudeReport.md +++ b/ClaudeReport.md @@ -21,6 +21,7 @@ - **R25 (2026-06-30) — full fresh ClaudeQAPlan run focused on the new R24 E2EE backup/restore surface + cornerstone regression. 0 new defects; recurring bar stays clean. Two user-gated items surfaced (functions deploy + a fixture-recovery I caused).** **Cheap gates all green:** unit **244** · functions **38** (incl. new `onRestoreRequested` tests) · `theme-scan` CRITICAL **1 = false-positive** (`HomeScreen.kt:829` `Surface(color=CloserPalette.PinkBright)`+white text is a brand-accent count pill like the legible Settings "3" badge — not an adaptive surface; MAJOR/REVIEW all pre-existing-acceptable) · `painter-xml-scan` **0** · `wiring-scan` 🔴**0** dead setters / 🔴**0** dead notif settings (20 🟠 orphan-readers = known interface-decl false-positives) · **cold-start `entrypoint_smoke` 6/6 on BOTH emulators** · **`connectedDebugAndroidTest` FirstRunRenderSmokeTest 4/4** (first-run composables paint light+dark). Monkey fuzz skipped (AVD activity-resolution quirk, not an app bug). **Pass D (security) — CLEAN, deep on the new backup/restore surface:** D1 at-rest (admin ground-truth) — backup `manifest` holds pointers/metadata only (generation, messageCount=33, `sha256:` checksum, tokenized `snapshotUrl`, uids); the Storage **snapshot blob is `enc:v1:` ciphertext** (16455 B, 0 plaintext markers in first 2KB — server-blind); loose chunks folded to snapshot (0 now; rule enforces `isCiphertext(payload)`); `restore_requests`=0 (clean, no lingering keybox). D2 rules static — `backup/**` + `restore_requests/**` member-scoped; keybox handover bound to the couple's **other** member with immutable pubkey/nonce; recipient-only create/delete; status-flips constrained; Storage `users/{uid}/backups` uploader-scoped **write**, **read** via tokenized capability URL. D3 **live raw-API negative** — non-member token → read backup manifest / list chunks / list restore_requests / create restore_request / write chunk = **all DENIED (403/400)**; original couple/messages/self-grant negative **all 403** (no regression). **Verified the cross-user restore mechanism** (would-be bug, checked before filing): the couple manifest points to ONE uploader-scoped snapshot, but the partner reads it via the **tokenized `?token=` capability URL** (plain GET → **200 + `enc:v1:`** confirmed) stored in the couple-gated manifest — sound, not a bug. **Also discovered the R24 `storage.rules` deploy gap is RESOLVED** (snapshot uploaded + compaction ran). **Pass E (notifications):** cold-start smoke 6/6 both ✓; the NEW `restore_requested` "Help your partner restore 💜" partner push is **DEPLOYED + live-firing** — Sam's `notification_queue` has **3 entries today** (from this session's live restore loops), routes to RESTORE_CONSENT (client wiring + consent screen live-verified). **DEPLOY GAP FOUND (not a bug): the R24-b functions change is NOT deployed** — `onRestoreFulfilled` absent from the deployed list, and the couple doc has **no `lastRestoreSelfAlertAt`** + **0 `restore_self_alert`** entries → the deployed `onRestoreRequested` is the pre-R24-b (partner-push-only) version; the recipient self-alert + completion alert need `firebase deploy --only functions` (source correct + unit-tested). **Pass M (settings):** the new Security entries were all live-verified earlier this session (recovery-phrase reveal on no-lock, Copy button + `IS_SENSITIVE` masking, "Help my partner restore" + back button). **Cornerstone regression (Sam 5556, intact):** A — free Sam → premium Desire Sync → **Paywall** ("Go deeper together", pills legible, C-PW-001 holds); B — Play hub all cards render with correct premium badges; L — Messages inbox + main thread **fully decrypted** (attribution, Seen, day-separators, E2E lock glyphs, **0 `enc:` leak**, 0 FATAL); N — Today daily-question renders with decrypted answer + reveal state. Home renders. **0 FATAL across the round.** **⚠️ FIXTURE DAMAGE I CAUSED (process landmine — now documented in ClaudeQAPlan + Eng Manual): `./gradlew :app:connectedDebugAndroidTest` UNINSTALLS the app-under-test on completion, which WIPED QA's (5554) app data (auth + keys + App-Check debug token) → QA is now at fresh onboarding. Never run instrumented tests on the 5554/5556 fixtures — use a throwaway (5558).** Silver lining: confirmed **onboarding slide-1 CtaSlide art renders** on the fresh install (O-ONBOARD-001 stays fixed). **Fixture recovery is user-gated** — QA's account (`qa_1782321603516@closertest.com`) still exists server-side but I don't have the password and resetting it is a gated auth write; needs the password or authorization to reset. **Board:** 0 open P0/P1 · 1 P2 (O-AGE-001 pre-ship age gate, user) · 1 P3 (BRAND-DARK-COVERAGE, user). **Two user actions to fully close the round: (1) `firebase deploy --only functions` (self-alert + completion alert), (2) restore the QA fixture (password or re-auth).** All R24/R25 code uncommitted (user commits). - **R25-b (2026-06-30) — QA(5554) fixture RECOVERED + live 2-device partner-assisted restore verified end-to-end; the R25 fixture-recovery gate is now CLOSED. 0 defects.** Reset QA's Auth password via admin (user-authorized) → signed QA back in → landed **NEEDS_RECOVERY** (data-wiped, no local couple key). Ran the full restore: QA "Start restore" published `restore_requests/{QA}` (code 592847) → **deployed `onRestoreRequested` fired end-to-end** (Firestore→FCM→Sam received "Help your partner restore 💜", notif id 40038, channel `partner_activity`, importance 4 — **LIVE Pass E restore-notif coverage, previously only via cold-start smoke**) → Sam's **Change-1 consent screen live-verified**: email anchor `qa_…@closertest.com` prominent + name **QA** (decrypted locally via couple key — server cannot) + "make sure this is your partner's real account before approving" + active confirm checkbox; **Approve disabled until code(6) AND confirmed** (verified both gates: code alone kept it disabled) → Sam approved (keybox sealed to QA's ECIES pubkey, request→READY, no error) → **QA auto-restored**: paired Home ("Connected with Sam" + "Sam / Revealed" decrypted-answer chip), **full chat history decrypts** (June 27→Today, all bodies readable), and a fresh send `restore_ok_R25` from restored QA **decrypts on Sam at 10:15 PM** = bidirectional round-trip proving the restored AES-256-GCM keyset is identical = **full R24 restore regression on the current build**. **Observation (deferred, low-pri, NOT filed as a defect):** tapping the restore push while Sam's app was already foregrounded (warm) opened Play hub, not RESTORE_CONSENT — most likely an artifact of tapping a *collapsed 2-item* notif group header (its contentIntent ≠ the restore notif's); couldn't cleanly re-test without re-wiping a precious fixture, and cold-start routing is smoke-green. **Still user-gated (unchanged):** `firebase deploy --only functions` for the self-alert + `onRestoreFulfilled` completion/anti-takeover pushes (blocked→deploy; source correct + unit-tested). - **R25-c (2026-06-30) — LIVE-FIRE of the deployed owner-alerts (Change 3): both new restore self-alerts observed firing on QA's OWN device end-to-end; last user-gated item CLOSED. 0 defects.** User authorized re-wiping QA(5554) to watch the self-alerts live now that functions are deployed. `pm clear closer.app` → fresh onboarding (Allow notifications) → sign in → **NEEDS_RECOVERY** → "Start restore" published a fresh `restore_requests/{QA}` (code 565429). **(1) Request-time self-alert fired LIVE on QA's own device** — posted to QA's **system shade** (`closer.app` id 67945, channel `partner_activity`, importance 4, category social, tap contentIntent present) **and** wrote a durable in-app record (`users/{QA}/notification_queue`, `type=restore_self_alert`, 23:17:38): *"New device is restoring your history / If this wasn't you, secure your account now."* — plus the partner push to Sam (*"Help your partner restore 💜"*), both from the single `onRestoreRequested` create-trigger. **The R25-b notification-routing deferred obs is now CLOSED:** tapping Sam's *single* restore notif opened **RESTORE_CONSENT** (not Play hub) → confirms the earlier Play-hub artifact was tapping a *collapsed 2-item notif-group header* whose contentIntent ≠ the restore notif's. **Consent gate re-verified:** code(6) *alone* kept **Approve disabled** → checking "I reached QA directly…" **enabled** it (both gates required). Sam approved → **`onRestoreFulfilled` fired server-side (status ok, 1319 ms) on the REQUESTED→READY transition** → **(2) completion self-alert** durably queued to QA (`type=restore_self_alert`, 23:19:50): *"Your history was just restored / A new device now has access. If this wasn't you, secure your account now."* It did **not** post to QA's shade only because QA's app was **foregrounded** at that instant (it had auto-restored and navigated to Home) — expected foreground-FCM handling; the push still succeeded to QA's live tokens + the durable queue entry guarantees the owner sees it on any device. Both self-alerts landed **132 s apart** (> ~60 s dedupe window → no suppression, correct). **Robustness proven live:** one **stale FCM token** (`f_T4C0ri…`, `registration-token-not-registered`, left over from QA's pre-wipe install) failed the send but `Promise.allSettled` shrugged it off — the function finished `ok` and delivered to the other tokens. **QA auto-restored to paired Home** ("Connected with Sam" + "Sam / Revealed" decrypted chip), content decrypts, **0 FATAL/ANR** → fixture **left healthy** (no re-restore needed). **Net: Change 3 (owner alerts) is now fully live-validated end-to-end (request self-alert shade+queue, completion self-alert queue, READY-transition trigger, allSettled resilience) — the `firebase deploy --only functions` gate is CLOSED.** **Minor follow-on (not a defect):** prune tokens FCM reports as `not-registered` (stale-token housekeeping) — fits the existing scheduled-cleanup follow-on family. +- **R25-d (2026-06-30) — implemented FCM stale-token pruning (closes the R25-c follow-on): shared helper + wired into ALL 19 push sites; build + 47 unit tests green. Requires user `firebase deploy --only functions` to go live.** New [`functions/src/notifications/pruneTokens.ts`](functions/src/notifications/pruneTokens.ts): `isDeadTokenError` prunes **only** `messaging/registration-token-not-registered` + `messaging/invalid-registration-token` — deliberately **NOT** `invalid-argument` or any transient/server error (`unavailable`/`internal`/quota/auth), so a payload bug or an outage can never wipe every user's tokens; `selectDeadTokens` (pure index→token map); `pruneDeadTokens` (best-effort, **never throws** — housekeeping must not fail the notify path; only touches Firestore when a dead token is actually seen; batch-deletes matching `fcmTokens` docs + clears the legacy `fcmToken` field). Each caller reuses the `tokens` array + `Promise.allSettled` results it already had → **one added line per site**. Wired into all 19 senders: questions (onAnswerWritten/onAnswerRevealed/onMessageWritten), dates (createDateMatch/onDateHistoryCreated/onDateReflectionWritten), couples (onCoupleLeave/acceptInviteCallable/scheduledOutcomesReminder), games (onGameSessionUpdate), notifications (gameRetention/dailyQuestionReminder/reengagement/streakReminder/sendGentleReminderCallable/sendThinkingOfYouCallable), billing (onEntitlementChanged), users (onUserDelete), backup (onRestoreRequested — the R25-c live repro that surfaced the stale `f_T4C0ri…` token). New [`pruneTokens.test.ts`](functions/src/notifications/pruneTokens.test.ts): **9 unit tests** (fn suite **38→47**) asserting dead-vs-transient classification, both error shapes (`errorInfo.code` + `code`), index mapping, dedupe, and fail-safe on garbage. `npm run build` clean (tsc verified every `db`/uid/`tokens`/results reference across the 19 sites) + `dist/notifications/pruneTokens.js` emitted. **Not deployed** (deploy is user-gated); pruning takes effect on the next functions deploy. (Note: the repo's own auto-commit daemon committed each file as `koga.industries@gmail.com` — I ran no git commit.) - **R24-d (2026-06-30) — three restore-flow UX fixes per user reports ("there needs to be a back button on help your partner restore. also tapping recovery phrase does nothing" → then "add a copy button for the recovery phrase"). All LIVE-verified on QA/Sam; 244 unit tests green (no regressions).** **Why:** the manual "Help my partner restore" consent screen (R24-c) opened with no way back except gesture; on a device with **no enrolled lock**, tapping "Recovery phrase" launched `BiometricPrompt` which silently errored (only `onAuthenticationSucceeded` was overridden) → felt like a dead tap; and once revealed there was no way to copy the phrase (12 words, error-prone to hand-transcribe). **What (uncommitted):** **(1) Back button** — `RestoreScreens.RestoreScaffold` gained an optional `onBack` param rendering a `CloserGlyphs.Back` `IconButton`; `RestoreRequestScreen`/`RestoreConsentScreen` accept `onBack`, wired in `AppNavigation` to `navigateBackOrHome` (`popBackStack()` else Home). **(2) Lock-less reveal** — `SecurityScreen.launchBiometricForPhrase` now checks `BiometricManager.canAuthenticate(BIOMETRIC_STRONG or DEVICE_CREDENTIAL) != BIOMETRIC_SUCCESS` (or null activity) and, when there's no lock to prompt, reveals the phrase directly (`viewModel.revealRecoveryPhrase()`) instead of firing a prompt that no-ops. When a lock *is* enrolled, behaviour is unchanged (still gated). **(3) Copy button** — the Recovery-phrase `AlertDialog` gained a `dismissButton` "Copy" (with `CloserGlyphs.Copy` icon) that writes the phrase to the clipboard and toasts "Recovery phrase copied". The `ClipData` is flagged **`IS_SENSITIVE`** (via `description.extras` PersistableBundle) so the phrase is **redacted from the Android 13+ clipboard-preview chip and excluded from clipboard history** — copy convenience without leaking the secret into the preview UI. **LIVE-verified:** ✅ QA (no PIN) — tapping Recovery phrase immediately shows the dialog with the 12-word phrase ("lion fair card like foot good full fame disk flat"); ✅ **Copy** → toast fires and the clipboard-preview chip shows masked dots `••••••` (sensitive flag confirmed working); ✅ Sam — Settings → Security → "Help my partner restore" opens the consent screen **with a back arrow** + the empty-state card ("There's no restore request waiting right now…"), and tapping back returns to Security. **Verification:** `:app:assembleDebug` OK + `:app:testDebugUnitTest` **244 green** (UI-only change, no test delta). Uncommitted: `ui/pairing/RestoreScreens.kt`, `ui/settings/SecurityScreen.kt`, `core/navigation/AppNavigation.kt`. - **R24-b (2026-06-30) — hardened partner-assisted restore per user's "check the account matches the email" concern: recipient identity + active confirm gate on consent, two request-lifecycle bug fixes, and owner security alerts. 243 unit tests green; consent identity + confirm gate + re-request fix + full loop LIVE-verified on QA/Sam.** **Why:** when the partner approves, they hand over the couple key — the consent screen never showed *who* was receiving it, and the recipient's own account got no alert. Investigating surfaced two real robustness bugs too. **What (uncommitted):** **(1) Consent identity + confirm gate** (`RestoreViewModel`/`RestoreScreens`) — Sam now sees the recipient's **email (plaintext anchor) + display name (decrypted locally via the couple key; the server can't, so identity is necessarily client-side)**, plus an explicit **"I reached them directly and this is their account"** checkbox; **Approve is gated on `code.length==6 && confirmed`**. Added distinct **no-request / expired** empty states (the screen previously showed the form unconditionally on a stale deep-link). **(2) Two lifecycle bugs found + fixed** (`RestoreManager`): **Bug A** — `createRestoreRequest`'s `set()` over an existing request is a rule-denied key-changing update → a **retry silently `PERMISSION_DENIED`s**; fixed by delete-then-create. **Bug B** — `expiresAt` was never enforced; `fulfillRestore` now **rejects an expired request** before wrapping the key. **(3) Owner security alerts** (`onRestoreRequested` + new `onRestoreFulfilled`) — a **"was this you?" alert to the recipient's OWN devices** at request time and on key transfer (the real anti-takeover signal for a phished password when the owner still has a device); quiet-hours bypassed (security), deduped via `couples/{id}.lastRestoreSelfAlertAt` (60s) against create-loops, each notify branch independently try/caught; client `RESTORE_SELF_ALERT` type wired (`isEnabled` true, `routeFor` `AppRoute.SECURITY`, `fromRemoteType`). **Honest framing (SECURITY.md):** the code is the crypto root of trust; identity+confirm stop *accidental* approval + add social-engineering friction (they do NOT stop takeover — the couple email matches); the owner self-alert is the takeover catch; the strongest control (email-verification challenge) needs mail infra → Future.md. **Verification:** `:app:assembleDebug` + **243 unit tests** (+10: `RestoreViewModelTest` ×6 — identity/fallback/expired/no-request/approve-gating; `RestoreManagerTest` ×4 — delete-then-create order, expiry + wrong-code rejection, no-expiry-legacy allowed; functions `onRestoreRequested.test.ts` ×6 — dedupe window + READY-transition guard as extracted pure helpers) + functions **build green** + `wiring-scan` **🔴0 dead setters / 0 dead notif settings**. **LIVE on QA(5554)/Sam(5556), fresh APK:** ✅ Sam's consent shows QA's real **email `qa_1782321603516@closertest.com` + name "QA"** + the confirm checkbox using the name; ✅ **Approve stays disabled with code-only**, enables only after checking the box; ✅ **re-request after a prior request created a fresh code (370690) with NO `PERMISSION_DENIED`** (Bug A fix); ✅ approve → **QA restored key (couple_crypto recreated) + 34 messages with no phrase** and returned Home (R24 regression through the new gate). **FOUND + FIXED — misleading Recovery-phrase empty-state on a partner-restored device (user-asked "ensure Recovery Phrase displays as it should").** After partner-assist restore, `storeTransferredKeyset` intentionally transfers the key **but not the phrase** (the phrase is one-way from the couple key), so QA's Settings → Security correctly greyed the "Recovery phrase" row — but the copy read *"…will appear here once your couple is set up on this device,"* which is **false** (QA is fully set up, chat working). Fixed `SecurityScreen`/`SecurityViewModel` to detect **key-present-but-no-phrase** (`encryptionManager.aeadFor(coupleId) != null` with `recoveryPhrase == null`) and show the accurate partner-restore copy ("You set this device up with your partner's help, so the recovery phrase isn't saved here. Ask your partner to open Settings → Security and read you theirs — either partner's phrase can restore your history."). **LIVE-verified both devices:** ✅ **Sam (original device)** — Recovery phrase row enabled → PIN-gated reveal shows the correct **12-word phrase** ("lion fair card like foot good full fame disk flat") with the right explanatory copy; ✅ **QA (partner-restored)** — greyed row now shows the accurate partner-restore message (not the "not set up" copy). **Navigation (answered):** recipient reaches restore via the `NEEDS_RECOVERY` "Unlock your history" screen → **"No phrase? Ask your partner to restore this device"** → RestoreRequestScreen; the helper reaches consent **only via the `restore_requested` notification** (OS push + in-app inbox entry once the function is deployed) → RESTORE_CONSENT — no manual Settings entry exists (candidate follow-on). **BUILT (R24-c, user-asked "we will build that entry backup") — partner-assist now also restores the recovery phrase + a manual helper entry.** After confirming a partner-restored device was fully restored (key + content) but deliberately *phrase-less*, added: **(1) phrase transfer** — the `keybox` ECIES payload became an envelope `ckx:v1:{keyset, phrase?}` (`CoupleKeyTransfer.wrapCoupleKey(...recoveryPhrase)` → `unwrapCoupleKey` returns `TransferredKey(keyset, phrase?)` → `CoupleEncryptionManager.storeTransferredKeyset(coupleId, handle, phrase?)`); `RestoreManager.fulfillRestore` includes the sender's phrase, `completeRestore` stores it. Backward-compatible (a prefix-less legacy keybox → key-only). The phrase rides inside the same OOB-code-gated ECIES ciphertext as the key — server stays blind; both partners are meant to hold the shared phrase, so this restores parity, not exposure. **(2) Manual "Help my partner restore"** row in Settings → Security (gated on holding the couple key) → RESTORE_CONSENT, so the approver can reach consent even if the notification never arrives. **LIVE-verified QA/Sam:** ✅ full re-run (QA wiped → requested code 462926 → **Sam approved via the new manual Settings entry**, not a notification) restored key + 34 msgs; ✅ QA's Settings → Security **Recovery phrase row now enabled**, and PIN-gated reveal shows the **identical phrase** as Sam ("lion fair card like foot good full fame disk flat") — the greyed/partner-restore state is gone; ✅ `couple_crypto_secure.xml` grew 1646→1872 B (phrase now stored). Unit: `CoupleKeyTransferTest` +1 (phrase round-trip) + return-type update → **244 total green**. **GATED (user deploys):** `onRestoreRequested`+`onRestoreFulfilled` (self-alert pushes) + still-pending `storage.rules` (snapshot compaction) + R23 date functions. All uncommitted (user commits): `ui/pairing/{RestoreViewModel,RestoreScreens}.kt`, `ui/settings/SecurityScreen.kt`, `crypto/{CoupleKeyTransfer,CoupleEncryptionManager}.kt`, `data/backup/RestoreManager.kt`, `notifications/PartnerNotificationManager.kt`, `functions/src/backup/onRestoreRequested.ts` + `index.ts`, `{RestoreViewModel,RestoreManager,CoupleKeyTransfer}Test.kt`, `onRestoreRequested.test.ts`, + docs (SECURITY.md, Engineering_Reference_Manual R24-b/c, Future.md, ClaudeiOSPlan, this entry). - **R24 (2026-06-30) — built E2EE conversation backup + full partner-assisted restore (foundation for Option B). Code-complete, 233 unit tests green, deploy-gated for live verify.** **What (uncommitted):** devices keep a couple-key-encrypted backup of all conversations so a new/wiped device restores history, and a partner can **fully restore for the other (key + content) with no recovery phrase**. **Phase A** — schema (`FirestoreCollections` backup/restore consts) + `BackupCodec` (JSON→`enc:v1:` envelope, checksum, reactions) + `FirestoreBackupDataSource` (manifest `generation`-CAS transactions, chunk append, snapshot finalize w/ delete-after-commit, restore_requests) + `firestore.rules` (backup manifest/chunks + restore_requests, new `isPublicKey` helper) + `storage.rules` (`users/{uid}/backups/` — uploader-scoped, reuses existing `onUserDelete` cleanup). **Phase B** — `BackupManager` (incremental `appendSince` + full-state `compact` that captures deletes/reactions; resolved-timestamp-only; throttled/single-flighted) + conversation backup-reads on `FirestoreConversationDataSource`; opportunistic trigger from `HomeViewModel.loadHome`. **Phase C** — separate `ConversationCacheDatabase` (Room; kept OFF the asset-backed `AppDatabase` to protect its schema hash) + `BackupRestoreManager` (download snapshot+chunks → decrypt → checksum-validate → upsert dedup-by-id). **Phase D (headline)** — `CoupleKeyTransfer` (ECIES-wrap the couple keyset to a recovering device's fresh pubkey; context `"{coupleId}|restore|{sender}|{recipient}|{nonce}"`; **6-digit OOB code = SHA-256(pubkey‖nonce)**) + `RestoreManager` (A request→complete, B fulfil-after-code) + `RestoreViewModel`/`RestoreRequestScreen`/`RestoreConsentScreen` + RecoveryScreen "Ask your partner to restore" entry + nav. **Phase E** — `onRestoreRequested` Cloud Function (notifies partner; high-signal, not toggle-gated; audit-log no key material) + `RESTORE_REQUESTED` notif type wired (`fromRemoteType`/`routeFor`/isEnabled). **Verification:** `:app:assembleDebug` + **233 unit tests** (9 new: `CoupleKeyTransferTest` proves the couple key round-trips only to the right device+context, wrong key/nonce fail, OOB code binds to pubkey+nonce for swap-detection; `BackupCodecTest` round-trip/checksum/forward-compat/cursor) + functions **tsc green** + `wiring-scan` **🔴0**. **Security posture:** server holds only `enc:v1:` ciphertext + `keybox:v1:` blobs (AEAD tamper-evident); the OOB-code entry gate defeats a server/MITM pubkey swap + remote account-takeover; unpair revokes (rules require active couple); threat model + residual risks (no forward secrecy, rollback-freshness, client-enforced verification) documented in SECURITY.md. **Docs:** SECURITY.md (E2EE list + 2 threat rows + roadmap), Eng Ref Manual (**R24-BACKUP** landmine w/ wire formats + hazards), ClaudeQAPlan (Pass D backup/restore + rules-negative + Pass E `restore_requested`), ClaudeiOSPlan (byte-compat parity item), Future.md (Option B + FS + freshness + WorkManager/backfill + Settings indicator/opt-out). **LIVE-VERIFIED (2026-06-30) on QA(5554)/Sam(5556), fresh APK, `-gpu swiftshader_indirect`:** simulated device loss on QA by deleting **only** the secure prefs (`couple_crypto_secure.xml` = couple key + `user_key_secure.xml` = ECIES key) via `run-as` (NO `pm clear` — App Check token preserved) + backgrounding (KEYCODE_HOME) + `am kill` to drop the in-memory key → cold-launch QA lands on `NEEDS_RECOVERY`. **Full partner-assist (no phrase):** ✅ QA "Ask your partner to restore this device" → RestoreRequestScreen "Start restore" → published a fresh `pub:v1:` + created `restore_requests/{QA}` (rules **allowed**, no `PERMISSION_DENIED`) → showed OOB code **940687**. ✅ Sam driven to RESTORE_CONSENT (deep-link, function-independent) → typed **940687** → "Approve restore" → `fulfillRestore` verified code == `SHA-256(pubkey‖nonce)`, wrapped the couple keyset to QA's fresh pubkey (`keybox:v1:`), set status READY. ✅ **QA auto-completed with NO recovery phrase** — `couple_crypto_secure.xml` **recreated at 20:00** via `unwrapCoupleKey`+`storeTransferredKeyset`; QA navigated past `NEEDS_RECOVERY` to Home ("Connected with Sam"). ✅ **Content restored: 34 messages** across all 4 conversations (`main`:28 + 3 `q_daily_fun_*` discuss threads) upserted into QA's separate `conversation_cache.db` (confirmed via on-device `sqlite3` count) — sourced from the **Firestore `enc:v1:` chunks** (server-blind). ✅ Restored key **decrypts live content**: QA Messages inbox + main thread render the full history in plaintext ("hi"/"hey", R18 test msgs, "gu", today's pings) — not 🔒 placeholders. ✅ `completeRestore` deleted the request post-unwrap (no lingering keybox). **DEPLOY GAP FOUND (not a code bug): `storage.rules` was NOT deployed** (only `firestore.rules` was — which is why every Firestore-side op above worked). Both devices' `backupNow`→`compact` snapshot upload 403s (`GetDownloadUrlTask` "Permission denied") because the deployed storage.rules lacks the `users/{uid}/backups/` block → snapshot compaction can't run. **Backup + restore still work fully via the Firestore chunks** (the 34-msg restore proves it); only the Storage snapshot/compaction (chunk-folding) is blocked. **Fix = deploy `firebase deploy --only storage`** (rules file is correct + structurally identical to the working `chat_media` block); after deploy, compaction folds chunks → snapshot automatically on the next `backupNow`. **Verdict: R24 — headline full partner-assisted restore (key + 34 msgs, NO phrase, OOB-code-gated) VERIFIED LIVE end-to-end + server-blind; 0 FATAL. One outstanding DEPLOY-gate: user runs `firebase deploy --only storage` to enable snapshot compaction (+ still-pending `onRestoreRequested` for the partner push, and the R23 date functions). Board unchanged: 0 open P0/P1.** All uncommitted (user commits): `data/backup/*`, `data/remote/{FirestoreBackupDataSource,BackupCodec,FirebaseStorageDataSource,FirestoreConversationDataSource,FirestoreCollections}.kt`, `crypto/{CoupleKeyTransfer,CoupleEncryptionManager}.kt`, `data/local/ConversationCache*`, `di/DatabaseModule.kt`, `domain/model/ConversationBackup.kt`, `ui/pairing/{RestoreViewModel,RestoreScreens,RecoveryScreen}.kt`, `ui/home/HomeViewModel.kt`, `notifications/PartnerNotificationManager.kt`, `core/navigation/{AppRoute,AppNavigation}.kt`, `functions/src/backup/onRestoreRequested.ts` + `index.ts`, `firestore.rules`, `storage.rules`, `app/build.gradle.kts`, tests, + the docs above.