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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
'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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>