Commit Graph

460 Commits

Author SHA1 Message Date
null 4952b129c5 fix(crypto): stop telling users "waiting for partner" when their key is gone
decryptPartnerAnswer returned a nullable payload, and null meant two opposite
things: the partner hasn't released their key yet (wait — all is well), or the
keybox is sitting right there and this device cannot open it (your key is gone).
The reveal UI rendered WAITING_FOR_PARTNER for both. Someone whose key was lost
was told to keep waiting for something that had already happened — and could
never report the bug accurately, because the app described it wrongly. A
LOST_LOCAL_KEY phase with honest copy has existed the whole time; this path just
never reached it.

Now a typed PartnerAnswerResult: WaitingForPartner (no keybox), Decrypted, or
KeyUnavailable (keybox present, no local key and no usable escrow — or a payload
we hold the key for and still can't open, which is broken, not pending).

Mutation-checked: collapsing KeyUnavailable back into WaitingForPartner fails
exactly the three tests that assert the distinction.

Repaired collateral from my own edit: the replacement over-cut and deleted
releaseOwnKeyForThread / decryptOwnThreadAnswer / decryptPartnerThreadAnswer.
Compile caught it; restored verbatim from git, then diffed the function list
before/after to prove nothing else went missing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 22:33:31 -05:00
null 3c74aec82d feat(crypto): escrow the sealed-reveal key under the couple key (heals the second-device gap)
The per-user ECIES keypair lived on exactly one phone. A second device — a new
phone, a reinstall — had no private key, so every sealed reveal died there. Worse
than dying: it reported "waiting for partner". The user isn't waiting for anything;
their key is gone. They'd never report that bug accurately, because the app told
them the wrong story.

Fix: escrow, not multi-device fan-out. The private keyset is stored encrypted under
the COUPLE key (AAD = uid) at users/{uid}/devices/primary/secure/escrow; a device
holding the couple key — which is a precondition for reading anything at all —
imports it and becomes crypto-identical to the original. Fan-out (sealing every
release key to N public keys, plus device lifecycle and pruning) buys the same
outcome for an order of magnitude more protocol, and this product has no
multi-device story to justify it.

Escrow leaks nothing: the only party besides the owner who can hold the couple key
is the partner, and everything this keypair protects — release keys for the
partner's OWN answers, restore keyboxes of the SHARED keyset — is material the
partner already has. It adds zero capability to anyone. It's owner-only anyway:
the escrow is a SUBDOC because the parent devices/primary must stay
partner-readable (they seal to the public key) and rules can't gate one field.
That distinction is now the mutation-checked test — making the escrow inherit the
parent's read rule fails exactly "the PARTNER cannot read the escrow".

Corrected the KDoc while I was in there: it claimed a second sign-in "overwrites
users/{uid}/devices/primary". It doesn't — every publish site is existence-gated,
so device 1 keeps working. The real failure was narrower and quieter than the
documentation said, which is its own small lesson about trusting comments.

Heal-forward on Home load (migrateProfileFields shape: idempotent, best-effort,
never blocks Home) so existing users escrow on next launch. Residual case
documented rather than hidden: a user whose only device dies BEFORE ever escrowing
keeps today's behaviour — that key never left the phone and nothing can fix it
retroactively.

10 new rules tests (151 total, mutation-checked). Android suite green, assemble
clean. Rules deploy + the live wipe→recover→sealed-reveal proof still pending.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 22:28:43 -05:00
null 5716aead8f feat(crypto): wire "change recovery phrase" with a partner handshake (closes the desync landmine)
The API existed and was deliberately unwired, carrying warnings at three layers:
re-wrapping the couple key under a new phrase leaves the PARTNER's stored copy
stale, so their Settings → Security reveals a phrase that unwraps nothing and the
"lost your phrase? ask your partner" path — the whole reason both partners hold it
— hands over a dud. This makes it shippable rather than deleting it.

The phrase can't travel via the server in plaintext, but it can travel sealed to
the key both partners already hold. And the WRAP moves last:

  phase 1  publish the new phrase (enc:v1: under the couple key) + phraseGeneration
  phase 2  each device confirms it can read it (ack)
  phase 3  once BOTH acked, re-wrap under it + phraseWrapGeneration

Until phase 3 the old phrase unwraps everything, so an interrupted change is a
no-op instead of an unrecoverable couple. A device stores the new phrase only when
the wrap is actually made from it — the stored phrase and the wrap never disagree,
which is the invariant the landmine is about. An old client that ignores the fields
never acks, so the change simply never completes: it degrades to "nothing
happened", the right failure direction for a crypto rollout.

Two design notes against the plan. (1) The plan's "disable Rotate mid-handshake"
guard is gone: nothing stores the new phrase until the wrap moves, so a rotation
landing mid-handshake wraps under the phrase everyone still has, and phase 3 runs
in a TRANSACTION that re-reads keyGeneration — without it, a phase-3 write racing a
rotation republishes a wrap of the pre-rotation keyset and rolls the rotation back,
stranding the partner. The transaction subsumes the guard; a UI gate would have been
theatre. (2) Either device completes phase 3, so an offline changer can't leave the
couple showing a phrase the wrap doesn't honour.

I ALSO FOUND THE HARNESS I SHOULD HAVE BEEN USING: firestore-tests/ runs the rules
against the emulator. It immediately proved three bugs in my own rules, two of them
critical, all now fixed and pinned by 20 new tests (141 total, mutation-checked):

  - phase 3 was DEAD for every couple. `request.resource.data.keyGeneration` errors
    when the field never existed — couples are created without it — so the first
    phrase change of any couple was denied AFTER the UI had already shown the user
    their new phrase. The dud-phrase outcome, relocated. Both sides now default.
  - `phraseWrapGeneration` was in the allowlist but guarded by NOTHING: a bare
    one-field write passed (an unchanged wrap short-circuits the wrap clause), and
    the partner's client trusts that field as proof the wrap moved — so one write
    made them overwrite their working phrase with one that unwraps nothing.
    Permanently, silently, no crypto needed. Now only a genuine phase 3 may move it.
  - phase 3 never checked the acks server-side (client-only), so a client could
    complete before the partner had the phrase — exactly what the handshake exists
    to prevent. The rules now require both members' acks at the current generation.
    A fourth, caught by the tests themselves: the predicate didn't require the wrap
    to actually change, so advancing the generation alone still passed.

Also: the harness revealed the C-ROTATE-001 rules I shipped earlier broke a
standing test ("a member can re-wrap the couple key — allowed"). No live flow used
it (updateWrappedKey was dead code, now deleted), and the test encoded the very
behaviour the hardening removes — rewritten to assert the new invariant plus the
lawful rotation path.

Rules deploy is user-gated and NOT yet done; the client tolerates the old rules
(phase 1 is rejected, nothing breaks). Android suite + 108 functions tests + 141
rules tests green. Live 2-device verification still pending on the throwaway couple.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 21:57:58 -05:00
null bc3ad2650b chore(dates): remove the dead date_plan_preferences plumbing (Batch F)
Dead code isn't inert — this carried a live-looking E2EE encryptor and a rules
block, inviting the next engineer to extend a surface nothing reads (which is
exactly how N-002 happened: writes into a collection with no reader). Removed,
all verified caller-dead first: the Firestore preference methods + mapper on
FirestoreDatePlanDataSource, the repository preference surface (interface +
impl + the assemblePlanSuggestion placeholder, whose body was literally
`val x = /* comment */` swallowing a return — dead AND weird), the
DatePlanPreference/DatePlanSuggestion domain models, DatePlanPreferenceDao +
its DI provider + the AppDatabase accessor, and the firestore.rules block
(collection is now default-deny, strictly safer; stray pre-R15 docs are
orphaned — launch-checklist note in Future.md). DateBuilderViewModel's
vestigial savePreference() renamed createPlan() — it has created real PLANNED
plans since the N-002 fix.

The one deliberate KEEP: DatePlanPreferenceEntity stays registered in
AppDatabase. Room's identity hash is computed from the SCHEMA, and the DB ships
via createFromAsset with no migrations — dropping the entity changes the hash
and crashes every install at first DB open. The entity now carries a loud KDoc
saying exactly that (and the AppDatabase comment points at it), so nobody
"cleans it up" without regenerating the asset DB. Removing DAOs/methods is
hash-neutral; proven live: fresh install on-device, DB opens clean, DB-served
content renders.

Also repaired collateral from my own removal script: it over-cut plansRef
(caught by compile, restored verbatim) — and the repo impl no longer injects
the same datasource twice under two names.

Live post-removal: Create Plan saves without error or PERMISSION_DENIED (the
surviving savePlan path). Caveat, stated honestly: the Home "Date coming up"
tile wasn't re-observed because the Compose date picker resists uiautomator
(cells expose content-desc only) and a today-dated plan is excluded by design
(scheduledDate > now) — the tile logic is untouched by this batch and was
live-verified in R15. Full unit suite green, assembleDebug clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 20:34:54 -05:00
null 7341f64ae2 fix(crypto): warm apps adopt rotations via a couple-doc watcher; streak RMW → transaction
Landmine audit after C-ROTATE-001 (user: "any landmines that need fixing?"),
hunting the class that has now bitten twice — stale reads feeding one-shot
decisions. Two found, both fixed.

1. A WARM app never adopted a partner's rotation at all. Adoption ran at process
   start (MainActivity, once per uid) and on Home's initial load — and nowhere
   else. The 🔑 push routes to Security, which doesn't adopt; the couple listener
   in HomeViewModel watches the USER doc (pairing state), not the couple doc. So
   an app sitting open on any screen when the partner rotates renders 🔒 for
   every new-key message until process death — and a foregrounded process can
   stay warm for days. Fix: the couple doc is now observable
   (FirestoreCoupleDataSource.observeCouple → CoupleRepository.observeCoupleForUser,
   composed observeUser → coupleId → flatMapLatest so pairing changes reroute
   the stream), and CoupleKeyRotationAdopter.watch(uid) adopts on every
   emission. MainActivity's hook is now that long-lived watch (collectLatest on
   the auth uid: sign-out cancels it cleanly). Snapshot listeners deliver the
   partner's write from the server within seconds — no cache trap, no push
   required, warm or cold. Adoption stays idempotent, a failed adoption is
   recorded and the watch keeps collecting, and a stream error is recorded and
   ends the watch quietly (degrades to the one-shot hooks, never crashes).

2. updateStreak was the same disease verbatim: cache-first get() → compute next
   streak → merge write. A stale snapshot double-counts or clobbers the streak
   when both partners answer near-simultaneously. It now runs as a Firestore
   transaction — server-read values, retried on contention, concurrent answers
   converge. Same StreakCalculator semantics, same skip-if-unchanged.

Audited and deliberately left alone: updateDisplayName/updateSex read coupleId
via a cache-first snapshot, but a stale/missing coupleId only means the field is
stored plaintext until migrateProfileFields heals it on key-unlock — self-
healing already exists, so no change. requestRestore is stale-cache-immune
(delete-then-set on a fixed doc id). The backup manifest already does CAS.

CoupleKeyRotationAdopterTest pins the watcher contract (adopt per emission, skip
nulls, keep collecting past failures, stream errors recorded not thrown);
mutation-checked — dropping the recording fails exactly those two tests. Full
suite green, assembleDebug clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 19:46:52 -05:00
null cabfca5e28 fix(crypto): rotation must advance keyGeneration — a cached read silently stranded the partner (C-ROTATE-001)
Caught live on the fixture couple, second rotation. The first rotation worked;
the second looked identical from the rotating device — dialog confirmed, no
error, keyset swapped locally — and permanently locked the partner out of every
message written afterwards: 🔒 "Couldn't unlock on this device", with no way to
recover on his own, because the one signal that tells a partner to adopt a new
key never fired.

The chain: rotateCoupleKey read the couple through the cache-first
getCoupleById, got the stale keyGeneration=0 (the doc was already at 1), and
computed next = 1 — the value already on the document. Firestore doesn't count
an unchanged value as a change: affectedKeys() omitted keyGeneration, so the
monotonic rule never engaged, onCoupleKeyRotated never fired, and the partner's
device kept seeing "up to date". Meanwhile the write DID replace
wrappedCoupleKey with a wrap of the newer keyset and the rotating device DID
commit it locally — new content under a key only one phone has.

Same disease as C-AUTH-001, new organ: a cache-first read driving a one-shot
decision. Fixed at both layers:

- client: getCoupleByIdFromServer (Source.SERVER) backs the rotation decision;
  offline throws, nothing is written, the user is told nothing changed —
  rotation must fail loudly rather than half-happen.
- rules: a write that CHANGES wrappedCoupleKey must now carry a strictly
  increased keyGeneration. The stranding write is unrepresentable server-side,
  whatever any future client computes. (The old rule only checked monotonicity
  when keyGeneration was itself in affectedKeys — which is exactly the case
  that never arises when the bug happens.)

CoupleKeyRotationRepositoryTest pins it: generation comes from the server never
the cache, the published generation strictly advances, server write precedes the
local commit, offline fails without guessing. Mutation-checked by reinstating
the cached read — the two key tests fail, restored, suite green.

The fixture partner is un-stranded by rotating once more with this build: the
server read yields the true generation, the write is a real change, the trigger
fires, and adoption delivers the latest keyset (which retains every older key,
so the stranded-era messages decrypt too).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 19:33:12 -05:00
null 8b0bc58231 fix(crypto): adopt a key rotation at app start, not only on Home load
Found by live-verifying the rotation on a throwaway couple: after the partner
rotated, the other device cold-started into its last screen — a conversation —
and every message written under the new key rendered "🔒 Couldn't unlock on this
device". It stayed that way until the user happened to tap Home, because that's
the only place adoption ran. A chat notification opens a conversation directly,
so this is the normal path, not a corner case: the partner's phone would look
broken for as long as they avoided Home.

The couple key is an app-level fact, so adopting it is app-level work. New
CoupleKeyRotationAdopter runs on the startup path beside FCM registration (same
authState collect + best-effort runCatching shape as registerFcmToken), and Home
now calls the same use case instead of its own inline copy — one home for the
logic, with an entry point for callers that already hold the couple (no
redundant read) and one that resolves it. Failures go to CrashReporter per the
house pattern; never throws, because a device that can't adopt right now is not
broken — all pre-rotation content still decrypts and every later launch retries.

Verified live on the throwaway couple, two rotations deep (generations 0→1→2):
the partner cold-starts and reads all four messages across all three key eras,
zero locked placeholders. The proof the startup path is what's doing it: Home
logs only when IT adopts, that log never fired, and the content still decrypted
— Home found the key already current because MainActivity had adopted first.

Android suite green, assembleDebug clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 03:28:24 -05:00
null 2c44cc6ff2 feat(crypto): couple-key rotation, phase 1 — rotate forward (Future.md security #3)
A couple-key compromise currently exposes everything, forever, because the key
never changes. This adds the rotation ceremony: a fresh AES-256-GCM key becomes
the keyset's primary while the old keys stay for reads. The keyset is a Tink
keyring and every enc:v1: blob carries its key-id internally, so all history
keeps decrypting with zero wire-format changes — none of the 25 isCiphertext
rule sites move. Phase 1 protects FUTURE content only (a stolen keyset still
contains the old key); forward secrecy for history is phase 2, which builds on
the keyGeneration plumbing laid here. The Security-screen copy says so plainly.

The ceremony (CoupleRepositoryImpl.rotateCoupleKey): read the couple fresh so
concurrent rotations collide at the rules instead of overwriting each other →
prepareRotation builds the rotated keyset and re-wraps it under the SAME phrase
(fail-closed with a typed error when this device lacks keyset or phrase; nothing
persisted anywhere) → ONE merge write lands the new wrap + a strictly-increasing
keyGeneration atomically, so the partner can never observe a bumped generation
pointing at the old wrap → only then commitRotation stores locally. A failed
server write leaves the device coherent on the old key; a crash after it
self-heals through the same adoption path as the partner.

Adoption (CoupleEncryptionManager.adoptRotationIfNeeded, hooked into Home's
healing block, synchronously before the screen settles — until the rotated
keyset is stored, new content renders locked): couple.keyGeneration ahead of the
local generation → unwrap the published wrap with the locally-stored phrase →
replace the keyset. Replaced only on success, never deleted on failure, so old
content survives anything. No phrase on this device → needsRecovery, and both
recovery flows already deliver the rotated keyset for free (phrase entry unwraps
the current wrap; partner-assist exports the current keyset). Same phrase both
sides is the entire distribution trick — no new ceremony, no partner action.

Server: onCoupleKeyRotated (couples/{id} update, pure isKeyGenerationIncrease
edge guard so streak/rhythm/re-wrap updates never fire it, and a rules-forbidden
downgrade or redelivered stale event never alerts) sends both members the 🔑
security alert through the house pipeline, bypassing quiet hours like the
restore self-alerts. The push is also functional: the partner's closed app can't
read new-key content until it next loads Home — the tap takes them there.

Rules: isUpdatingRecoveryWrap admits keyGeneration, strictly increasing
(monotonic like encryptionVersion), untouched for plain phrase re-wraps.

Tests (real Tink, mocks stop at storage): history readable after rotation + NEW
writes unreadable by the old keyset — mutation-checked by dropping setPrimary,
which kills exactly that test (a rotation that forgets setPrimary passes
everything else while protecting nothing) — same-phrase unwrap reads both eras
(the partner's whole adoption, proven), prepare persists nothing until commit,
fail-closed without phrase/keyset, adoption state machine incl. corrupt-wrap.
Android suite green, assembleDebug clean, functions 105/105, tsc clean.

Deploy (scoped): firebase deploy --only firestore:rules and
--only functions:onCoupleKeyRotated. Live verify follows deploys.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 02:49:50 -05:00
null 2dd09d5d0d feat(restore): spare the requesting device its own "was this you?" alert (Future.md security #2)
The restore self-alerts fan out to every device the recipient owns — including
the new device that is doing the requesting. "Was this you?" sent to the asker
is noise; the copies that matter go to their partner and to any OTHER device the
real owner still holds (the phished-password-without-device-loss case), and
those are untouched. For a legit single-device owner the self-alert becomes a
clean no-op, which was the point.

The requesting device identifies itself: RestoreManager fetches its own FCM
token at request time and the doc carries it as a create-only, optional
requesterFcmToken. Doc-embedded on purpose — MainActivity's token registration
races the restore flow on a fresh device, so cross-referencing fcmTokens
server-side can't reliably name the requester. Strictly best-effort on the
client (runCatching → null → field omitted, never written hollow): a restore
must never block or fail over a notification nicety, pinned by test.

sendPushToUser gains optional excludeTokens (filtered after merge/dedupe;
excluding everything is a clean zero no-op via the existing empty-list guard),
threaded through the shared queueAndPush — the notification_queue record is
still written, so the in-app alert history stays complete — and applied to the
two self-alerts only; the partner "help them restore" push is deliberately
unfiltered. Rules: requesterFcmToken joins the create allowlist as an optional
plain string (opaque device identifier, no format to pin); partner-update and
status-flip rules are unaffected since the field is create-only. Old clients
never send it; the server reads it only if present — no deploy-order coupling.

Tests: 3 new push.test.ts cases (exclusion, exclude-all no-op, absent-list
unchanged) — mutation check on the filter kills exactly those; 2 new
RestoreManagerTest cases (token embedded; FCM failure never blocks the request).
Functions 101/101, tsc clean; Android suite + assembleDebug green.

Deploy (scoped): firebase deploy --only firestore:rules, then
--only functions:onRestoreRequested,functions:onRestoreFulfilled.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 02:39:20 -05:00
null b2a04b7187 chore(auth): delete dead hasProfile() — it was C-AUTH-001 waiting to be called
Final sweep after the auth hardening: audited every remaining getUser caller for
the exists-but-empty trap. All are display-only (partner names/photos — a stale
read shows a blank label for a frame, writes nothing, routes nothing). One
finding: hasProfile() has zero callers and contains both halves of C-AUTH-001
verbatim — runCatching{...}.getOrDefault(false) turns a failed read into "has no
profile", and the cache-default get() means the post-sign-in stub document reads
as "no profile" too. Its name invites exactly the onboarding-routing use that
just burned us, so the next person reaching for it would reintroduce the bug
through the front door. Deleted from all three layers; git history keeps it if a
safe variant is ever wanted (it would need Source.SERVER + stub-awareness + a
Result return, at which point it's resolveDestination).

Compile clean, full unit suite green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 01:53:04 -05:00
null 18e6cf7b30 fix(auth): GoogleProfileMerger must not be able to kill a successful sign-in
Self-review of the last four commits against the codebase's own error-handling
conventions. Two real defects found, both in GoogleProfileMerger, both mine:

1. Its writes could crash sign-in. The class doc said "best-effort by design",
   but createUser/updateDisplayName/updatePhotoUrl were unwrapped, and merge()
   runs inside the view model's onSuccess block in viewModelScope — a throw
   there is an unhandled coroutine exception, after authentication has already
   succeeded. The words claimed a contract the code didn't deliver. merge() now
   never throws: the seed runs under runCatching and failures are recorded.
   (The pre-rework copies had the same unwrapped createUser, so this is older
   than my refactor — but I rewrote the class and kept the hole, and the doc
   claiming best-effort made it worse than inherited.)

2. It was the only file in domain/ using android.util.Log. The house pattern
   for use cases is an injected CrashReporter (DailyQuestionResolver is the
   model): recordException for failures, log() for breadcrumbs. Swapped over;
   a swallowed failure now actually surfaces in Crashlytics instead of dying
   in logcat on a device we'll never see.

Also aligned: the two Kotlin assert() calls in RecoveryPhraseNormalizationTest
were the only ones in the suite and silently depend on the JVM -ea flag — now
assertNotEquals like everything else; RecoveryScreen's OutlinedButton is
imported rather than fully qualified (the file's lone qualified call was the
anomaly, not the rule).

GoogleProfileMergerTest pins the contract: a failed read writes nothing and is
recorded (C-AUTH-001's conflation), a failed write is recorded not thrown, a
stub is not seeded over, a complete profile is untouched, and only blank fields
are filled. Mutation-checked by removing the runCatching — and that check also
caught a bug in the test itself: the "complete profile" fixture had defaulted
photoUrl to "", so the merger legitimately tried to fill it and the strict
mock's throw was swallowed by the very runCatching under test — a test passing
for the wrong reason. Fixture fixed; the mutation now kills exactly the one
test that asserts the contract.

Hilt graph validated via assembleDebug; full unit suite green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 01:37:16 -05:00
null 0955276024 fix(auth): stop isSignInStub from looping every new signup into profile setup
Caught on-device before it shipped, by doing the thing I'd been putting off:
creating a real account and signing in again. My own isSignInStub — added two
commits ago to fix C-AUTH-001 — sent every brand-new email/password user straight
back into profile setup on their next sign-in, forever. Name filled in, profile
finished, still asked "What should your partner call you?" every launch.

It tested email.isBlank() && createdAt == 0L && coupleId == null, on the reasoning
that every path creating a real user writes email and createdAt first, so their
absence must mean nothing is written. Both halves are false:

  - an email/password signup never writes `email` at all — CreateProfileViewModel
    builds its User without one;
  - it only writes `createdAt` through createUser, which it calls only when it
    reads no document — a race it normally LOSES to FCM registration, so it takes
    the targeted-update branch (updateDisplayName/updateSex) and writes neither.

So a finished profile — displayName and sex set, nothing else — read as a stub
forever, and the retry loop I added then dutifully classified it as a new user.
The predicate now asks "is anything set at all?", which is what "nothing has been
written here yet" actually means. Any one field is enough to trust the document.

Two things I got wrong and am correcting rather than leaving:

  - the previous commit claimed isSignInStub was "now shared rather than private to
    OnboardingViewModel". It wasn't. The private copy stayed and shadowed the new
    shared one, so the domain-model version was dead code and the narrow local one
    was what actually ran — which is why the first attempt at this fix changed
    nothing on device. The private copy is gone; there is one definition now. The
    duplicated-logic trap I'd just written a commit message about, walked into.
  - the manual's C-AUTH-001 entry advised requiring email/createdAt before believing
    a read. That advice would reproduce this exact bug, so it now says the opposite,
    with the reason.

Verified live on the throwaway, both directions, because they fail in opposite ways
and fixing one blindly breaks the other: a real signup (account created, profile
completed, re-signed-in) now lands on Home; the paired fixture still lands on
Recovery. The disposable account was deleted through the app's own flow afterwards.

Tests: aFinishedProfileWithNoEmailOrCreatedAtIsNotAStub pins the regression;
anyOneIdentifyingFieldIsEnoughToTrustTheDocument pins the rule. Suite green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 01:25:25 -05:00
null 9448537b6b fix(auth): make a profile write unable to unpair a couple (C-AUTH-001 hardening)
The previous commit fixed the path that reached the hazard. This removes the
hazard: createUser was a whole-document set() of every field, so any caller
holding a partially-loaded User wrote coupleId = null over a paired user and
dropped coupleId/partnerId. The User that does this is not exotic — for ~200ms
after every sign-in, users/{uid} exists with no fields on it at all. Getting a
read slightly wrong should cost a no-op, not a relationship's history.

createUser now merges, and UserProfileWrite omits every null or blank value, so a
field is only ever written with a real value and a hollow User produces an empty
map. For a document that doesn't exist yet — the only case callers actually intend
— the result is byte-identical to before. Clearing a field stays the job of the
targeted update* methods, where the call site says so. It also carries the same
locked-placeholder require()s the targeted writers already had.

plan is deliberately no longer written: User.plan defaults to "free", so merging it
would silently downgrade a paying user whose document we read while it was hollow.
The server owns that field (RevenueCat), reads already default a missing plan to
"free", and no gate reads it — entitlements live in the server-only subdoc. Checked
against firestore.rules: create requires no fields, and update's hasOnly allowlist
still covers everything written.

Correcting my last commit message while I'm here: it claimed CreateProfile submit
calls createUser and overwrites the name. Only half true — it uses targeted merges
when it sees an existing doc, and only calls createUser when it reads null. The
path that actually reached the sharp edge was Google sign-in: mergeGoogleProfile
decided "new account" from runCatching { getUser(uid) }.getOrNull(), which is null
for a failed read as much as for a missing document — C-AUTH-001's conflation, in a
place that then wrote a whole document. It also existed as two verbatim copies, in
LoginViewModel and SignUpViewModel, so the same bug was there twice. Now one
GoogleProfileMerger: it does nothing on a failed read, and won't seed a name over a
stub (which would overwrite a returning user's real name with their Google one).
Both view models drop userRepository entirely as a result.

User.isSignInStub is now shared rather than private to OnboardingViewModel, since
two callers need the same question answered.

UserProfileWriteTest pins the rule; mutation-checked by reverting the omit rule,
which fails 3 of them. Full unit suite green. Sign-in verified live on the throwaway.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 00:58:28 -05:00
null 6330bf98ca fix(auth): don't mistake the sign-in stub document for a new user (C-AUTH-001)
Verified on a throwaway emulator with a real paired account: signing in on a new
device sent a fully set-up user to CREATE_PROFILE instead of RECOVERY. Not a rare
race — it happened on every run.

The previous commit guessed at the cause and was wrong: it assumed the profile
read *failed*, and retried on exceptions. Instrumenting the decision showed the
read succeeds and returns a document that exists with no fields at all:

  DIAG null=false nameBlank=true sexBlank=true couple=false -> create_profile
  DIAG null=false nameBlank=true sexBlank=true couple=false -> create_profile
  DIAG null=false nameBlank=false sexBlank=false sexLen=33 couple=true -> home

The first two land within a millisecond of sign-in; the real document arrives
215ms later. Sign-in registers the FCM token, and that merge-write materialises
users/{uid} before the profile has synced. Reading from the server does not dodge
it — Source.SERVER returns the same field-less document — so the read has to be
*trusted* before it is acted on, not just sourced differently.

resolveDestination now treats a field-less document as "ask again" rather than
"new user", retrying until one with real identity on it arrives; only that decides.
A read that never succeeds routes Home, which is non-destructive and routes to
Recovery on its own. New users are unaffected: every path that creates a user
writes email and createdAt before onboarding, and a document that genuinely stays
empty still falls through to profile setup.

This mattered because createUser is a whole-document set(), not a merge, and the
CreateProfile submit calls it: tapping through the screen that should never have
been shown overwrites the real displayName with the locked placeholder and drops
coupleId/partnerId, unpairing the couple.

Keeps getUserFromServer for the decision so an offline read throws rather than
answering from cache. Landmine written up as C-AUTH-001 — the general trap is that
exists() == true does not mean populated, and blank fields right after sign-in mean
"not synced yet".

Verified live end to end on the throwaway: sign-in now lands on Recovery, and a
Title-Cased phrase (previously rejected) unlocks to Home, "Connected with Ben".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 00:48:49 -05:00
null 7ab80013df fix(recovery): stop the two ways a returning user loses their history
Recovering on a new phone was walked end to end (a fixture was wiped, then
restored through the app's own flow). The Recovery screen itself is good — the
app detects the missing key and routes there by itself, and the copy is honest.
Everything around it had two holes, one of them destructive.

1. Sign-in could send a returning user into new-user profile setup (P2, data
   loss). OnboardingViewModel treated "couldn't read the profile" and "has no
   profile" as the same thing, so a slow read right after sign-in — a real race,
   hit live — routed them to CREATE_PROFILE, which asks "What should your
   partner call you?" over a `🔒 Couldn't unlock on this device` value. Anyone
   tapping through it re-encrypts and overwrites the name we had just failed to
   read, and never reaches Recovery. The repository already distinguishes the
   cases (missing doc = success(null), failed read throws), so only a successful
   read may now route to profile setup; a read that never succeeds retries and
   then goes Home, which is non-destructive and routes to Recovery on its own.

2. A correct phrase was reported as wrong. It is Argon2id key material, so it is
   byte-exact, and only .trim() was applied — while the field allowed the
   keyboard to capitalise the first word and autocorrect the wordlist. Every
   generated phrase is lowercase a-z single-spaced (all 248 WORDLIST entries
   checked), so folding typed input to that canonical form is lossless and
   cannot weaken the KDF: it only removes failures that were never about the
   phrase being wrong. "That phrase doesn't match" now means it actually
   doesn't. Also sets KeyboardCapitalization.None + autoCorrectEnabled = false.

3. The escape hatch was styled as a footnote. Most people arriving here are on a
   new phone and never saved the phrase, so "ask my partner" — not the field —
   is their real way through, yet it was a bare TextButton under the one control
   they can't use. It is now a full-width OutlinedButton with plainer copy
   ("I don't have the phrase — ask my partner"). It stays below the field rather
   than replacing it: someone who does have the phrase pasted from a message is
   unlocked instantly and offline, while this path waits on the partner.

Adds RecoveryPhraseNormalizationTest (7 cases): auto-capitalisation, shouting,
double/tab/newline spacing and chat-pasted text all fold to the canonical phrase,
while a genuinely wrong phrase still differs. Unit suite green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 00:25:20 -05:00
null 5151698d56 fix(avatar): harden the cropper's error handling; unify with the codebase
Checking error handling on the crop sheet surfaced three real problems, two of
which I had introduced.

1. Crash on a high-megapixel photo. The picked image was decoded at full size
   and handed to a hardware Canvas. An 8000x8000 photo decodes to 256MB, which
   Android allocates but the canvas refuses to draw ("trying to draw too large
   bitmap") — thrown inside Compose's draw phase, where the runCatching around
   the decode can't see it. Proven live: an 8000x8000 pick killed the app.
   Fixed by subsampling at decode (inSampleSize from a bounds-only pass) so the
   working bitmap is capped at ~2048px / ~16MB. Native heap on that pick went
   256MB(attempted) -> 46MB.

2. The subsample fix then broke decoding for EVERY image. decodeStream returns
   null by design in inJustDecodeBounds mode, and I had `?: return null` on it —
   so loadOriented bailed right after the bounds pass, for all inputs. It only
   looked like "the huge photo failed"; a normal photo would have failed too. I
   never re-tested a small image after the change. The new AvatarCropSheetTest
   caught it. The stream is now what's guarded; the real check is the header size.

3. Errors were swallowed and off-standard. runCatching{}.getOrNull() dropped the
   cause and a failure silently dismissed the sheet — indistinguishable from a
   save that did nothing. Now unified with the screen's own pattern
   (EditProfileViewModel.save): the sheet reports the Throwable up via a new
   onError, the VM records it through the injected CrashReporter and surfaces
   the message through uiState.error -> the existing snackbar. The crop also
   moved off the main thread (withContext(IO)); inline, `saving` flipped within
   one frame and never showed.

Adds AvatarCropSheetTest (androidTest, 4 tests, on-device BitmapFactory/Canvas):
subsample math for 8000+/oversized, oversized decode stays bounded, garbage
returns null instead of throwing, crop output is square and bounded. All green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 23:58:38 -05:00
null 7b8fa652f6 feat(avatar): frame a picked photo, and fall back to initials not a heart
Initials, shared. Home's partner bubble and the Settings "Connected with" row
are two views of one person and gave different answers when there was no photo —
Home showed initials, Settings showed a heart. Settings now shows the partner's
initials in a 48dp circle, so the photo and no-photo states share one
silhouette. The helper is lifted to ui/components/Initials.kt rather than copied;
an identical copy in two files is exactly how packArtworkRes drifted. The
unpaired state keeps the heart — there is nobody to take initials from yet.

Framing. Picking a photo now opens a crop sheet: pinch to zoom, drag to move,
inside the real avatar circle. The avatar was a blind ContentScale.Crop, so a
non-square or off-centre photo was centre-cropped and could lose the subject —
Ava's 640x480 test photo cropped straight past her face. The crop is applied at
pick time and handed back as a normal Uri, so setPhotoUri → upload is untouched.
EXIF orientation is honoured (a portrait selfie would otherwise crop sideways),
output is a 512px JPEG, and the source aspect is respected.

Two bugs found by driving it live, both of which looked fine in code:
- Panning did nothing at 1x. The clamp was viewport*(scale-1)/2, which is 0 at
  1x — correct only for a square source. A cover-fit 640x480 photo already
  overflows horizontally at 1x, so that pinned it dead centre and made framing
  impossible: the whole point of the sheet. Now clamped to the picture's actual
  overflow, computed from the source aspect.
- Panning then revealed empty space at the circle's edge. ContentScale.Crop had
  already discarded the overflow and returned a viewport-sized node, so
  graphicsLayer was sliding an already-cropped square around — the pixels being
  panned to had been thrown away first. The preview now draws the bitmap itself
  at cover*scale with the same maths as the crop, so preview == output.

Adds androidx.exifinterface. Verified live on 5554: sheet opens, pan moves the
picture (pixel-probed), no empty edge. 0 FATAL.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 23:19:37 -05:00
null 76d8746a9b fix(settings): the paired "Connected with" card rendered muddy and unpolished
Two defects, both only in the paired state — the unpaired state was already
styled correctly, which is why this hid.

1. The card was see-through. `partnerCardColor` used primaryContainer at 52%
   alpha, and the page behind it is a gradient brush, so the gradient bled up
   through the card: it read muddy against the crisp profile card directly above
   (which is 96%), and the card's own content bounds showed as a paler,
   sharp-edged band inside it — measured, not guessed: a 24-unit jump
   (218,209,226 -> 242,232,251 -> 217,208,225) across the Row's 18dp content
   inset. Now 96% like its sibling; the same scanline is flat (240,229,253 ->
   241,231,255). Content colours are the container's own on* pair rather than
   the page's ink, so contrast is correct in both themes.

2. The heart was a bare, outsized glyph. ProfileAvatar's no-photo fallback is a
   40dp Icon with no container, so the paired row showed a naked heart unlike
   anything else on the page — while the *unpaired* branch right below it, and
   every settings row, use a 48dp tile + 24dp glyph. The avatar is now used only
   when a real partner photo exists; otherwise it falls back to that same tile.

Verified live both fixtures (dark 5554 / light 5556) with pixel probes before
and after. theme-scan REVIEW 25 -> 24 (one hardcoded-colour hit removed);
CRITICAL/MAJOR unchanged. Unit tests green, 0 FATAL.

Filed alongside this round's Pass C results (R32) in ClaudeReport/ClaudeQACoverage.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 22:29:05 -05:00
null 089437e415 fix(nav): give the question pack library a way back
The pack library was a navigation dead end: it is not a bottom-nav tab and it
draws no header of its own, and it was missing from shellBackRoutes — so the
shell rendered neither an app bar nor a bottom bar. Once there, the system Back
gesture was the only way off the screen.

It is reached by drilling in from four places (Home "All packs", the Play hub,
the question composer's empty state, and the weekly recap), so it needs the same
shell back affordance its own detail page (QUESTION_CATEGORY) already had.

Audited the whole route table for the same hole rather than patching just this
one. Every other non-tab route either sits in shellBackRoutes or draws its own
back; the two remaining are PAIR_PROMPT and RECOVERY, which are self-contained
entry flows with their own CTAs, so they are intentionally left alone.

Verified live on both fixtures: Home -> Question Packs -> a pack -> back ->
Question Packs -> back -> Home, in dark and light. Unit tests green, 0 FATAL.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 21:31:13 -05:00
null 66d38251ea fix(packs): show pack name and description in full on library cards
The library cards clipped the two things a card exists to communicate: the name
at maxLines=1 ("Communicati…") and the description at maxLines=2 ("…about
pers…"). Both are now uncapped and cards size to their content, matching the
pack detail page.

Removing the caps alone was not enough. The question-count pill shared the title
row, so it reserved width for the row's full height and squeezed the text into a
narrow column — the name then broke mid-word ("Communicatio/n") and the
description wrapped to a ragged 8 lines. The pill moves down to join the access
pill, which gives the text the card's full width and matches how the detail page
already groups its pills.

Verified live on both fixtures (dark 5554 / light 5556): full names on one line,
full descriptions across the card, pills grouped below. Unit tests green,
0 FATAL.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 21:24:26 -05:00
null 8aea4730cc perf(res): convert all artwork to WebP; unify + blend pack art
Converts every PNG in res to WebP (124 files, 104M -> 28M on disk, -73%).
Each file was encoded both lossless and lossy q95 and the smaller kept, but
lossy only where it measured visually lossless (PSNR >= 40 dB); dimensions and
alpha were verified per file before the PNG was removed, so 25 files that
compressed better losslessly stayed exact. No nine-patches exist to break, and
R.drawable refs are extension-agnostic (the only ".png" in code is a runtime
share-cache filename). Debug APK 141.9 -> 128.8 MB; pack art in the APK
26.4 -> 11.5 MB. The disk saving is larger than the APK saving because AAPT2
already crunched PNGs at build time.

Unifies pack artwork in a new PackArtwork.kt. packArtworkRes was duplicated
verbatim in the library and detail screens — which is exactly how the two
drifted before (one kept a grouped mapping that gave several packs the same
illustration). It now exists once, alongside the two art composables. This is
also where an imported pack's art would resolve.

Pack detail page fixes:
- Removed the second back arrow. The nav scaffold already supplies the
  "Question Pack" bar and its back affordance (AppRoute.kt), so the in-screen
  IconButton was a duplicate; onBack is now unused and gone.
- The description is shown in full. It was capped at maxLines=4 with an
  ellipsis, which truncated most packs mid-sentence — this is the page where
  someone decides whether to open the pack, so it should not be abridged.
- The hero art is blended instead of pasted on: it runs full-bleed (the list no
  longer pads horizontally; items pad themselves) and dissolves into the page.
  The dissolve is an alpha mask (BlendMode.DstIn), not a scrim in the
  background colour — the page behind is a gradient brush, so fading to any
  single colour left a visible pale band in light theme. Fading the image to
  transparent lets the real background through, correct in both themes.
  Library cards get a softer version of the same, into the card surface.

Verified live on both fixtures (dark 5554 / light 5556): single back arrow,
full description, art melting into the page with no hard edges, correct
per-theme art. Unit tests green, 0 FATAL.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 20:52:14 -05:00
null 388b927437 feat(packs): add distinct pack artwork 2026-07-14 20:18:56 -05:00
null 1ac724bcde fix(content): give packs a pack_id, and stop icon_name leaking into the UI
Populates question.pack_id (build_db.py). The column was plumbed end to end
(question.pack_id -> QuestionEntity.packId -> Question.packId) but was NULL for
all 3811 rows, so content had no pack identity. Bundled packs now carry one —
the logical id a pack declares in metadata (daily ships as category
daily_fun_mc but is logically daily_single_choice_weekly_v1), else its category
id. This is the hook a purchased/imported pack needs: it becomes just another
pack_id in the same table rather than a special case. No schema or behaviour
change; identity hash unchanged.

Fixes two regressions the rebuilt db exposed, both latent for the same reason:
every category previously carried the placeholder icon_name "question", which
masked them. Packs now declare real Material icon names (shield, forum, paid).

1. Category glyphs. categoryGlyphStyle keyed on `iconName ?: categoryId`, so
   icon_name won. Material names are a different vocabulary from this file's
   keys, so 22 of 23 categories fell through to the default star. The curated
   per-category glyph now wins, with icon_name as the fallback — which is also
   what gives an imported pack a real glyph, since its category_id will not be
   listed here but its declared icon_name resolves. Added the Material aliases
   and the two unmapped categories (quality_time, daily_fun_mc).
2. Pack library chips. metadataLabels() rendered the raw icon_name as a
   user-facing tag, producing chips like "Chat Bubble Outline". It was only ever
   invisible because the list filtered the single value "Question". icon_name is
   a rendering detail, not a topic, so the chip is gone; access remains.

Verified live on both fixtures (dark 5554 / light 5556): distinct correct glyphs
(Boundaries warning, Communication chat, Rebuilding Trust shield, Sex & Desire
heart-outline), no leaked chips, and the new Quality Time pack browsable with
150 questions and a calendar glyph. Unit tests green, 0 FATAL.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 19:10:37 -05:00
null 8e6dabd4a4 feat(seed): rebuild asset db from JSON; verify Room loads it on-device
Regenerates app/src/main/assets/database/app.db with the fixed build_db.py, so
the app finally ships the current question catalog. The db had drifted far from
the JSON source of truth: 6103 -> 3811 questions (the intentional 150-cap trim),
+150 quality_time (a category the app could not show at all), and daily 500 ->
511 — the 11 wildcards, whose feature (DailyModeResolver) was built but had zero
content in the db, so wildcard days were dark. Identity hash is unchanged
(7e7d78fc...), schema untouched, so no migration is involved.

Scale labels now actually render. The db stored snake_case answer_config while
the app's only parser (QuestionMapper.parseAnswerConfig) reads camelCase, so
optString("minLabel","") resolved to "" and the scale UI fell back to bare
numbers. The rebuild emits the shape the parser reads.

Adds AssetDatabaseVerifyTest (androidTest): Room opens the asset lazily, so an
app that launches proves nothing about it — the bundled db is only validated on
first DB touch, and a bad one crashes every user on a fresh install. The test
forces a real open via openHelper.readableDatabase (triggering the copy +
identity/schema check) and asserts content, no orphan category_ids, integer
depth, and the camelCase scale contract. Verified 4/4 green on a clean install
on throwaway emulator 5558; the 5554/5556 fixtures were never touched.

Also stops shipping ~6MB of dead weight: app.db.bak_q4 and app.db.bak_q5 were
tracked inside assets/, and everything under assets/ is packaged into the APK.
build_db.py was making it worse by writing its backup next to the db; backups
now go to build/db-backups/ (gitignored, outside the packaged tree) and the
stale ones are removed. APK now contains only assets/database/app.db.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 18:49:20 -05:00
null 757d4efdda test(home): robust assertion in render smoke's unanswered-state case
The unanswered case asserted a specific CTA ('Answer privately') that the preview's
demo state doesn't surface in the test viewport. Assert the stable header instead —
still exercises the UNANSWERED render path (setContent), just without a brittle text
match. Verified 3/3 green on a throwaway emulator (fixtures untouched).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 20:54:17 -05:00
null ed0aa4d3b3 fix(test): HomeContentRenderSmokeTest nested-comment (components/* opened a block comment)
'ui/home/components/*' in the KDoc contains a /* sequence, which Kotlin treats as
a NESTED block-comment opener (Kotlin nests block comments) -> unclosed comment.
Reworded. androidTest now compiles. (App was never affected.)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 20:49:06 -05:00
null 1eacf36d81 fix(test): HomeContentRenderSmokeTest unclosed-comment (emoji in KDoc broke Kotlin lexer)
The prior commit's androidTest didn't compile — a stop-sign emoji in the KDoc
tripped the Kotlin lexer ('Unclosed comment'). Replaced with plain text. App was
never affected (androidTest isn't in the APK); this restores connectedAndroidTest.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 20:47:59 -05:00
null f7eb03417b test(home): HomeActionMapperTest (JVM) + HomeContentRenderSmokeTest (instrumented)
Part 3 of the Home refactor. Adds the durable coverage the plan called for:
- HomeActionMapperTest: 12 JVM cases over the lifted pure mapper (refresh states,
  withHomeActions pairing/daily/loading/error paths, toHomeLabel mc-drop, secondary
  cap of 3, C-HOME-001 primary/pending dedup) — locks the extraction as faithful.
- HomeContentRenderSmokeTest: instrumented render net for Home (via the VM-free
  PairedHomePreviewScreen), light + dark — catches 'composes fine, crashes on
  first paint'. Runs on a THROWAWAY only (uninstalls app-under-test).

JVM test green; androidTest compiles. Fills the 'Home has no UI test' gap.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 20:46:16 -05:00
null 9e81c15350 perf(home): @Immutable on HomeAction/PendingActionCard/HomeCategorySummary
Part 2b (Compose stack advantage). Marks the three verified-deeply-immutable
card models @Immutable so their single-instance params get structural-equality
skipping (upgrade over the K2 reference-equality strong-skipping default).
HomeAnswerStats/HomeUiState left unannotated (LocalAnswer/Question/Set transitive
types not fully audited — a false @Immutable promise would cause stale UI).
Rendered output identical; only recomposition frequency drops. compile green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 20:42:04 -05:00
null 7be9a1bf15 refactor(home): lift pure action mapper -> HomeActionMapper.kt (VM 867->562)
Part 2b. Moves the ~305 lines of pure HomeUiState action-derivation extensions
(refreshDailyQuestionState/withHomeActions internal; toHomeAction/
buildDailyQuestionAction/buildPendingActions/has*/toHomeLabel private) out of
HomeViewModel verbatim (de-indented, byte-identical bodies). Verified pure — no
this@HomeViewModel/repo refs. VM call-sites unchanged (same-package extensions).
compile + full unit suite green. HomeViewModel now 562 lines (from 1030).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 20:40:31 -05:00
null 227d141221 refactor(home): extract data models -> HomeModels.kt (VM 1030->867)
Part 2a. Moves the Home data classes/enums + computeDailyQuestionState (kept
@VisibleForTesting internal) + gameRouteFor (widened private->internal, its only
caller loadHome is same-package) out of HomeViewModel into HomeModels.kt.
Same package -> no consumer import churn (AppNavigation, DailyQuestionStateTest
unchanged). Behavior-identical. compile + ui.home unit tests green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 20:38:29 -05:00
null 415f8d6866 refactor(home): extract StreakMilestoneDialog + drop dead MomentCueCard
Step 9/9 of the UI split. StreakMilestoneDialog -> components/ (kept internal;
ArtPreviewScreen call updated to the new package). MomentCueCard was dead (no
callers) — removed. Orphaned imports cleaned. compileDebugKotlin green.
HomeScreen.kt now ~620 lines (from 1921).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 20:34:37 -05:00
null 94360a5438 refactor(home): extract header + partner sheet -> components/HomeHeaderSection.kt
Step 8/9. Verbatim move (HomeHeader + PartnerQuickActionsSheet public, rest private;
@OptIn(ExperimentalMaterial3Api) + .semantics a11y + full state param preserved).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 19:43:22 -05:00
null c13dbc5bfd refactor(home): extract primary/activation cards -> components/HomePrimaryCards.kt
Step 7/9. Verbatim move (activation + primary hero public, benefit-pill private).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 19:40:08 -05:00
null d324efe182 refactor(home): extract status strip -> components/HomeStatusStrip.kt
Step 6/9. Verbatim move (strip public, chip private). compileDebugKotlin green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 19:32:54 -05:00
null 48197ffd1e refactor(home): extract action feed -> components/HomeActionFeed.kt
Step 5/9. Verbatim move (section public, card private). compileDebugKotlin green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 19:31:42 -05:00
null 88460922c3 refactor(home): extract pending cards -> components/HomePendingCards.kt
Step 4/9. Verbatim move (section public, card-view private). compileDebugKotlin green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 19:30:25 -05:00
null 07c810c9a1 refactor(home): extract category preview -> components/HomeCategoryPreview.kt
Step 3/9. Verbatim move (grid public, mini-card private). compileDebugKotlin green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 19:29:06 -05:00
null 18ad31506e refactor(home): extract loading/error/empty -> components/HomeStateCards.kt
Step 2/9. Verbatim move, public entries. compileDebugKotlin green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 19:27:52 -05:00
null 5f39194c00 refactor(home): extract shared card kit -> components/HomeActionStyle.kt
Step 1/9 of the HomeScreen decomposition. Moves the 6 shared style decls
(homeActionGlyph, HomeGlyphIcon, homePrimaryArt, HomeActionColors,
HomeActionTone.actionColors, HomePill) verbatim into ui/home/components/, made
public per the components/ visibility idiom. No behavior change. compileDebugKotlin green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 19:26:08 -05:00
null a63bf350b1 fix(crash): LocalDate.ofInstant crashes on API<34 + add Lint/R8 CI gates
Adding Android Lint to CI immediately caught two real crash bugs invisible to
all emulator QA (the fixture emulators run API 34+):

- SettingsViewModel + YourProgressViewModel called LocalDate.ofInstant, which
  was only added in API 34 — but minSdk is 26. On every device running Android
  8-13 (the bulk of the install base) these throw NoSuchMethodError and crash
  the Settings and Your Progress screens. Fixed with the API-26-safe equivalent
  Instant.atZone(zone).toLocalDate() (same result).

The other two Lint errors were false positives (ProduceStateDoesNotAssignValue
on two EncryptedChatImage composables that DO assign value inside the producer —
the check misfires on a suspend/?.let RHS) — explicitly @Suppress'd with a note,
so Lint reaches 0 errors legitimately rather than via a blanket baseline.

CI (android-ci.yml) gains two jobs:
- android-lint: ./gradlew :app:lintDebug (fails on error-severity; 113 existing
  warnings are non-fatal and left for a separate burndown).
- release-build: first-ever R8 gate — builds :app:bundleRelease with a throwaway
  keystore + dummy RC_API_KEY (satisfies the release guards; AAB not distributed),
  so the minify/shrink/sign toolchain can never silently rot. Verified locally:
  bundleRelease SUCCEEDS today (95MB AAB). A green build proves the toolchain,
  not runtime survival of reflectively-loaded classes (Tink) — that stays a
  release-APK QA item.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 18:41:04 -05:00
null 4e807cb088 fix(settings): Manage-subscription deep link used namespace, not applicationId
ExternalLinks.MANAGE_SUBSCRIPTION pointed Play's subscription manager at
package=app.closer (the code namespace); the Play package is the applicationId
closer.app. In production the Manage button would miss the app's subscription
entry. Invisible to all QA so far because no real subscription has ever existed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 18:22:03 -05:00
null 6668a3c4b7 refactor(brand): in-app prose -> 'Closer Couples' (launcher + wordmark stay 'Closer')
Extends the store/marketing rename to in-app user-facing PROSE, per owner decision.
Every string where 'Closer' names the app in a sentence now reads 'Closer Couples'
(Android + iOS): settings, pairing, invite share, widget headlines, paywall
subhead/thank-you, age gate, privacy body.

Deliberately KEPT as 'Closer' (judgment calls, documented in docs/NameChange.md):
- launcher label / iOS CFBundleDisplayName (14 chars truncates under the icon)
- wordmark logos (onboarding, auth alt-text, widget header, share-card drawText,
  iOS onboarding) + their asserting tests
- 'Grow Closer' onboarding pun (intimacy, not the app name)
- entity/length-capped lines: privacy rotator 'so Closer cannot read them'
  (hard 64-char cap), iOS 'not even Closer's servers'
- iOS 'Settings > Closer' help path (must match the kept display name)
- 'Closer Premium' tier label (matches the RevenueCat entitlement display name)
- all technical identifiers (app.closer / closer.app / closer_premium / etc.)

TodayWidgetTest exact-match assertions updated. compileDebugKotlin + unit tests green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 17:07:31 -05:00
null 2c3c2df8c4 fix(copy): reconcile divergent premium benefit lists + drop inaccurate claim
The paywall and subscription screens showed two different premium benefit lists (centralizing
into CloserCopy surfaced the drift). Unified to a single CloserCopy.premiumBenefits used by both.

Also an accuracy fix: dropped "Exportable memories" — it directly contradicts the app's own
privacy copy ("Closer does not currently offer a data export", strings.xml privacy_no_export_body),
i.e. a benefit the app explicitly does not provide. Remaining items are all verified real features
(QuestionComposer, Connection Challenges, Desire Sync, Memory Lane, date planning, answer history).

compileDebugKotlin clean; single source of truth for both surfaces going forward.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 00:50:29 -05:00
null 23cd213953 refactor(copy): Slice 1 — dedup age-gate copy into CloserCopy.AgeGate
The 18+ min-age error was triplicated verbatim (SignUpViewModel, CreateProfileViewModel ×2);
consolidated to CloserCopy.AgeGate.ageError(minAge). Also lifted the two disclaimer variants
(kept distinct: brief on sign-up, with-reason on the DOB step — not unified) and the duplicated
"Please enter your date of birth." prompt. 6 call sites now source from the catalog; copy is
byte-identical (no behavior change), compile clean.

Adds docs/CopyMigration.md — the migration plan, scoped (after a gap review) to the ~15-25
brand-voice/duplicated lines, NOT all ~281 UI literals: the bulk stays inline until the single
i18n → strings.xml pass, since double-migrating to a Kotlin catalog first is wasted work.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 00:39:04 -05:00
null 0195564d25 docs(readme): refresh ready-state home screenshots 2026-07-09 00:30:27 -05:00
null 51efff3f78 refactor(copy): start CloserCopy catalog — migrate paywall + subscription voice copy
Establishes a typed Kotlin copy catalog (ui/brand/CloserCopy.kt), sibling to the existing
CloserBrandCopy (privacy rotator). Rationale over strings.xml: the app is English-only and
pre-launch, so a typed catalog gives one-place brand-voice review + compile-time safety
without Compose stringResource() friction or orphaned-string drift. strings.xml's real value
is the localization pipeline, which we migrate to in one pass when i18n is on the roadmap
(gate recorded in Future.md).

First slice (highest-value, monetization surface):
- PaywallScreen + SubscriptionScreen voice copy (benefit lists, headlines, value props,
  "Thank you for supporting Closer", couple-shared taglines) now source from CloserCopy.
- Generic chrome ("Continue", "Restore", "Manage subscription", error-retry) intentionally
  stays inline — it isn't brand voice.
- Surfaced real copy drift: the paywall and subscription benefit lists differ (swap two
  items + reorder) — co-located and flagged in CloserCopy.kt for a copy decision, left
  verbatim (not silently unified).

compileDebugKotlin clean; no behavior change (copy identical, just relocated).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 00:26:15 -05:00
null 59e033692b chore(cleanup): purge 55 unused string resources
strings.xml had 127 entries but the app renders copy from hardcoded Compose literals
(only ~4 files use stringResource) — so ~half of strings.xml was a resource catalog that
was pre-created for the pairing/home/partner-home/settings-nav screens and never wired up.

Removed the 55 entries with zero R.string./@string references anywhere (kt/xml/manifest),
including whole dead sections (Settings nav labels, all Pairing subsections, Home screen,
Partner home). Kept everything actually referenced: app_name, today_widget_description,
common actions in use, and the Appearance/Notifications/Account/Privacy sections.
Resources compile clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 00:20:32 -05:00
null 21392ec2f3 chore(cleanup): remove verified dead files (Tier 1 dead-file sweep)
Removes files confirmed unreferenced by symbol-level git-grep and proven safe by a
clean :app:compileDebugKotlin + compileDebugUnitTestKotlin (no main/test references).

Dead code (every top-level type had 0 external references):
- core/notifications/NotificationHelper.kt — dead duplicate of the live
  NotificationChannelSetup (which is what CloserApp/AppMessagingService actually call)
- core/notifications/NotificationPermissionHelper.kt — unused permission helper
- notifications/PartnerNotificationScheduler.kt — superseded by PartnerNotificationManager
- data/questions/QuestionJsonParser.kt — superseded by the Room/asset-DB path
- data/repository/FakeQuestionRepository.kt — orphaned fake, no test/DI consumer
- ui/questions/QuestionDetailViewModel.kt — superseded VM, no composable binds it
- domain/model/{Entitlement,InviteStatus,QuestionSessionStatus}.kt — unused models/enums
  (entitlement state is read as Firestore booleans, not this model)

Stray artifacts:
- gitleaks-current.json / gitleaks-history.json — sanitized scan output (history = []),
  referenced by no CI/config; regenerable
- 19 stale .gitkeep placeholders in directories that now hold real files

Deliberately KEPT (flagged but not dead): WheelHistoryScreen/ViewModel.kt (named
"WheelHistory" but declare the live, nav-wired GameHistoryScreen/GameHistoryViewModel).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 00:10:40 -05:00
null b47302d6b7 docs(readme): refresh daily question screenshots 2026-07-08 14:53:12 -05:00