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>
Critical fix: Purchases was never told the Firebase uid, so RevenueCat assigned its
own anonymous app_user_id. The revenueCatWebhook Cloud Function writes premium status
to users/{app_user_id}/entitlements/premium using that id — meaning a real purchase
would silently never unlock premium for the signed-in account, and CouplePremiumChecker's
partner-side read (same path, different uid) was broken by the same root cause.
RevenueCatBillingRepository now collects AuthRepository.authState and calls
Purchases.awaitLogIn(uid) on sign-in / awaitLogOut() on sign-out, guarded against
redundant calls and wrapped best-effort so a failed sync retries on the next auth event
instead of crashing the singleton.
Also:
- Bump com.revenuecat.purchases 8.20.0 -> 10.12.0 (verified: real published version,
stable API surface across 8->10 per RevenueCat's own migration notes for the calls
this app uses; confirmed resolved + full Hilt/KSP graph compiles clean).
- Purchase cancellation (user backs out of the Play billing sheet) is now distinguished
from a real failure via PurchasesTransactionException.userCancelled, using a shared
PURCHASE_CANCELLED_SENTINEL (same marker-constant idiom PaywallViewModel already uses
for offering-load failures) so PaywallViewModel resets silently instead of surfacing
the SDK's internal error text.
- PaywallScreen: genuine purchase errors (billing unavailable, network, etc.) now show
a snackbar. Previously there was zero user-facing feedback on a real purchase failure
beyond the loading spinner disappearing.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The daily reveal chip used displayCategoryName() (a different surface than the
Home pill fixed earlier), so it still showed 'Daily Fun Mc'. Fixed centrally in
the shared helper. Verified live: reveal now shows 'Daily Fun'.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Waiting-on-partner screen: 'Send a little nudge 💜' button (shown while the
partner is still playing, not once it's your turn) reusing the generic
thinking-of-you callable (10/day, quiet-hours-safe server-side); one-shot
Toast on result incl. friendly rate-limit copy. Verified live: nudge →
partner_activity push landed on the partner.
- Per-game banner copy: YOUR_TURN/RESULTS in-app banner now branches on gameType
('Your turn — guess their answers' for How Well, 'only mutual yeses ever show'
for Desire Sync, etc.) instead of one generic line; mirrored in the Cloud
Function's partner_completed_part push (yourTurnBody). Verified live.
- Accessibility: merged contentDescription on the This or That MatchScoreBadge
('You matched on N of M') and the How Well score ring Canvas ('You guessed N
of M correctly') — both were split/Canvas visuals invisible to TalkBack.
Unit + functions typecheck green; assembleDebug clean. Server copy change needs
a functions deploy (bundled with the C1 finish-guard).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- NextBeatCard: a self-contained 'what's next' card under every game's results
CTAs so finishing a game doesn't dead-end — shows 'Tonight's question is ready
→' when today's daily is unanswered, else a 'Day N together' streak note. Own
@HiltViewModel (DailyQuestionResolver + LocalAnswerRepository + couple), so the
four game screens only drop in the composable. Verified live on How Well results.
- How Well role swap: the guesser's results now lead with 'Your turn — let {name}
guess you', starting a new round where they become the subject (Newlywed-style
reversal; starter == subject). Verified live.
- Date Match: deterministic per-couple deck shuffle (kills 'every couple sees the
same first card') + skip already-swiped ideas so the deck resumes past them
instead of restarting at card 1. Verified live (swiped card no longer reappears).
- Desire Sync: remember served question ids per couple (SeenDesireQuestionStore on
the settings DataStore) so back-to-back rounds don't repeat prompts; clears the
record to cycle the pool when the unseen remainder can't fill a round.
- How Well pool purity: exclude daily_fun_mc novelty MCs from getQuestionsForPrediction
(1869 real prediction questions remain); DAO method has no other caller.
Unit suite green; assembleDebug clean; live-verified on the emulator pair.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- DOB picker (sign-up + profile): swap the View-based android.app.DatePickerDialog
(platform teal) for a shared Compose M3 DobPickerDialog that renders in the app
palette in both themes; future dates unselectable. Verified live dark = purple.
- SignupHandoff: back the pending DOB with the app's Preferences DataStore keyed
per-uid, instead of an in-memory singleton that died on process death and made
CreateProfile re-ask for the birth date after any restart mid-onboarding
(verified: am kill mid-profile → relaunch → no DOB re-ask). Local-only write,
so the auth-token/Firestore-rules race the old comment guarded against doesn't
apply; per-uid key prevents cross-account leakage.
- Connection Challenges: expose statusDay (calendar-actionable day) on
ChallengeState; the day card shows a 'Day N unlocks tomorrow 🌙' teaser instead
of spoiling the next prompt the moment today's step is marked done. +2
ChallengeStateMachine tests.
Unit + functions suites green; verified live on the emulator pair.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Challenges catalog: hide the 🔒 Premium badge once the couple has premium
(A-003b; matches the Play hub's showPremiumBadge pattern). Verified live
both directions under an authorized grant/revoke cycle.
- Game banner lifecycle (BANNER-LIFE-001): entering a session's screen now
consumes any banner pointing at it (GamePromptController.consumeForSession
wired into ActiveGameSessionMonitor.enter), and activity from a DIFFERENT
session may replace a stale persistent banner. Verified live: no banner on
reveal; stale RESULTS banner replaced by a new session's prompt.
- Waiting/join screen: says 'Your turn — {name} already played their part'
for the non-starter once a first part landed (new partHasFinished mapped
from partFinishNotifiedAt; completedByUsers only fills at reveal). Session
observe mapping now also carries completedByUsers/joinedByUsers.
- How Well results: matched-row colors are now a theme-aware container+content
pair (dark mode was near-invisible: fixed pale-green container under
onSurfaceVariant text).
- Date Match: top card fully opaque — next card's text no longer bleeds
through (was alpha 0.96).
- functions: don't send 'X finished — see your results!' for abandoned/quit
sessions (status flips to completed with empty completedByUsers; a real
completion always has both uids). Found live when a quit triggered a false
banner. Needs deploy (bundled with C4).
Unit + functions suites green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Delete unused ui/theme/Color.kt (zero refs; live palette is Theme.kt).
- Remove orphaned EditProfileScreen wrapper composable (no route references it;
AccountScreen embeds EditProfileContent, which stays).
- Debug-gate ArtPreviewScreen + PairedHomePreviewScreen out of the release nav
graph (BuildConfig.DEBUG).
- Collapse duplicate wheel_history route into game_history (same screen; wheel
surfaces now navigate to Past Games directly).
- CouplePremiumChecker: inject AuthRepository instead of raw
FirebaseAuth.getInstance() (finishes the in-flight auth-DI refactor for
non-data-layer call sites).
- Copy/UI polish: 'It's a match!' (matches the push copy), internal 'mc' token
never renders in the Home daily pill ('Daily Fun Mc' → 'Daily Fun'),
welcome/splash privacy tagline no longer clips mid-sentence (maxLines 4 +
ellipsis, AUTH-TRUNC-001).
Unit + functions suites green; assembleDebug clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Game answer listeners (ToT/HowWell/DesireSync): close(err) instead of
swallowing snapshot errors, + .catch in each VM's observeReveal surfacing a
retryable ERROR (GameCopy.SYNC_ERROR + retrySync re-attach) — games no longer
hang on WAITING forever on listener failure (GAME-HANG-001; matches the
Wheel/Capsule sources' established pattern).
- Daily question: paired fallback pool is now premium-INDEPENDENT so both
partners always resolve the same deterministic question; viewer-premium pools
broke the couple contract when entitlement state differed or flipped mid-day
(DQ-MISMATCH-001, reproduced live: partners answered different questions).
- Daily reveal: humanize raw option-id fallbacks so slugs never render
(DQ-SLUG-001, e.g. 'fake_awards_should_be_mandatory').
- assignDailyQuestion.ts: replace hardcoded CST_OFFSET_HOURS=-6 with DST-safe
Intl America/Chicago helpers (DST-001) + 5 regression tests (CDT/CST labeling,
6PM reveal instant, spring-forward round-trip).
Verified live 2-device: identical daily question on both partners post-fix;
ToT full loop 5/5 reveal regression clean. Unit 244 + functions 58 green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
DailyQuestionViewModel and PartnerHomeViewModel each held a raw
db.collection(...).addSnapshotListener on the partner's answer doc (just reading
snapshot.exists()). Replace with the existing, proven
FirestoreAnswerDataSource.observeAnswerForUser(...) Flow collected in a
viewModelScope Job (auto-cancelled; previous run cancelled on restart) — dropping
the FirebaseFirestore injection from both. Same behavior (exists → non-null),
now through the data layer.
Verified live: daily question shows the correct "waiting for your partner" state
via the new Flow; compiles + unit suite green.
HomeViewModel's two listeners use MetadataChanges.INCLUDE + inline analytics and
drive the core reveal — left as a scoped follow-on (needs a metadata-aware
data-source Flow + two-device real-time verification). See docs/standardization-review.md.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Five ViewModels reached into the FirebaseAuth.getInstance() static singleton for
the current user id; switch them to the injected AuthRepository.currentUserId
(BucketList, QuestionThread, DateBuilder, MessagesInbox, Conversation). Removes
the Firebase-in-presentation-layer coupling and makes them testable. No behavior
change; compiles + unit suite green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Centralize all plugin + dependency versions in gradle/libs.versions.toml (the
modern Gradle standard) and switch the root + app build scripts to the generated
libs.* / alias(libs.plugins.*) accessors. No version changes — a 1:1 migration of
the 57 hardcoded coordinates. Full assembleDebug + unit suite green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>