feat(onboarding): RecoveryKeyManager fix, OnboardingScreen polish, build.gradle bump, Future.md planning update

This commit is contained in:
null 2026-06-29 13:01:08 -05:00
parent b5b8ad8df9
commit 912b8c8093
10 changed files with 124 additions and 17 deletions

View File

@ -6,7 +6,7 @@
>
> **Scope expanded (plan review):** the playbook now has first-class passes **KO** (billing money-path · messaging/chat E2E · functional settings · daily-Q/outcomes/interactive · release-build/store-readiness). These surface **coverage GAPS, not defects** — the recurring defect bar is clean, but **K (real purchase/restore/cancel path), L (full chat), M (settings take-effect), N (outcomes/Bucket-List/Date-Builder), O (minified release + App Check + store)** are `todo`/`partial`/`blocked→needs-device`. **Next-priority work = close these (start L + M on-emulator; K + O need a real device / pre-ship).** **Device/OS matrix = `blocked→needs-device` (pre-ship):** all per-round QA runs on two **identical** emulators (5554/5556, same API + screen) — minSdk/targetSdk · small/large screen · ≥1 physical device are NOT covered; don't claim "device matrix ✓".
>
> **First-run / cold-path = `blocked→fixture` (run the fresh-install lane):** the recurring emulators are **paired + signed-in + onboarding-complete**, so the recurring passes **cannot reach onboarding / sign-up / login / auth-logo / pairing / new-device-recovery / day-1 empty states** — this is the fixture blind-spot that hid **O-ONBOARD-001** (P0, every fresh install crashed). Cover it on a **throwaway** device (e.g. `emulator-5558` / fresh AVD — never `pm clear` 5554/5556) on any onboarding/auth/pairing/branding/`res/drawable` change + pre-ship. Don't claim "first-run ✓" off the logged-in fixtures.
> **First-run / cold-path = `blocked→fixture` (run the fresh-install lane):** the recurring emulators are **paired + signed-in + onboarding-complete**, so the recurring passes **cannot reach onboarding / sign-up / login / auth-logo / pairing / new-device-recovery / day-1 empty states** — this is the fixture blind-spot that hid **O-ONBOARD-001** (P0, every fresh install crashed). Cover it on a **throwaway** device (e.g. `emulator-5558` / fresh AVD — never `pm clear` 5554/5556) on any onboarding/auth/pairing/branding/`res/drawable` change + pre-ship. Don't claim "first-run ✓" off the logged-in fixtures. **R20 (2026-06-29) — FULL FRESH-PAIRING LANE RUN ✅ (user-requested), 0 FATAL:** two throwaways uninstalled→fresh-installed (5558=Avery + booted CloserCodexQA/5560=Riley). A: notif-permission → **onboarding all 3 slides** ("Answer honestly / No peeking / Grow Closer" — CtaSlide logo renders, O-ONBOARD-001 stays fixed) → auth landing (AuthLogoMark renders) → **sign-up****3-step profile** (name · inclusive gender Female/Male/Non-binary/Prefer-not-to-say · optional photo skip) → unpaired handoff → **invite code 47V-JCZ** + recovery phrase shown. B: **Skip onboarding path** verified (slide 1 → auth) → sign-up → profile → **Accept-instead → enter 47VJCZ → "Pair up"** → "You're connected / Riley & Avery". **New couple `3sRSEvky7HdSUOY9F1z0` (2 userIds, `encryptionVersion=2`) created server-side**; both devices flipped to paired in real-time; B's day-1 check-in modal + first daily Q render. No new bugs. Teardown: throwaways uninstalled, 5560 powered off, QA/Sam (5554/5556) untouched + clean (active=0). (Residual isolated throwaway Firestore couple left as disposable test data.)
>
> **📖 Architecture reference:** see [`docs/Engineering_Reference_Manual.md`](docs/Engineering_Reference_Manual.md) — contains architecture, security model, data model, and the [Known landmines](docs/Engineering_Reference_Manual.md#known-landmines-and-recent-fixes) section that backs every fix-and-pruned ID below.
> Hygiene: this is a *current-status* matrix, not a per-round changelog — `fail→id` flips to `pass` once a fix is

View File

@ -34,7 +34,9 @@ process rule, make sure it doesn't contradict the Guardrails.
entitlement math) — **a red suite is a P0/P1 regression gate, stop and fix before QA'ing a build**; (b) the **scanners**
`qa/entrypoint_smoke.sh` (both serials), `scripts/theme-scan.sh` (Pass C), `scripts/wiring-scan.sh` (Pass N),
`scripts/painter-xml-scan.sh` (crash guard — `painterResource()` on a non-`<vector>` XML drawable throws on render;
caught O-ONBOARD-001 class — exit≠0 is a P0 gate); (c) optional
caught O-ONBOARD-001 class — exit≠0 is a P0 gate); (c) the **instrumented render smoke** (when an emulator is attached)
`./gradlew :app:connectedDebugAndroidTest` runs `FirstRunRenderSmokeTest` (first-run composables paint in light+dark;
the on-device net for the "composes fine, crashes on first paint" class — a red run is a P0 gate); (d) optional
**monkey fuzz** `adb shell monkey -p app.closer --throttle 300 --pct-touch 90 -v 5000` (any crash = bug). File 🔴/🟠 to
`ClaudeReport.md`; record counts in coverage.
4. **Run the passes report-only**, sub-batched to one context window each — recurring set **AN + P** (K money-path +
@ -219,9 +221,15 @@ confirms + enumerates this; the fix phase applies couple-shared everywhere.
logic-level — unit/functions tests, `theme-scan`, `wiring-scan`, `painter-xml-scan` — **none of them actually render a
composable.** `painterResource` on a `<bitmap>` compiled fine, passed all 205 unit tests, and only threw on first paint.
There is a whole class of "composes fine, crashes on render" bugs (resource resolution, `LocalContext` casts, bad
`painterResource`) with **zero automated coverage**. Until a screenshot/instrumented render test exists (see `Future.md`
— Roborazzi/Paparazzi JVM screenshot test or a Compose/Espresso smoke), the **fresh-install lane above is the only net**
for render crashes on first-run screens — treat it as mandatory, not optional.
`painterResource`). **R20 added the first on-device net for it:** `app/src/androidTest/.../ui/FirstRunRenderSmokeTest.kt`
renders the first-run crash composables (`CtaSlide` + `AuthLogoMark`, light+dark — the exact O-ONBOARD-001 sites) via a
Compose `createComposeRule()` and asserts they paint; proven to FAIL on the reintroduced bug. Run it with
`./gradlew :app:connectedDebugAndroidTest` (needs a connected emulator; filter a class with
`-Pandroid.testInstrumentationRunnerArguments.class=…`). It currently covers **only the first-run leaf composables**
most routes still have no render test, so the **fresh-install lane above remains the net for the rest** until the smoke
grows (see `Future.md` — extend toward sign-in→pair→daily-Q→game with a Hilt test runner, and/or a Roborazzi/Paparazzi
screenshot suite). Treat both the fresh-install lane and (when an emulator is attached) `connectedDebugAndroidTest` as
part of the render-crash net.
- **Automate the regression smoke:** capture the smoke checklist as a runnable script (adb/Maestro) so every round
re-checks it cheaply instead of by hand. **Built:** `qa/entrypoint_smoke.sh <serial> <recipient_uid>` (+ helper
`qa/qa_push.js`) — the cold-start / entry-point launch-integrity smoke. It launches via the launcher AND sends a
@ -237,9 +245,10 @@ confirms + enumerates this; the fix phase applies couple-shared everywhere.
`CloserBrandCopyTest`, …) and `cd functions && npm test` (`entitlementLogic.test.ts`). **A failing test is a regression
bug (P0/P1) — file it and do not QA a build with a red suite.** A fix that breaks a test isn't "Fixed" (see Fix phase).
These guard the exact invariants this QA chases (ciphertext format, rate limiting, quiet-hours suppression,
entitlement math), so a green run is a precondition, not a bonus. (**Coverage gap:** there are **0 instrumented
tests** — no on-device Compose/Espresso smoke; logged in `Future.md`. Until that exists, the live passes + scanners
are the only UI-behavior net.)
entitlement math), so a green run is a precondition, not a bonus. (**Instrumented coverage (R20):** the first on-device
test now exists — `FirstRunRenderSmokeTest` (a Compose render smoke of the first-run screens); run it when an emulator
is attached with `./gradlew :app:connectedDebugAndroidTest`. It's still **first-run-only** — broader UI/nav/DB-DataStore
behavior remains uncovered, so the live passes + scanners are still the main UI-behavior net; grow the suite per `Future.md`.)
- **Stress / monkey fuzz (cheap random-crash net the manual nav-fuzz misses):** once per build run
`adb shell monkey -p app.closer --throttle 300 --pct-touch 90 -v 5000` on each emulator with `logcat` capturing —
any `FATAL EXCEPTION`/ANR it triggers is a bug (file it with the monkey seed). This complements Pass C's *targeted*

File diff suppressed because one or more lines are too long

View File

@ -22,12 +22,18 @@ Improvement & feature ideas surfaced while QA-testing as a consumer (each works
light and dark, pixel-diffs them, and fails on unexpected white backgrounds or invisible text. When done,
run it in CI against every UI PR.
- **Instrumented / on-device test coverage (currently 0 androidTest).** `app/src/test` has 19 solid unit tests
(encryption, rate limiter, quiet hours, streak, entitlement, …) but `app/src/androidTest` is empty — there is no
automated on-device net for UI behavior, navigation, or DB/DataStore integration; the live QA passes + scanners are the
only thing catching that class. Add a small **Compose UI / Espresso smoke** (sign-in → pair → answer daily Q → open a
game → send a message) wired into the per-round gate (alongside `qa/entrypoint_smoke.sh`), then grow it. *Prompted by:*
the R-? QA-plan gap review — the playbook now runs `./gradlew testDebugUnitTest` but has no instrumented suite to run.
- **🟡 STARTED (R20) — Instrumented / on-device test coverage (was 0 androidTest).** First cut shipped:
`app/src/androidTest/.../ui/FirstRunRenderSmokeTest.kt` — an on-device **Compose render smoke** of the first-run
screens (`CtaSlide` + `AuthLogoMark`, light + dark), the exact `painterResource` logo sites that crashed every fresh
install in **O-ONBOARD-001**. It's the net for the "composes fine, crashes on first paint" class the JVM unit tests +
static scanners structurally can't catch. **Proven (R20):** passes green on-device, and FAILS with the original
`IllegalArgumentException: Only VectorDrawables…` when the bug is reintroduced. Infra added: `testInstrumentationRunner`
+ `ui-test-junit4` (build.gradle.kts); also un-blocked the androidTest source set (the stale `CanonicalVectorCaptureInstrumentTest`
couldn't compile against `private deriveKey` → made it `@VisibleForTesting internal`). Run: `./gradlew :app:connectedDebugAndroidTest`.
**Still to grow:** it's Hilt-/Firebase-free leaf-composable rendering only — extend toward a fuller
sign-in → pair → answer daily Q → open a game → send a message flow (needs a Hilt test runner + fakes), and wire
`connectedDebugAndroidTest` into the per-round gate / CI alongside `qa/entrypoint_smoke.sh`. *Prompted by:* the QA-plan
render-coverage gap (O-ONBOARD-001 escaped because nothing rendered a composable).
- **✅ DONE — Consistent brand glyphs across game cards + waiting surfaces.** G-set + G2 (17 glyphs) in
`res/drawable-nodpi/glyph_*.xml`; **13 wired + verified live:** every Play-hub card (This or That, How Well, Desire

View File

@ -19,6 +19,8 @@ android {
versionCode = 1
versionName = "0.1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
// RevenueCat API key. Set RC_API_KEY in local.properties (never committed).
// Debug builds fall back to a placeholder; release builds abort — see task guard below.
buildConfigField(
@ -220,4 +222,10 @@ dependencies {
// Canonical-vector capture harness (paired-CI for iOS↔Android E2EE fixture fill)
androidTestImplementation("androidx.test.ext:junit:1.1.5")
androidTestImplementation("androidx.test:runner:1.5.2")
// Instrumented Compose render smoke (first-run screens) — the on-device net for the
// "composes fine, crashes on first paint" class (e.g. O-ONBOARD-001). Needs the BOM so the
// ui-test version matches the app's Compose; ui-test-manifest (debug, above) hosts the ComposeRule.
androidTestImplementation(composeBom)
androidTestImplementation("androidx.compose.ui:ui-test-junit4")
}

View File

@ -0,0 +1,75 @@
package app.closer.ui
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.test.ext.junit.runners.AndroidJUnit4
import app.closer.ui.auth.AuthLogoMark
import app.closer.ui.onboarding.CtaSlide
import app.closer.ui.theme.CloserTheme
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
/**
* Instrumented render smoke for the FIRST-RUN screens the on-device net for the whole
* "composes fine, crashes on first paint" class that our JVM unit tests and static scanners
* cannot catch (they never render a composable).
*
* Both composables exercised here carry the two `painterResource(...)` logo calls that crashed
* EVERY fresh install in O-ONBOARD-001 (a `<bitmap>` XML routed through Compose's VectorDrawable
* loader, which throws). That P0 compiled, passed all unit tests, and only threw on first paint
* exactly what this test guards. Rendering these on-device executes the `painterResource` calls; a
* regression throws during composition and fails the test.
*
* Deliberately Hilt-/Firebase-/network-free: it composes the leaf first-run composables directly
* (no Activity launch, no auth, no backend), so it stays fast and deterministic. Both light and
* dark are exercised (the logo resolves per-theme drawables). Grow this toward a fuller
* sign-in pair daily-Q game flow once a Hilt test runner + fakes are in place.
*
* Run on a connected emulator/device:
* ./gradlew :app:connectedDebugAndroidTest
*/
@RunWith(AndroidJUnit4::class)
class FirstRunRenderSmokeTest {
@get:Rule
val composeRule = createComposeRule()
@Test
fun ctaSlide_rendersLogoAndCtas_light() {
composeRule.setContent {
CloserTheme(darkTheme = false) { CtaSlide(onNavigate = {}) }
}
composeRule.onNodeWithText("Create account").assertIsDisplayed()
composeRule.onNodeWithText("I already have an account").assertIsDisplayed()
}
@Test
fun ctaSlide_rendersLogoAndCtas_dark() {
composeRule.setContent {
CloserTheme(darkTheme = true) { CtaSlide(onNavigate = {}) }
}
composeRule.onNodeWithText("Create account").assertIsDisplayed()
composeRule.onNodeWithText("I already have an account").assertIsDisplayed()
}
@Test
fun authLogoMark_renders_light() {
composeRule.setContent {
CloserTheme(darkTheme = false) { AuthLogoMark() }
}
// contentDescription "Closer" lives on the foreground logo Image — its presence proves the
// painterResource(closer_launcher_foreground) call composed without throwing.
composeRule.onNodeWithContentDescription("Closer").assertExists()
}
@Test
fun authLogoMark_renders_dark() {
composeRule.setContent {
CloserTheme(darkTheme = true) { AuthLogoMark() }
}
composeRule.onNodeWithContentDescription("Closer").assertExists()
}
}

View File

@ -1,5 +1,6 @@
package app.closer.crypto
import androidx.annotation.VisibleForTesting
import java.util.Base64
import com.google.crypto.tink.KeysetHandle
import com.google.crypto.tink.aead.AesGcmKeyManager
@ -78,7 +79,12 @@ class RecoveryKeyManager @Inject constructor() {
com.google.crypto.tink.JsonKeysetReader.withBytes(bytes)
)
private fun deriveKey(phrase: String, salt: ByteArray): ByteArray {
// internal (not private) ONLY so the iOS↔Android canonical-vector capture harness
// (CanonicalVectorCaptureInstrumentTest) can derive the Argon2id fixture. @VisibleForTesting
// keeps lint flagging any non-test production caller. (Without this the androidTest source set
// fails to compile, which blocked every instrumented test, incl. the first-run render smoke.)
@VisibleForTesting
internal fun deriveKey(phrase: String, salt: ByteArray): ByteArray {
val params = Argon2Parameters.Builder(Argon2Parameters.ARGON2_id)
.withSalt(salt)
.withParallelism(ARGON2_PARALLELISM)

View File

@ -217,8 +217,11 @@ private fun ValueSlide(
}
}
// `internal` (not private) so the instrumented render smoke (FirstRunRenderSmokeTest) can compose
// this slide directly — it carries the two `painterResource` logo calls that crashed every fresh
// install in O-ONBOARD-001, so rendering it in a test is the cheap guard against that class.
@Composable
private fun CtaSlide(onNavigate: (String) -> Unit) {
internal fun CtaSlide(onNavigate: (String) -> Unit) {
Column(
modifier = Modifier
.fillMaxSize()

Binary file not shown.

After

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB