chore(repo): keep QA fixture docs local-only
This commit is contained in:
parent
694294c2e5
commit
6af9c15833
|
|
@ -16,6 +16,7 @@ iphone/.build/
|
||||||
iphone/Package.resolved
|
iphone/Package.resolved
|
||||||
|
|
||||||
# Private project docs (agent-only, never commit)
|
# Private project docs (agent-only, never commit)
|
||||||
|
Claude*.md
|
||||||
FUTURE.md
|
FUTURE.md
|
||||||
HISTORY.md
|
HISTORY.md
|
||||||
PROJECT.md
|
PROJECT.md
|
||||||
|
|
@ -93,5 +94,6 @@ ClaudeReport.md
|
||||||
relationship-app.code-workspace
|
relationship-app.code-workspace
|
||||||
|
|
||||||
# QA fixtures with test credentials — local only, never commit
|
# QA fixtures with test credentials — local only, never commit
|
||||||
|
qa/README.md
|
||||||
qa/fixtures.local.md
|
qa/fixtures.local.md
|
||||||
qa/*.local.md
|
qa/*.local.md
|
||||||
|
|
|
||||||
|
|
@ -1,335 +0,0 @@
|
||||||
# Closer — Branding & Artwork Review (Pass H)
|
|
||||||
|
|
||||||
Living status document for Closer's brand artwork pass. It records which illustrations/glyphs are live, which surfaces
|
|
||||||
should reuse existing assets, and which brand issues belong in implementation or QA rather than image generation.
|
|
||||||
|
|
||||||
**Asset ownership:** Codex is responsible for making all needed images, dark variants, and custom glyphs. Do not leave
|
|
||||||
image work as user-generated prompt handoff; backlog items here are Codex-owned assets to generate, add to the repo, and
|
|
||||||
verify.
|
|
||||||
|
|
||||||
**Current state (2026-06-28 re-audit against the code/resources — supersedes the 2026-06-27 audit):**
|
|
||||||
- **Backlog 1 (dark illustration variants) — essentially DONE.** `drawable-night-nodpi/` now holds a dark variant for
|
|
||||||
**every** non-transparent surface: all 22 `illustration_*` (incl. all `illustration_couple_*` heroes, `daily_question`,
|
|
||||||
`partner_activation`, `tonight_partner_prompt`, `together_empty`) **and all 10 `pack_art_*` banners**. Only **3**
|
|
||||||
transparent celebration assets have no dark variant — `illustration_premium_unlock`, `illustration_spin_wheel`,
|
|
||||||
`illustration_streak_milestone` — which rule 1 exempts *if* they read on both themes (verify, don't assume). Decoupled
|
|
||||||
in-app-Dark + system-Light now renders the dark art correctly app-wide after the **C-DARKART-002** fix (see below), so
|
|
||||||
the "light/pink art on a dark screen" defect class is closed for these surfaces.
|
|
||||||
- **Backlog 2 (custom glyphs) — ✅ DONE (2026-06-28).** Every generic Material icon has been replaced with a Closer
|
|
||||||
glyph: **0 `Icons.*` call sites and 0 `androidx.compose.material.icons` imports remain** (was 187 across ~49 distinct
|
|
||||||
icons). Wiring goes through a central `ui/components/CloserGlyphs.kt` accessor object (`@Composable` getter vals →
|
|
||||||
`ImageVector.vectorResource(R.drawable.glyph_*)`), used by 53 files; `CategoryGlyph.kt` resolves category glyphs the
|
|
||||||
same way. Build + 205 unit tests green; verified live both themes (back, check, close, lock, chat, heart, photo,
|
|
||||||
camera, mic, category badges all render with correct tint). **BRAND-ICON-CUSTOM is resolved.**
|
|
||||||
- **C-DARKART-002 (theming mechanism, fixed 2026-06-28):** `MainActivity` now drives `AppCompatDelegate.setDefaultNightMode`
|
|
||||||
from the in-app `ThemeMode`, so the real Configuration `uiMode` follows the app theme and **all** art resolution —
|
|
||||||
`painterResource` AND `BrandIllustration`, `drawable-night*` included — follows the in-app theme. This supersedes the
|
|
||||||
old "only `BrandIllustration` follows the theme" caveat.
|
|
||||||
- Done earlier: This-or-That redesign (C-DARK-UI-001) + Premium-unlock modal (A13).
|
|
||||||
|
|
||||||
> Branding **defects** (off-brand color, clipped/low-contrast art, a **light image on a dark screen**, a **generic
|
|
||||||
> Material icon**) → `ClaudeReport.md`. Pure "could be warmer / feature" ideas → `Future.md` `## QA`. **New art/glyphs to
|
|
||||||
> create** (dark variants, custom glyphs) → logged HERE as Codex-owned assets to make and verify.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Brand standards — MUST hold every QA round (Pass C visual + Pass H branding own these)
|
|
||||||
Two non-negotiable brand rules. A violation of either is a **bug** (`ClaudeReport.md`) **and** the asset to create is
|
|
||||||
logged here as Codex-owned image/glyph work:
|
|
||||||
1. **Every image has a LIGHT and a DARK variant that matches the IN-APP theme.** No light/pink art on a dark screen, no
|
|
||||||
dark art on a light screen. `drawable-nodpi/` = light, `drawable-night-nodpi/` = dark. Since **C-DARKART-002**
|
|
||||||
(2026-06-28) the dark variant is auto-selected by the real Configuration `uiMode`, which `MainActivity` syncs to the
|
|
||||||
in-app `ThemeMode` via `AppCompatDelegate.setDefaultNightMode` — so **both** `painterResource` and `BrandIllustration`
|
|
||||||
resolve `-night` correctly even in the decoupled in-app-Dark + system-Light state. Transparent/celebration art that
|
|
||||||
genuinely reads on **both** themes is the only exemption — **verify it, don't assume.** A surface missing its variant = bug.
|
|
||||||
2. **Every icon/glyph is a custom Closer glyph — NO generic Material icons, no generic hearts.** Material `Icons.*`
|
|
||||||
(ArrowBack, Favorite, Person, Lock, Star, PlayArrow, …) are placeholders, not brand. Each in use is replaced by a
|
|
||||||
bespoke glyph in the Closer house style, logged in the icon backlog below for Codex to make + add (`glyph_*`).
|
|
||||||
|
|
||||||
**Image style lock:** all generated imagery must match the existing shipped flat 2D vector assets, especially
|
|
||||||
`illustration_couple_paywall`, `illustration_couple_subscription`, `illustration_couple_onboarding`,
|
|
||||||
`illustration_daily_question`, and the `pack_art_*` banners. Keep shapes simple and graphic, with pastel fills, minimal
|
|
||||||
facial/detail rendering, clean vector-like edges, and gentle gradients only. **Reject** painterly/storybook rendering,
|
|
||||||
3D-ish lighting, realistic texture, detailed hair/skin shading, dramatic cinematic glow, hard rectangular backdrops, or
|
|
||||||
anything that looks materially richer than the existing asset family. For any new batch, generate and review **one sample
|
|
||||||
first** against the existing contact sheet before saving more assets.
|
|
||||||
|
|
||||||
## Image theme-variant coverage (light + dark per surface) — ✅ essentially COMPLETE
|
|
||||||
> Re-verified against `res/` on 2026-06-28. Every non-transparent surface now has BOTH a light (`drawable-nodpi/`) and a
|
|
||||||
> dark (`drawable-night-nodpi/`) asset with the identical filename.
|
|
||||||
|
|
||||||
| Illustration | Light | Dark | Status |
|
|
||||||
|---|---|---|---|
|
|
||||||
| **All 22 `illustration_*`** — account_deletion_goodbye · answer_history_empty · bucket_list_empty · connection_challenges_header · couple_history · couple_invite · couple_onboarding · couple_paywall · couple_subscription · daily_question · date_match_empty · date_match_success · memory_lane_capsule · messages_empty · pairing_success · partner_activation · privacy_recovery · quiet_hours · reveal_celebration · together_empty · tonight_partner_prompt | ✅ | ✅ | **both exist** — dark variants added since the 2026-06-27 audit |
|
|
||||||
| **All 10 `pack_art_*`** (communication, deep_reflection, desire, family_commitment, fun_date, future_goals, home_life, intimacy, money_values, trust_repair) | ✅ | ✅ | **both exist**; pack banners verified live dark (C-DARKART-002 round) |
|
|
||||||
| illustration_premium_unlock · illustration_spin_wheel · illustration_streak_milestone | ✅ (transparent) | — | transparent/celebration — **exempt per rule 1 IF they read on both themes; verify** (only remaining "no dark" assets) |
|
|
||||||
|
|
||||||
**Remaining (small):** spot-check the freshly-added dark variants in Pass C (light + dark) for contrast/clipping — they
|
|
||||||
exist + resolve correctly via the C-DARKART-002 uiMode-sync (pack art + Today hero verified live across all 4 theme/art
|
|
||||||
states); the rest resolve by the same mechanism but haven't each been eyeballed. Then verify the 3 transparent celebration
|
|
||||||
assets read on dark; add a dark variant only if one doesn't. **Orphan:** `illustration_couple_history` has both
|
|
||||||
light+dark variants but **no code reference at all** (superseded by `illustration_answer_history_empty`) — dead asset,
|
|
||||||
removal candidate. **Debug-only now:** `illustration_quiet_hours` was removed from the Notification-settings UI
|
|
||||||
(2026-06-28, unprofessional mid-list art) and is now referenced **only** in the debug `ArtPreviewScreen` gallery.
|
|
||||||
|
|
||||||
> **⛔ CLAUDE — decoupled-theme art now works app-wide** (C-DARKART-002): raw `painterResource(R.drawable.illustration_*`/
|
|
||||||
> `pack_art_*)` follows the in-app theme via the Configuration `uiMode` sync, so it no longer *needs* `BrandIllustration`
|
|
||||||
> to get the right `-night` variant. `scripts/painter-xml-scan.sh` (crash guard) + Pass C's decoupled check (system-light +
|
|
||||||
> in-app-Dark) remain the per-round guards.
|
|
||||||
|
|
||||||
## Icon/glyph audit — ✅ COMPLETE (every Material icon is now a Closer glyph)
|
|
||||||
> Done 2026-06-28: all 187 `Icons.*` call sites (~49 distinct icons) were swapped to `glyph_*` via the central
|
|
||||||
> `CloserGlyphs` accessor; **0 `Icons.*` and 0 `androidx.compose.material.icons` imports remain.** The table below is kept
|
|
||||||
> for the historical mapping (Material icon → glyph) and as the reference for any *new* icon added later: never introduce a
|
|
||||||
> raw `Icons.*` — add a `CloserGlyphs` accessor + `glyph_*.xml` instead. A `scripts/` guard that fails on any `Icons.`
|
|
||||||
> call site would keep this at zero (recommended).
|
|
||||||
|
|
||||||
| Generic icon (≈remaining uses) | Used for | Wire to `glyph_*` (✅ asset exists) |
|
|
||||||
|---|---|---|
|
|
||||||
| `ArrowBack` (31) | every top-bar back | `glyph_back` (brand chevron) |
|
|
||||||
| `Favorite` / `FavoriteBorder` (17/5) | **generic hearts** — likes, love, daily | `glyph_heart` (the Closer two-equal-halves heart, filled + outline) |
|
|
||||||
| `Lock` / `LockOpen` (18/2) | premium-locked, security | `glyph_lock` / `glyph_lock_open` (keyhole motif from the mark) |
|
|
||||||
| `Person` / `People` (16/1) | avatars/partner fallback | `glyph_person` / `glyph_couple` |
|
|
||||||
| `Check` / `Done` / `CheckCircle` (11/1/1) | confirm/selected/sent | `glyph_check` |
|
|
||||||
| `ArrowForward` / `ArrowForwardIos` (10/4) | row chevrons, next | `glyph_forward` |
|
|
||||||
| `PlayArrow` (8) | Play tab / start | `glyph_play` |
|
|
||||||
| `Close` (7) | dismiss/close | `glyph_close` |
|
|
||||||
| `Star` (6) | premium/★ ideas | `glyph_star` (brand sparkle) |
|
|
||||||
| `Visibility` / `VisibilityOff` (5/2) | password reveal | `glyph_eye` / `glyph_eye_off` |
|
|
||||||
| `ContentCopy` (4) | copy invite code | `glyph_copy` |
|
|
||||||
| `Sync` (3) · `Image`/`PhotoLibrary`/`PhotoCamera`/`AddAPhoto` (3/2/2/2) · `Send` (3) · `Chat` (3) · `Delete` (3) | retry · media pickers · send · messages · delete | `glyph_sync` · `glyph_photo`/`glyph_camera` · `glyph_send` · `glyph_chat` · `glyph_trash` |
|
|
||||||
| `Home` (2) · `Settings` (1) · `Notifications`/`NotificationsNone` (2) | bottom nav + settings | `glyph_home` · `glyph_settings` · `glyph_bell` |
|
|
||||||
| `LocalFireDepartment` (2) | **streak flame** (generic) | `glyph_streak` (brand flame/spark) |
|
|
||||||
| `Mic`/`Pause` · `Timeline`/`TrendingUp`/`Psychology` · `Fingerprint`/`Key`/`Shield` · `Edit`/`Add`/`Share`/`Refresh`/`Warning`/`CalendarToday`/`Cake`/`CardGiftcard`/`AttachMoney`/`HourglassEmpty`/`QuestionAnswer`/`Palette`/`OpenInNew` (1 each) | voice · progress · security · misc | one bespoke `glyph_*` each, in house style |
|
|
||||||
|
|
||||||
Replace each `Icons.*` call site with `ImageVector.vectorResource(R.drawable.glyph_*)` + `Icon(tint=…)` (the existing
|
|
||||||
wiring pattern — see any wired glyph for the idiom). The glyphs now all exist, so a remaining `Icons.*` call site is an
|
|
||||||
**unfinished wiring = a logged brand defect**, not acceptable for ship. A `scripts/` scanner that greps `Icons\.` call
|
|
||||||
sites (excluding files that already wire the matching glyph) would make this measurable per round — currently ~196 remain.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## R10 brand walk (2026-06-26) — existing art integration clean, 0 defects
|
|
||||||
R10 visual sweep doubled as the Pass-H existing-art integration check: Today (paired-books daily-question art),
|
|
||||||
Paywall (couple illustration), Security (padlock), Memory Lane / Date / Bucket-List empties, Home cards — **all render
|
|
||||||
on-brand, in-context, both themes, no clipping/placeholder/off-brand issues** (any defect would be a `ClaudeReport.md`
|
|
||||||
bug; none found). The new game-alert surfaces (`GamePromptBanner`, `GameWaitingHeroCard`) use the brand purple gradient
|
|
||||||
+ PlayArrow glyph and read as intentional action banners, so no illustration is warranted there.
|
|
||||||
|
|
||||||
## This or That gameplay brand plan (Codex QA, 2026-06-27) — ✅ IMPLEMENTED + verified live R13 (2026-06-27)
|
|
||||||
> **Done (R13, working tree).** `ThisOrThatScreen.kt` `ChoicePromptBackdrop` replaced: the two-circle + diagonal-line
|
|
||||||
> "diagram" is gone — now a soft theme-aware glow + two faint paired-card silhouettes low in the frame, never crossing
|
|
||||||
> the prompt. `OptionCard` A/B map to `colorScheme.primary`/`secondary` with high-contrast `onSurface` body text, visible
|
|
||||||
> accent borders, and a rich filled selected state (`onPrimary`/`onSecondary`). `VersusBadge`, progress bar, the
|
|
||||||
> `N/total` + "This or That" pills, the mood number-circle, and `TotLengthChips` are all theme-aware (no fixed
|
|
||||||
> `CloserPalette` darks). Light/dark previews added. Verified live both themes (5554 dark / 5556 light), 0 FATAL — closes
|
|
||||||
> C-DARK-UI-001. Results screen left as-is (verified clean in R12 dark). The plan below is retained for history.
|
|
||||||
|
|
||||||
Live review used a dedicated QA launcher/device (`CloserCodexQA`) with a fresh admin-created test couple
|
|
||||||
(`codex-this-or-that-*` / `codex-partner-*`). Screenshots checked the mood picker and active gameplay in light and dark
|
|
||||||
mode. The visual issue is real: light-mode option buttons are directionally good, but dark mode makes the current prompt
|
|
||||||
backdrop feel like an accidental placeholder.
|
|
||||||
|
|
||||||
**Verdict:** The current `ChoicePromptBackdrop` two-circle + diagonal-line drawing should be treated as a branding defect
|
|
||||||
for active gameplay. In dark mode the diagonal line cuts through the question, the circles turn muddy, and the whole
|
|
||||||
motif reads like a technical diagram instead of a warm private ritual for two.
|
|
||||||
|
|
||||||
**Plan**
|
|
||||||
- Replace the two circles + line with a Closer-native "private choice" motif: two soft sealed answer cards, paired-card
|
|
||||||
silhouettes, or a subtle C-heart/keyhole accent. Keep it decorative and low-contrast behind the prompt; no line should
|
|
||||||
cross the question text.
|
|
||||||
- Keep the light-mode option card shape, spacing, and button feel. Make the colors theme-aware rather than reusing fixed
|
|
||||||
purple/pink values in both themes.
|
|
||||||
- Dark-mode option cards need stronger contrast: readable body text, visible borders, and selected states that feel rich
|
|
||||||
instead of dim. Disabled/other-selected states should still be legible.
|
|
||||||
- Keep `closerBackgroundBrush()` as the foundation, but consider one very subtle game-local glow or paired-card vignette
|
|
||||||
in brand colors. The background should support focus, not compete with the question or options.
|
|
||||||
- Re-check the mood picker after gameplay is fixed. The numbered mood circles are acceptable, but paired-card glyphs or
|
|
||||||
warmer step badges would feel more branded than plain numbered bubbles.
|
|
||||||
- Results can reuse `illustration_reveal_celebration` and existing heart/petal particles for high-match moments; the game
|
|
||||||
surface itself should stay code-native rather than needing a full raster illustration.
|
|
||||||
|
|
||||||
**Implementation map**
|
|
||||||
- Update `app/src/main/java/app/closer/ui/thisorthat/ThisOrThatScreen.kt`, especially `ChoicePromptBackdrop`,
|
|
||||||
`OptionCard`, and `VersusBadge`.
|
|
||||||
- Add local theme-aware helpers for This-or-That option A/B colors, selected colors, disabled colors, border colors, and
|
|
||||||
prompt-backdrop alphas. Validate against both `MaterialTheme.colorScheme.background` and `surface`.
|
|
||||||
- Prefer Compose `Canvas`/shape drawing for the prompt motif. Generate raster art only if the code-native motif cannot
|
|
||||||
carry the brand; if generated, use transparent PNG art with no readable text.
|
|
||||||
- Add or update light/dark Compose previews for mood selection, active prompt, selected answer, waiting/disabled, and
|
|
||||||
results.
|
|
||||||
|
|
||||||
**Acceptance checks**
|
|
||||||
- Light and dark screenshots show no decorative element crossing or competing with the prompt text.
|
|
||||||
- Long prompts, long options, selected, unselected, and disabled states stay readable.
|
|
||||||
- The light-mode cards retain the current friendly button feel.
|
|
||||||
- Dark mode feels intentionally Closer-branded: aubergine/lavender/pink, soft and private, not a placeholder diagram.
|
|
||||||
- Logs are checked after opening a game from notification/deep link before assuming the route worked.
|
|
||||||
|
|
||||||
## Generated Art Live
|
|
||||||
The generated art (source in gitignored `docs/brand/generated-art/`, copied full-res to
|
|
||||||
`app/src/main/res/drawable-nodpi/`) is now wired into the app via the shared `EmptyState`
|
|
||||||
(rounded-tile, theme-safe) and a new `ui/components/BrandIllustration.kt` helper:
|
|
||||||
|
|
||||||
| Art | Screen wired | Verified |
|
|
||||||
|---|---|---|
|
|
||||||
| A1 `pairing_success` (transparent) | PairingSuccessScreen hero (replaced the keyhole chip; pulse/spring kept) | gallery (transparent floats) |
|
|
||||||
| A2 `answer_history_empty` | AnswerHistoryScreen empty (replaced generic couple_history) | shared EmptyState (Run-2 both-theme) |
|
|
||||||
| A3 `connection_challenges_header` (banner) | ConnectionChallengesScreen series-list header | gallery (16:9 banner) |
|
|
||||||
| A4 `memory_lane_capsule` | MemoryLaneScreen empty (replaced 📦 emoji) | shared tile |
|
|
||||||
| A5 `date_match_empty` / `date_match_success` | DateMatchesScreen empty / "It is a match!" modal | shared tile / gallery |
|
|
||||||
| A6 `bucket_list_empty` | BucketListScreen empty | **live dark + light** |
|
|
||||||
| A8 `messages_empty` | MessagesInboxScreen (new empty state added) | shared EmptyState |
|
|
||||||
| A9 `quiet_hours` | ~~NotificationSettings quiet-hours section~~ **REMOVED 2026-06-28** (out-of-place mid-list illustration → cleaner standalone toggle); asset now in debug Art-preview gallery only | n/a |
|
|
||||||
| A10 `past_games_empty` | WheelHistoryScreen ("Past Games") empty | shared tile |
|
|
||||||
| A11 `privacy_recovery` | SecurityScreen header | **live dark** |
|
|
||||||
| A12 `account_deletion_goodbye` | DeleteAccountScreen header | **live dark** |
|
|
||||||
| A13 `premium_unlock` (transparent) | **✅ Wired R13** — one-time Premium unlock modal (`PremiumUnlockOverlay`) | **live both themes** |
|
|
||||||
|
|
||||||
All 12 also live in the debug **Art preview** gallery (Settings → Art preview) for both-theme verification. A7 pack art is
|
|
||||||
**N/A** because all 10 question packs already have `pack_art_*`.
|
|
||||||
|
|
||||||
A1-A12 prompts are complete and should not be regenerated. Empty/match/pairing states that need empty-or-new data were
|
|
||||||
not all reachable on the baseline couple, but their render path is proven through the shared tile + gallery.
|
|
||||||
|
|
||||||
**Premium unlock modal — ✅ IMPLEMENTED + verified live R13 (2026-06-27).** `ui/components/PremiumUnlockOverlay.kt`
|
|
||||||
(`PremiumUnlockViewModel` + `PremiumUnlockOverlay`), hosted at the `AppNavigation` root next to `MessageBubbleOverlay`,
|
|
||||||
so it surfaces over any screen. It's driven off `CouplePremiumChecker.isPremium()` (which already OR-combines both
|
|
||||||
partners) — so it fires for **both** the purchaser (own entitlement active) **and** the partner (couple-shared Premium
|
|
||||||
turns on), without depending on the push route. One-time per activation via a new persisted `premiumUnlockCelebrated`
|
|
||||||
flag on `SettingsRepository`/`SettingsDataStore` (set on dismiss; auto-reset when Premium lapses so a re-activation
|
|
||||||
celebrates again — mirrors `lastCelebratedStreakMilestone`). The modal shows `illustration_premium_unlock` (transparent,
|
|
||||||
floats via `BrandIllustration(tile=false)`) + "Premium unlocked ✨" + "Start exploring". **Verified live:** admin toggle
|
|
||||||
QA premium ON → modal on BOTH 5554 (dark) and 5556 (light); dismiss → relaunch → no re-show (gate holds); 0 FATAL.
|
|
||||||
|
|
||||||
## Glyph Status
|
|
||||||
|
|
||||||
**~63 `glyph_*.xml` now exist** in `app/src/main/res/drawable-nodpi/` — the original G/G2 set PLUS the full Material-icon
|
|
||||||
replacement set (back, heart, heart_outline, lock, lock_open, person, couple, check, forward, play, close, star, eye,
|
|
||||||
eye_off, copy, sync, photo, camera, send, chat, trash, home, settings, bell, streak, mic, pause, timeline, trending_up,
|
|
||||||
psychology, fingerprint, key, shield, edit, add, share, refresh, warning, calendar, cake, card_giftcard, attach_money,
|
|
||||||
hourglass, question_answer, palette, open_in_new, …). Source SVGs: `docs/brand/generated-art/glyphs/source-svg/`;
|
|
||||||
Android vectors: `docs/brand/generated-art/glyphs/android-vector/`.
|
|
||||||
|
|
||||||
**Wired in code (23 distinct, verified 2026-06-28):** `paired_cards`, `how_well`, `sealed_answer`,
|
|
||||||
`connection_challenge`, `memory_capsule`, `date_card_heart`, `question_packs`, `bucket_list`, `past_games`, `spin_wheel`
|
|
||||||
(Play-hub cards + WaitingForPartner); `couple_premium`, `privacy_lock`, `delete_account`, `settings` (Settings); plus
|
|
||||||
`couple`, `daily_card`, `forward`, `home`, `lock`, `play`, `streak`, `chat`, `closer_heart_keyhole`.
|
|
||||||
|
|
||||||
**Created but NOT yet wired (~40):** every glyph in the icon-audit table whose Material equivalent still appears in code —
|
|
||||||
`back` (vs ArrowBack ×31), `heart`/`heart_outline` (vs Favorite ×12/×5), `person` (vs Person ×16), `check` (×11),
|
|
||||||
`close` (×7), `star` (×6), `eye`/`eye_off` (×5/×2), `copy` (×4), `send` (×3), `sync` (×3), `photo`/`camera`, `trash` (×3),
|
|
||||||
`bell`, `mic`, `pause`, `timeline`, `trending_up`, `psychology`, `fingerprint`, `key`, `shield`, `edit`, `add`, `share`,
|
|
||||||
`refresh`, `warning`, `calendar`, `cake`, `card_giftcard`, `attach_money`, `hourglass`, `question_answer`, `palette`,
|
|
||||||
`open_in_new`, `quiet_hours_moon`, `export_data`, `closer_mark`. **This is the open work** — see the icon-audit table.
|
|
||||||
|
|
||||||
White monochrome vectors are re-tinted by `Icon(tint = ...)` and loaded via `ImageVector.vectorResource(...)`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Prompt Style Reference
|
|
||||||
|
|
||||||
Use this only when a future pass creates a new prompt. Existing completed art should not be regenerated.
|
|
||||||
|
|
||||||
> Flat 2D pastel vector **illustration** in the "Closer" couples-app style: soft rounded shapes, **no harsh outlines**,
|
|
||||||
> gentle smooth gradients, calm and intimate. **Palette only:** aubergine `#24122F`, deep purple `#56306F`, lavender
|
|
||||||
> `#B98AF4`, soft pink `#F7C8E4`, soft lavender `#D9B8FF`, blush white `#FFF8FC`. Mood: **warm, quiet, equal,
|
|
||||||
> intentional — a private ritual for two.** Recurring motifs available: paired/sealed cards, floating hearts and
|
|
||||||
> petals, a small wooden table with mugs + a lit candle + a lavender sprig, moon/window for quiet hours, a
|
|
||||||
> calendar/date-card, a sealed capsule/box. If people appear: **two balanced, inclusive figures, simple friendly
|
|
||||||
> faces, no dominant partner.** Soft abstract floral/heart shapes in the background.
|
|
||||||
> **Do NOT include:** any readable text, answer/prompt/message content, invite codes, emails, app UI/buttons, generic
|
|
||||||
> dating-app clichés, stock-photo realism, other brands' logos/wordmarks/app-store badges,
|
|
||||||
> alarm/urgency/surveillance/fire imagery.
|
|
||||||
|
|
||||||
> **The Closer brand mark** (use ONLY where a prompt explicitly calls for it — otherwise keep art mark-free): a
|
|
||||||
> pink-to-lavender **"C"** whose upper arc is soft pink and lower sweep is lavender, curving around a **heart-shaped
|
|
||||||
> negative space** with a small **dark aubergine keyhole** at its center (the keyhole = trust/privacy; the heart =
|
|
||||||
> the couple). Keep it faithful — do not redraw it as a plain `C`, turn the keyhole into a heart, add a key/lock
|
|
||||||
> shackle, add faces or text. On dark/aubergine surfaces use a white keyhole; on light/blush use the aubergine keyhole.
|
|
||||||
|
|
||||||
**Aspect/format conventions**
|
|
||||||
- Hero/onboarding/paywall: portrait **4:5** (matches `illustration_couple_*`).
|
|
||||||
- Empty states: roughly **square 1:1**, generous padding, sits centered above text.
|
|
||||||
- Celebration: **1:1**, transparent or blush-white background.
|
|
||||||
- Glyphs: **single-color** simple vector, square, legible at 20–32 dp.
|
|
||||||
- Pack/category art: **landscape ~16:9** banner, object-led (no people), like `pack_art_*`.
|
|
||||||
- Always readable on **both** blush-white (light) and aubergine (dark) surfaces — keep a soft self-contained vignette
|
|
||||||
rather than a hard rectangular background; export PNG with transparency where noted.
|
|
||||||
|
|
||||||
**Existing assets (reuse before generating):** the generated A1-A12 illustration set above, `illustration_couple_*`,
|
|
||||||
`illustration_daily_question`, `illustration_tonight_partner_prompt`, `illustration_partner_activation`,
|
|
||||||
`illustration_reveal_celebration`, `illustration_streak_milestone`, `illustration_together_empty`,
|
|
||||||
`illustration_premium_unlock`, all 10 `pack_art_*` assets, `particle_heart`, and `particle_petal`. These are Android
|
|
||||||
assets now; only generate new art when a future QA pass finds a specific missing surface or replacement-worthy defect.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Screen-By-Screen Audit
|
|
||||||
|
|
||||||
Legend: ✅ on-brand / no art needed · ➕ reuse/wire existing art · 🔤 brand-copy/color/code touch
|
|
||||||
|
|
||||||
| Screen / surface | Current brand state | Opportunity |
|
|
||||||
|---|---|---|
|
|
||||||
| Onboarding carousel | ✅ couple art + warm copy | ✅ strong already |
|
|
||||||
| Welcome (Create / I have account) | heart mark + privacy line | ✅ on-brand; could rotate privacy messages 🔤 |
|
|
||||||
| Sign up / Login / Forgot password | plain form | 🔤 add small heart mark + one privacy line above the form (no big art — keep forms clean) |
|
|
||||||
| Create profile — name / sex / photo | plain steps | 🔤 light: small step glyphs; ✅ otherwise (forms stay clean) |
|
|
||||||
| Pair: invite (create code) | `illustration_couple_invite` wired | ✅ |
|
|
||||||
| Pair: accept code / pairing success | `illustration_pairing_success` wired | ✅ |
|
|
||||||
| Home (paired) | cards, warm copy | ✅ good |
|
|
||||||
| Home (unpaired "bring your person in") | couple art present | ✅ on-brand |
|
|
||||||
| Today / daily question | clean card | ✅; reveal moment is the place for art (below) |
|
|
||||||
| Answer reveal (mutual) | `illustration_reveal_celebration` wired | ✅ |
|
|
||||||
| Answer history | `illustration_answer_history_empty` wired | ✅ |
|
|
||||||
| Play hub | game glyphs wired | ✅ |
|
|
||||||
| This or That (setup/play) | light buttons good; dark prompt backdrop off-brand | 🔤 replace two-circle/line motif + theme option colors |
|
|
||||||
| This or That / How Well / Desire Sync **results** | score + rows | ➕ reuse `reveal_celebration`; particles on high match |
|
|
||||||
| How Well / Desire Sync intro | icon + copy | ✅ |
|
|
||||||
| Spin the Wheel | nice wheel art | ✅ wheel is on-brand |
|
|
||||||
| Wheel complete / results | text reveal | ➕ celebration header (reuse particles) |
|
|
||||||
| Connection Challenges (series list) | `illustration_connection_challenges_header` wired | ✅ |
|
|
||||||
| Connection Challenges (active day) | clean | 🔤 small streak/heart glyph; ✅ otherwise |
|
|
||||||
| Memory Lane (list) | `illustration_memory_lane_capsule` empty state wired | ✅ |
|
|
||||||
| Memory Lane (sealed capsule) | lock + date | ➕ optional: reuse capsule art on sealed-card detail, no new art |
|
|
||||||
| Date Match (deck) | clean cards | ✅ |
|
|
||||||
| Date Match (your matches / empty) | empty + success art wired | ✅ |
|
|
||||||
| Plan Date / Date Builder | form | 🔤 small date-card glyph; ✅ otherwise |
|
|
||||||
| Bucket List (empty) | `illustration_bucket_list_empty` wired | ✅ |
|
|
||||||
| Question Packs (library) | all 10 `pack_art_*` assets present | ✅ |
|
|
||||||
| Messages (inbox empty) | `illustration_messages_empty` wired | ✅ |
|
|
||||||
| Conversation | chat | ✅ keep clean; quiet-hours art is in settings |
|
|
||||||
| Past Games (empty/list) | `illustration_past_games_empty` wired | ✅ |
|
|
||||||
| Your Progress / Activity | stats | 🔤 brand-colored charts; reuse `streak_milestone` for milestones |
|
|
||||||
| Paywall / Subscription | couple art present | ✅ strong (couple illustration + “one subscription for both”) |
|
|
||||||
| Premium unlock modal | `illustration_premium_unlock` wired in `PremiumUnlockOverlay` (R13) | ✅ one-time modal for purchaser + partner; verified live both themes |
|
|
||||||
| WaitingForPartner | per-game glyphs + copy | ✅ |
|
|
||||||
| Settings + sub-pages | dense lists | ✅ keep clean — **no illustrations**; brand via section headers/color only 🔤 |
|
|
||||||
| Security / Recovery phrase | `illustration_privacy_recovery` wired | ✅ |
|
|
||||||
| Privacy & Terms | settings row uses `glyph_privacy_lock` | ✅; page can stay text-first |
|
|
||||||
| Delete account | `illustration_account_deletion_goodbye` wired | ✅ |
|
|
||||||
| Quiet hours (settings) | illustration REMOVED 2026-06-28 (looked unprofessional mid-list) — now a clean standalone toggle | ✅ matches "Settings stays clean — no illustrations" |
|
|
||||||
| Notifications (system) | `ic_notification_closer` used | ✅ no glyph swap needed |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Generation Backlog
|
|
||||||
|
|
||||||
**Status (2026-06-28 re-audit) — the asset-generation backlogs are essentially DONE; the remaining work is CODE:**
|
|
||||||
1. **Dark illustration variants — ✅ DONE.** `drawable-night-nodpi/` has a dark variant for every non-transparent
|
|
||||||
surface (all 22 `illustration_*` + all 10 `pack_art_*`). Remaining: verify the 3 transparent celebration assets
|
|
||||||
(`premium_unlock`, `spin_wheel`, `streak_milestone`) read on dark, and Pass-C spot-check the new variants.
|
|
||||||
2. **Custom glyphs — ✅ DONE.** Assets created AND wired: all 187 `Icons.*` call sites swapped to `glyph_*` via the
|
|
||||||
central `CloserGlyphs` accessor; 0 Material icons / 0 material-icon imports remain. BRAND-ICON-CUSTOM resolved.
|
|
||||||
|
|
||||||
Do not regenerate **completed** illustration/glyph art unless a QA pass logs a specific defect.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Notes for the asset hand-off
|
|
||||||
- Match filenames to the existing scheme: `illustration_<name>.png` (nodpi), `pack_art_<pack>.png`, `glyph_<name>.xml`,
|
|
||||||
`particle_<name>.png`. Provide @1x in `drawable-nodpi/` (illustrations) or density buckets where the existing asset has them.
|
|
||||||
- For the generated G-set, use the source SVGs in `docs/brand/generated-art/glyphs/source-svg/` for review and the
|
|
||||||
Android-ready vectors in `docs/brand/generated-art/glyphs/android-vector/` for app wiring.
|
|
||||||
- After adding art, re-run Pass C (visual, light + dark) on those screens to confirm contrast + no clipping, and
|
|
||||||
re-export store graphics per `docs/brand/visual-identity.md` if the palette/mark changed.
|
|
||||||
|
|
@ -1,125 +0,0 @@
|
||||||
# ClaudeGamesReview — Full Games, Mechanics, Retention & UI Review
|
|
||||||
|
|
||||||
> **Session 2026-07-07 (Claude, senior-dev review round).** Scope: all 7 games verified live on a
|
|
||||||
> fresh 2-emulator couple, mechanics checked against their real-world inspirations (researched),
|
|
||||||
> retention assessment, full-app UI warmth sweep, code-quality audit + fix batches.
|
|
||||||
> Bugs are cross-referenced by ID; canonical defect state lives in `ClaudeReport.md`.
|
|
||||||
> Test couple: **Ava + Ben** (`j39xR7PVZLLCveWnmenU`), created through the real signup → invite-code
|
|
||||||
> → recovery-phrase pairing flow on `CloserQA2` (5554, light) + `CloserCodexQA` (5556, dark).
|
|
||||||
> Premium was granted via admin (user-authorized), verified (partner unlock modal + push), then
|
|
||||||
> revoked (locks confirmed restored). 0 active sessions at session end.
|
|
||||||
|
|
||||||
## 1 · Per-game verdicts (mechanics fidelity + live results)
|
|
||||||
|
|
||||||
| Game | Real-world analog | Fidelity | Live 2-device result |
|
|
||||||
|---|---|---|---|
|
|
||||||
| This or That | Rapid-fire preference quiz | ✅ Faithful | **PASS** — engineered answers → exactly 3/5 "in sync"; simultaneous symmetric reveal; warm framing ("Lots in common 💛", neutral "Differ" chips) |
|
|
||||||
| How Well Do You Know Me | The Newlywed Game | ✅ Core faithful; no round-2 role swap (see R-2) | **PASS** — subject/guesser roles correct on both intros; engineered 2/5 exact; near-miss scale renders amber "4 → 3" (isClose softening); low score framed "Getting there!" |
|
|
||||||
| Desire Sync | Mojo Upgrade-style mutual-desire matching | ✅ Canonical privacy contract, verbatim in intro copy | **PASS** — engineered intersection → exactly "2 shared desires / 3 stayed private"; non-mutual yeses never rendered on either device; count doesn't leak whose/which; answers `enc:v1:` at rest |
|
|
||||||
| Spin the Wheel | Conversation-deck wheel | ✅ Honest randomness (category picked during spin; decorative stop angle) | **PASS** — identical 10-question set both sides; blind until both answered; side-by-side reveal; completion gate blocks finish with unanswered prompts |
|
|
||||||
| Connection Challenges | 7-day couples habit challenge | ✅ Day-gating real (can't complete 2 days in one day) | **PASS** — premium challenge opened with entitlement; day-1 complete → "waiting for partner"; ⚠ day-2 prompt text visible a day early (spoiler, see U-13) |
|
|
||||||
| Memory Lane | Time capsule / letter to future selves | ✅ | **PASS** — sealed capsule shows only title + "Opens in 29 days"; title AND body `enc:v1:` at rest (admin-verified) |
|
|
||||||
| Date Match | Tinder swipe-to-match | ✅ incl. one-sided-like privacy | **PASS** — simultaneous mutual-LOVE race → exactly **1** match doc; celebration modal + "It's a match!" push on BOTH devices |
|
|
||||||
|
|
||||||
**Shared engine seams, verified live:** one-active-game gate converges the second starter into the
|
|
||||||
active session (Ben's How Well attempt → "Ava is playing a Wheel game / Join the game");
|
|
||||||
first-finisher `partner_completed_part` fires as an in-app banner (foreground) and real push
|
|
||||||
(killed app, cold-start tap opened the app cleanly, 0 FATAL); mid-game Quit abandons correctly
|
|
||||||
(admin: 0 active sessions after); "you both finished" results banner on both sides.
|
|
||||||
|
|
||||||
## 2 · Bugs found (canonical detail in ClaudeReport.md)
|
|
||||||
|
|
||||||
| ID | Sev | Summary | Status |
|
|
||||||
|---|---|---|---|
|
|
||||||
| DQ-MISMATCH-001 | **P1** | Partners can resolve **different daily questions** for the same day: server assignment is the unresolvable `q_default_daily` rollout fallback → each device falls back locally, and the fallback **pool depends on the viewer's premium state at that moment** (per-user entitlement, transient failures→false, mid-day flips). Reveal then cross-compares answers to different questions. Reproduced live (Ava: premium pool "tiny win" q; Ben: free pool "tiny debate" q; same clock/tz). | **FIXED** (paired fallback pinned to free pool — premium-independent) + follow-up filed to seed real server assignments |
|
|
||||||
| DQ-SLUG-001 | **P1** | Daily reveal renders the partner's answer as a raw option-id slug ("fake_awards_should_be_mandatory") when the id isn't in this device's question config (same class as HW-BREAKDOWN-001). | **FIXED** (humanize fallback in both reveal summaries) |
|
|
||||||
| GAME-HANG-001 (B1) | **P2** | ToT/HowWell/DesireSync answer listeners swallowed snapshot errors → game stuck on WAITING forever, no feedback (Wheel/Capsule already had the correct `close(err)` pattern). | **FIXED** (sources `close(err)` + VM `.catch` → retryable ERROR with "Try again" re-attach) |
|
|
||||||
| DST-001 (B2) | **P2** | `assignDailyQuestion.ts` hardcoded `CST_OFFSET_HOURS=-6`: wrong date labels + reveal times shifted 1h for the ~8 CDT months. | **FIXED** (Intl/America-Chicago helpers, matches streakReminder.ts pattern; 5 DST regression tests incl. spring-forward round-trip) |
|
|
||||||
| A-003b | P3 | Challenges catalog still shows 🔒 Premium badge after couple premium unlock (gate itself honors premium — opens fine). | Filed |
|
|
||||||
| BANNER-LIFE-001 | P3 | "You both finished · View" game banner persists across screens/games long after results were viewed (still up entering a NEW game's setup, and shown on top of the results screen it links to). | Filed |
|
|
||||||
| AUTH-TRUNC-001 | P3 | Welcome/splash privacy tagline clips mid-sentence without ellipsis ("Only you and your paired") — first-run first impression. | Filed |
|
|
||||||
| Copy/theming P3s | P3 | "Daily Fun Mc" internal category name as user-facing pill; unthemed teal date picker; DOB asked twice in signup→profile funnel; "It is a match!" vs push "It's a match!"; HW matched-row question text low contrast in dark; Date Match card-underlay text bleed; joiner screen titled "Waiting for X" when it's your turn. | Filed |
|
|
||||||
|
|
||||||
## 3 · Retention assessment (Hooked / Fogg / SDT lens)
|
|
||||||
|
|
||||||
**Strong foundations:** daily-question blind-reveal is a genuine daily anchor with a variable
|
|
||||||
reward (partner's answer) · streaks with repair + grace (aligned with Duolingo's leniency findings)
|
|
||||||
· Memory Lane = stored-value investment · Weekly Recap + Your Progress = progress evidence ·
|
|
||||||
first-finisher nudges close the async loop (verified: banner + push both fire) · notification suite
|
|
||||||
respects quiet hours.
|
|
||||||
|
|
||||||
**Prioritized recommendations:**
|
|
||||||
- **R-1 (P1): Fix the daily anchor supply chain.** `q_default_daily` fallback means the server
|
|
||||||
never really assigns; seed the Firestore `questions` pool (or make the callable assign a real id
|
|
||||||
from a synced catalog) so both devices always agree. (Client-side consistency fix landed this
|
|
||||||
session; assignment seeding is the durable fix.)
|
|
||||||
- **R-2 (P2): Role-swap rematch in How Well.** Newlywed Game's round structure swaps roles; the
|
|
||||||
results screen should offer "Now you guess — swap roles" (starter becomes guesser). Cheap, doubles
|
|
||||||
sessions per sitting, and answers "what do we do next?" at the emotional peak.
|
|
||||||
- **R-3 (P2): Return-tomorrow beat on results screens.** After every reveal the only CTAs are
|
|
||||||
"Play again / Back to Play". Add the next anchor: tomorrow's question teaser, challenge day N+1,
|
|
||||||
or streak state ("Day 2 tomorrow — keep it alive").
|
|
||||||
- **R-4 (P2): Repeat-avoidance for game pools.** Desire Sync's effective pool is 102 binary items
|
|
||||||
drawn `shuffled().take(n)` per session (no cross-session memory) and Date Match's deck is
|
|
||||||
deterministic (identical first card for every couple, every install). Persist seen-ids per couple.
|
|
||||||
- **R-5 (P3): Purify the How Well pool.** `getQuestionsForPrediction()` pulls all choice/scale
|
|
||||||
questions including 500 `daily_fun_mc` novelty MCs ("laugh-track move") — dilutes the
|
|
||||||
partner-knowledge premise. Exclude `daily_fun_mc` (or curate a prediction-worthy tag).
|
|
||||||
- **R-6 (P3): Challenges — hide tomorrow's prompt** (spoiler kills the daily ritual) and consider a
|
|
||||||
visible 7-day chain with both partners' marks (social accountability).
|
|
||||||
- **R-7 (P3): Analytics funnel gaps.** `RetentionEvent` has GameStarted/GameCompleted but no
|
|
||||||
first-answer, reveal-viewed, waiting-abandoned, or notification-tap-attribution events — the async
|
|
||||||
dead-end (biggest churn risk) is unmeasurable today.
|
|
||||||
- **Content notes:** 48/150 sexual_preferences items unreachable by Desire Sync (non-binary configs
|
|
||||||
— fine, Wheel uses them) · `daily_fun_mc` rows carry `sex='neutral'` (currently benign only because
|
|
||||||
of the binary filter; retag to avoid a future landmine) · This or That mood pools healthy (60–75
|
|
||||||
per mood vs 15 max draw); `rebuilding_trust` (45 q) reachable only via "All topics".
|
|
||||||
|
|
||||||
## 4 · UI warmth sweep (vs docs/copy-guide.md)
|
|
||||||
|
|
||||||
Scores 1–5 per cluster (tone / emotional safety / welcome / consistency):
|
|
||||||
- **Games (all 7): 5/5/–/4.** Best-in-class results framing (no shaming anywhere: "Getting
|
|
||||||
there!", "Differ" not ✗, "Nothing in common this round" never blames). Consistency dinged for the
|
|
||||||
banner lifecycle + slug leak (fixed) + per-game accent divergence (DS magenta is intentional and
|
|
||||||
works).
|
|
||||||
- **Onboarding/pairing: 4/–/5/3.** "You're in / Now bring your person in", value-prop checklist,
|
|
||||||
recovery-phrase ceremony with word-confirmation are excellent. Dinged: tagline truncation, DOB
|
|
||||||
asked twice, unthemed date picker, dead-end Create-Profile when signed out.
|
|
||||||
- **Home/Today: 4/5/5/3.** Daily card + mode system (Tiny Win Tuesday live-verified) is a strong
|
|
||||||
anchor; "Daily Fun Mc" pill leak; Home-card question can disagree with Today tab (DQ-MISMATCH).
|
|
||||||
- **Empty states: 5.** Memory Lane's "No capsules yet" is the pattern to copy everywhere.
|
|
||||||
- Both themes render cleanly on every screen driven this session (fresh-couple run covered light
|
|
||||||
on 5554 + dark on 5556 throughout); HW matched-row contrast is the one dark-mode ding.
|
|
||||||
|
|
||||||
## 5 · Code-quality fixes landed this session
|
|
||||||
|
|
||||||
- **Batch 1 (correctness):** B1 observer `close(err)` ×3 + VM `.catch`→retryable ERROR ×3 (+
|
|
||||||
`GameCopy.SYNC_ERROR`); DQ paired-fallback premium-independence; reveal slug humanization ×2;
|
|
||||||
DST-safe Chicago date helpers + 5 regression tests.
|
|
||||||
- **Batch 2 (dead code / hygiene):** see ClaudeReport.md fix log.
|
|
||||||
- **Filed (structural, specced in Future.md):** generic session-game engine (ToT/HW/DS VMs are
|
|
||||||
~80% clones; ~250-line state machine ×4), generic replay VM/screen (3× ~60-line clones), shared
|
|
||||||
waiting/length-picker composables, premium-check invocation helper, `GameConstants` for divergent
|
|
||||||
`ADVANCE_DELAY_MS` (420/380), dispatcher injection, error-surfacing standardization for 6
|
|
||||||
silent-load VMs, `npm audit` 10 moderate (GCP transitive).
|
|
||||||
|
|
||||||
## 5.1 · R30 polish + experience follow-up (2026-07-07)
|
|
||||||
|
|
||||||
All Tier-A P3s and Tier-B UX gaps from the review shipped and were live-verified (detail: ClaudeReport R30). Highlights that change the player experience:
|
|
||||||
- **Daily question is fixed at the root.** The server pool was empty (every couple got the unresolvable `q_default_daily`); now Firestore `questions` is seeded with the free weekday pool and the server picks the same mode-aware, deterministic question the client would — so both partners get the identical themed question, even across time zones. Verified: server assigns `daily_single_choice_weekly_v1_072` (Tiny Win Tuesday), both devices resolve it, no slugs.
|
|
||||||
- **Games no longer dead-end:** NextBeatCard ("Tonight's question is ready →" / streak) under every results screen; How Well offers a Newlywed-style role swap.
|
|
||||||
- **Content freshness:** Desire Sync remembers served questions; Date Match shuffles per couple and skips already-swiped ideas (no more identical first card for everyone).
|
|
||||||
- **Waiting isn't passive:** a "Send a little nudge 💜" button on the waiting screen (reuses the quiet-hours-safe thinking-of-you callable).
|
|
||||||
- **Onboarding polish:** themed M3 date pickers; the birth date now survives an app restart mid-signup (no more double-ask).
|
|
||||||
- **A11y + copy:** score visuals carry TalkBack descriptions; banners speak per-game ("guess their answers"); the internal "Daily Fun Mc" label never faces users.
|
|
||||||
|
|
||||||
## 6 · Coverage notes
|
|
||||||
|
|
||||||
- Verified live: all 7 games happy path 2-device · result extremes (0-match impossible for DS
|
|
||||||
reveal? engineered 3/5, 2/5, 2-shared, 3/5-sync variants) · one-active gate · abandon · cold-start
|
|
||||||
push join · simultaneous-LOVE race · premium grant/unlock/revoke lifecycle · daily answer→reveal ·
|
|
||||||
day-0 check-in · pairing + recovery-phrase ceremony · deep-link scheme audit (only /join/ links
|
|
||||||
exist; game entries are notification-driven, all tested).
|
|
||||||
- Deferred (unchanged from plan): full Pass E matrix, Pass K money path, release build, monkey,
|
|
||||||
TalkBack full pass (spot: n/a this round), TodayWidget interactive smoke (code-read only),
|
|
||||||
am-kill mid-session resume (cold-start-from-push covered the recovery class).
|
|
||||||
|
|
@ -1,170 +0,0 @@
|
||||||
# Claude QA Coverage Matrix
|
|
||||||
|
|
||||||
> **Resume anchor — current status only.** Statuses: `pass | fail→id | todo | n/a | not implemented→Future.md | blocked→id`.
|
|
||||||
|
|
||||||
> **R30 (2026-07-07, polish + UX + seed):** all remaining P3s + 8 UX gaps FIXED & live-verified (see ClaudeReport R30 / ClaudeGamesReview §5). **Daily-question pool SEEDED + server made mode-aware/deterministic + deployed** — both devices resolve the same real server-pinned question (DQ-MISMATCH/DQ-SLUG closed at the root, verified live). Gates: unit pass · functions 58 · cold-start smoke run. Still deferred: TodayWidget interactive, TalkBack full pass, release/minify (O), device/OS matrix.
|
|
||||||
|
|
||||||
> **R29 (2026-07-07, games/mechanics/retention/UI review):** Pass B (all 7 games) **re-run live 2-device on a fresh couple = pass** (engineered-answer verification; see ClaudeGamesReview.md) · onboarding/signup/pairing/recovery-ceremony/day-0 check-in **pass (live, first full run since R24)** · daily answer→reveal loop **fail→DQ-MISMATCH-001/DQ-SLUG-001 → FIXED e5868bd, re-verified** · premium grant/unlock/revoke lifecycle **pass** · cold-start push open **pass (organic, killed app)** · one-active gate/abandon/simultaneous-match race **pass** · TodayWidget interactive + TalkBack spot + am-kill mid-session resume **todo (deferred; cold-start-from-push covered the recovery class)** · Pass D at-rest spot-check (game answers + capsule on new couple) **pass (`enc:v1:`)**.
|
|
||||||
> **R28 (2026-07-02) — closed R27's two gaps: FIXED HW-BREAKDOWN-001 (P3) + ran the 3 premium games live 2-device under an explicitly-authorized admin grant, then revoked. 0 P0/P1/P2, 0 FATAL.** **HW-BREAKDOWN-001 (P3) FIXED** — added `humanizeOptionId()` (`_`→space + Title-case) to all 3 fallback branches of `HowWellAnswer.displayText()` in `ui/howwell/HowWellScreen.kt` (proper `config` labels still win); built + installed both emulators → archived. **Premium grant (user-authorized this occurrence) → couple-shared unlock re-confirmed:** QA `entitlements/premium`=true (source `qa_admin`) unlocked BOTH — Sam's free Play hub dropped the 🔒 + QA got the one-time **"Premium unlocked ✨ You both have Premium now"** modal. **Pass B premium games — all 3 PASS live 2-device:** **Desire Sync** (Sam Y·Y·Y·Y·Y / QA Y·Y·Y·N·N → both devices show identical **"3 shared desires / 2 stayed private"** = the 3 mutual-YES only; You/partner both "Private"; Sam waiting-screen auto-flipped + fired "You both finished — View" banner) · **Memory Lane** (create+seal capsule → list shows **title-only under lock**, **sealed body does NOT leak**; clears up long-title pre-existing rows) · **Date Match** (mutual like on "Sunrise hike + thermos coffee" → **"Matched"** in couple-shared "Your Matches" **on both devices**; premium-tagged idea "Overnight camping getaway" swipeable/matched → A-201 gate lifts under premium). **Premium REVOKED after testing** (admin hasPremium/isActive/premium=false + `revokedAt`) — verified at DB (all-true→all-false) **and live in UI** (Memory Lane + Past Games 🔒 **Premium** again; free fixture restored). **Copy fix BANNER-RESULTS-COPY-001 (P4 cosmetic) — FIXED:** foreground game-results banner "See how you and **Your partner** compare" (capitalized generic fallback awkward mid-sentence) → `GamePromptBanner.styleFor()` RESULTS line now name-branches ("See how you and Sam compare" / name-free **"See how you both compare"**); compiles + installed both. **O-AGE-001 (P2) — 18+ age gate IMPLEMENTED + live-verified (throwaway 5558):** `AgeGate`(18+)+`User.birthDate`+datasource(read/create/`updateBirthDate`)+`firestore.rules` update-allowlist `+birthDate`+`SignupHandoff`; sign-up **Date of birth** picker validated **before account creation** (under-age → no account) + conditional DOB step in CreateProfile for Google/legacy (skipped for email via handoff). Live: DOB-required error ✓; **age 17 → "You must be at least 18 to use Closer." + no account** ✓; adult → account created → CreateProfile NAME step (DOB skipped, Step 1/3) ✓; profile save succeeds ✓. **Landmine caught live:** birthDate on the *update* path hit PERMISSION_DENIED (rules allowlist undeployed) → broke profile save → fixed by making the write **best-effort** (`runCatching`). **birthDate persistence: `firestore:rules` DEPLOYED by user + verified against the live rule** (authed update PATCH `{birthDate,lastActiveAt}` → 200 ALLOWED + persisted; non-allowlisted field → 403, so nothing weakened). Unit suite **279 green** (+8 AgeGate). **0 FATAL, both emulators.**
|
|
||||||
> **R27 (2026-07-02) — full-plan COMPLETION (live-ran the passes R26 carried): P·I·J·F·G·H + Pass-B free games; 1 new P3 (HW-BREAKDOWN-001).** Same build as R26 (UI-only; app unchanged, only doc edits). **Pass P** question-bank PASS (6103 Qs: 0 empty/dupe/placeholder; choice/scale answer-configs all present; daily pack 500 intact; 22 categories; **Room identity hash `7e7d78…` preserved**). **Pass I** perf PASS (core-tabs **6.67% janky**/90th 31ms/0 missed-vsync; conversation scroll **3.04%**/90th 19ms — smoother than R8). **Pass J** a11y PASS (font_scale 2.0 → Home/Play/Settings reflow, scroll, no hidden critical actions [nav-label wrap known-acceptable]; reduce-motion no hang; **TalkBack 160/160 `Icon()` have contentDescription**; touch-targets carried [Batch-8 48dp]). **Pass F** resilience PASS (Messages **renders from cache in airplane-mode**, 0 FATAL, no dead-end; **portrait-lock holds** `requestedOrientation=PORTRAIT` under forced landscape; process-death via 6/6 smoke; concurrency carried). **Pass G/D3** security PASS — **live raw-API negative:** non-member ID token → couple doc / messages / daily answers / **date_reflections** / partner user-doc = **403 all**; **self-grant own premium = 403**; own-doc = 404 (valid auth, rules are the gate). **Pass H** branding PASS (all driven screens on-brand; 2 P3 backlogs carried). **Pass B free games (live 2-device this session):** This-or-That 5/5 (R26) · **How Well** answered→predicted→**"2 of 3"** with correct scale/choice breakdown · **Connection Challenges** resume→Day6 complete→advance Day7→mutual per-day gate ("waiting for partner"), streak/missed-day recovery · **Spin-the-Wheel** spin/category/session/written+choice answer/cap/quit (full 10-Q completion carries R18b). **NEW FINDING HW-BREAKDOWN-001 (P3):** How Well results breakdown renders a wrong *choice*-guess as its **raw option ID** (`a_small_romantic_surprise`) instead of the human label (correct answer resolves to text) — cosmetic ID-leak, untouched feature. **Pass B premium games (Desire Sync · Memory Lane · Date Match):** paywall **GATE verified** (all → Paywall for free: Desire Sync [Pass A], Date Match [free-swipe→paywall, A-201 holds], Memory Lane [premium-badged]); **GAMEPLAY `blocked→premium-grant-authorization`** (admin grant declined by auto-mode; gameplay carries R12/R14). **K/O `blocked→needs-device`/pre-ship** (unchanged). **0 FATAL, 0 P0/P1/P2; 1 new P3.**
|
|
||||||
> **R26 (2026-07-01) — full-plan run on the text-input/truncation + DateReflection-hardening build; QA fixture re-restored (user-authorized); 0 defects.** Cheap gates GREEN (unit **244** · fn **47** · theme-scan CRIT **1=false-pos** [HomeScreen:829 brand count pill] · painter-xml **0** · wiring 🔴**0** dead). **Cold-start smoke 6/6 BOTH** (Sam+QA). **QA(5556) fixture RESTORED** — env logout after standby (couple key intact on disk) → admin password reset (user-authorized) + sign-in, **no restore ceremony**; Home + history decrypt. **Pass D E2EE at-rest CLEAN** — conversations (main+discussion), daily answers (both users), date_reflections all `enc:v1:` + content-free metadata; image msgs = encrypted mediaUrl only; **rules/crypto UNCHANGED this cycle** (R25 D2/D3 negative results hold). **Pass L** — inbox decrypted no-`enc:`-leak + full thread decrypt + **2-device round-trip QA→Sam decrypts** (restore-key integrity / R24 regression holds). **Pass A** free→**Paywall** (C-PW-001 pills legible). **Pass M** settings render/structure clean (debug rows gated). **Pass N — Date Memories/Reflection (R25 todo) CLOSED** — 2-device reflect→reveal, edit-before-reveal (rules deployed), notes field, bg/fg deep-link, `date_reflection_ready`/`opened` pushes, + R25-fixed hardening (read-failure→retryable ERROR, bounded couple-read) all verified live this session. **Text-input hardening (this build):** display-truncation removed from message/answer/question/error surfaces (ellipsize chrome only); free-text caps unified in `ui/components/TextInputLimits.kt` + trim-on-send; wheel written-answer cap added; near-limit counter. **Pass B (added live this round):** full **This-or-That** 2-device lifecycle — start (Sam)→waiting-for-partner gate→**join from QA's foreground banner**→both answer 5→**5/5 "Two peas in a pod" results synced on BOTH devices** (per-Q You/partner breakdown decrypted, all Match); confirmed live foreground game banners (`partner_completed_part` "Your turn" + results "You both finished · View") + real-time reveal sync (Pass E/F incidental). **0 FATAL, 0 new defects.** Not run (carried / pre-ship): K money-path (needs-device), O release build, device/OS matrix, remaining 6 games' B re-run (no games-logic change; smoke covers game cold-starts; last full 7-game B clean R12/R18b).
|
|
||||||
> **R25 (2026-06-30) — full fresh run on the new R24 E2EE backup/restore surface + cornerstone regression; 0 new defects.** Cheap gates green (unit **244** · fn **38** · theme-scan CRIT **1=false-pos** [HomeScreen:829 brand count pill] · painter-xml **0** · wiring 🔴**0** · cold-start smoke **6/6 both** · render smoke **4/4**). **Pass D CLEAN (deep on backup/restore):** at-rest manifest=pointers-only, Storage snapshot=`enc:v1:` (16KB, server-blind), restore_requests=0; rules member-scoped + keybox bound to other member + immutable pubkey; **D3 live negative all-denied** (backup manifest/chunks/restore_requests + create/write = 403/400; original couple/self-grant = 403); cross-user restore via **tokenized capability URL** verified (plain GET→200+`enc:v1:`); **R24 storage.rules deploy gap RESOLVED**. **Pass E:** `restore_requested` partner push **deployed + firing** (Sam queue: 3 today) → RESTORE_CONSENT; **R24-b functions NOT deployed** (`onRestoreFulfilled` absent, no `lastRestoreSelfAlertAt`, 0 `restore_self_alert`) → self-alert + completion alert = `blocked→deploy`. **Pass M:** new Security entries live-verified this session (recovery reveal on no-lock, Copy+`IS_SENSITIVE` mask, Help-my-partner-restore + back). **Cornerstone regression (Sam 5556):** A paywall gate ✓, B Play-hub cards+badges ✓, L inbox+thread fully decrypted no-leak ✓, N daily-Q decrypted+reveal ✓, 0 FATAL. **⚠️ PROCESS LANDMINE (I caused): `connectedDebugAndroidTest` UNINSTALLS+WIPES the app-under-test → wiped QA(5554) data → QA at fresh onboarding (O-ONBOARD-001 stays fixed). NEVER run instrumented tests on 5554/5556 fixtures — use throwaway 5558.** QA fixture recovery = `blocked→user` (password/re-auth needed). **User actions to close: (1) `firebase deploy --only functions` ✅ DONE (functions deployed by user; both self-alerts live-validated R25-c). (2) restore QA fixture ✅ DONE R25-b.**
|
|
||||||
> **R25-b (2026-06-30) — QA(5554) fixture RECOVERED; live 2-device partner-assisted restore verified end-to-end, 0 defects.** Password reset via admin (user-authorized) → QA signed in → **NEEDS_RECOVERY** → "Start restore" published request (code 592847) → **deployed `onRestoreRequested` fired LIVE** (Sam got "Help your partner restore 💜" push, id 40038) → Sam's **Change-1 consent live-verified** (email anchor + name **QA** decrypted locally + confirm checkbox; Approve gated on code(6) **AND** confirm) → approve → **QA auto-restored**: paired Home + "Sam/Revealed" + **full chat history decrypts**; fresh `restore_ok_R25` from restored QA **decrypts on Sam** = bidirectional round-trip = full R24 restore regression. **Deferred obs (not a defect):** warm-start restore-push tap opened Play hub not RESTORE_CONSENT (likely collapsed-notif-group artifact; cold-start routing smoke-green).
|
|
||||||
> **R25-c (2026-06-30) — LIVE-FIRE of deployed owner-alerts (Change 3): both restore self-alerts observed on QA's OWN device; last user-gate CLOSED; 0 defects.** User-authorized re-wipe QA(5554) → sign in → NEEDS_RECOVERY → Start restore (code 565429). **(1) Request self-alert fired LIVE** — QA **shade** (`closer.app` id 67945, `partner_activity`, imp 4) + durable `users/{QA}/notification_queue` (`restore_self_alert`, 23:17:38 “New device is restoring your history”) + partner push to Sam (“Help your partner restore 💜”) — all from one `onRestoreRequested`. **R25-b routing obs CLOSED:** tapping Sam’s *single* restore notif → RESTORE_CONSENT (not Play hub) ⇒ earlier artifact was the collapsed 2-item group header. Consent gate re-verified (code(6) alone Approve-disabled → +confirm enabled). Sam approve → **`onRestoreFulfilled` fired (status ok, 1319ms) on REQUESTED→READY** → **(2) completion self-alert** queued to QA (`restore_self_alert`, 23:19:50 “Your history was just restored”) — not on shade only because QA was foregrounded (auto-restored); push still reached live tokens. 132s apart (>~60s dedupe → no suppression). **Robustness live:** 1 stale token (`registration-token-not-registered`) failed but `Promise.allSettled` → function ok. QA auto-restored to paired Home + content decrypts, 0 FATAL → **fixture healthy**. **Minor follow-on (not defect):** prune `not-registered` FCM tokens.
|
|
||||||
> **R25-d (2026-06-30) — implemented FCM stale-token pruning (closes R25-c follow-on); build + 47 fn tests green; NOT deployed (user-gated).** New `notifications/pruneTokens.ts` (`isDeadTokenError` prunes ONLY `registration-token-not-registered`/`invalid-registration-token` — never transient/`invalid-argument`, so a bug can't wipe all tokens; `pruneDeadTokens` best-effort, never throws, batch-deletes dead `fcmTokens` docs + legacy field, only on an actual dead token). Reuses each caller's `tokens`+`allSettled` results → 1 line wired into **all 19 push sites** (questions/dates/couples/games/notifications/billing/users/backup). `pruneTokens.test.ts` +9 tests (fn **38→47**). tsc verified every db/uid/tokens/results ref; `dist/notifications/pruneTokens.js` emitted. Takes effect on next `firebase deploy --only functions`.
|
|
||||||
> **R21 (2026-06-29) — brand-voice + Home-bubble polish, then full QA re-run; 0 new defects, 0 FATAL.** Copy: `prompt→question` (~26 strings) + clinical→Closer voice (Outcome/check-in feature, "Your Progress"→"Growing together", "Private sync"→"Just for two", Home eyebrow "Your daily question", paywall "…and growth"). Home partner bubble upgraded (Coil `SubcomposeAsyncImage` + gradient ring + a11y; partner photo verified live). Cheap gates all green (210 unit · 24 fn · theme-scan CRIT 0 · painter-xml 0 · smoke 6/6 both). **Reveal-when-answered verified LIVE end-to-end** (both answer → Home "Reveal is ready / Reveal together" → AnswerReveal shows both picks). Multi-angle nav verified (daily Q via Today+Home, reveal via Home card, Settings→Growing together, Play→Question Packs "250 questions"). Cornerstones A/B/D carry from R20 (no rules/crypto/games-logic change — diff is copy + Home-bubble UI); E re-verified (smoke). Also landed this session (uncommitted): recovery-UX partner-as-backup copy + change-phrase desync guard, `SECURITY.md`, first instrumented test `FirstRunRenderSmokeTest`. See `ClaudeReport.md` R21.
|
|
||||||
> **R20 (2026-06-29) — fresh full ClaudeQAPlan run; found + FIXED 2 escaped bugs.** Build HEAD `62696a6` + R20 fixes (uncommitted: `QuestionSessionRepositoryImpl.kt`, `GameSessionManager.kt`, `HomeViewModel.kt`). Cheap gates all green (unit **210** · fn **24** · theme-scan CRIT **0** · painter-xml **0** · wiring 🔴**0** · smoke **6/6 both**). Cornerstones A/B/D/E live-clean, 0 FATAL. **B-ABANDON-001 (P2)** — Quit/abandon on any game silently `PERMISSION_DENIED` (full `saveSession` set drops server-only flags → rule rejects removed `affectedKeys`) → stranded session; **fixed** (targeted `update(status,completedAt)`), verified live (quit→active=0→new game starts). **B-COPY-001 (P3)** — Home GAME_WAITING hero falsely claimed "partner already played their part"; **fixed** (neutral partner-named copy), verified live both devices. Both pending 1 confirm. See `ClaudeReport.md` R20 run-state.
|
|
||||||
> Build = **R18b working tree** (uncommitted: Wheel finish-gate + `partner_joined_game`/banner-standardization client+functions+rules + portrait lock + docs — full file list in `ClaudeReport.md` run-state); **210 unit + 24 functions tests green**; debug APK rebuilt+installed both emulators. **Deploy status:** `functions/` + `firestore.rules` **DEPLOYED by user** (join push live; Tier-2 self-constraint **verified live** — member own-uid add 200, foreign-uid/removal 403). No remaining deploy gates. Position + verdict: see `ClaudeReport.md` R18b run-state. **R18b polish/hardening round (latest):** fixed **E1 (P2)** Wheel silently-swallowed submit failure → retryable error (no false reveal); modern banner/bubble feel (haptics, spring, JOINED presence dot, tap+swipe, a11y, persistent-not-clobbered); predictive back (`enableOnBackInvokedCallback`); Wheel "Quit game" abandon; Tier-2 rules self-constraint. Pass-E smoke 6/6 both. **Verdict: R18 — fixed the last open visual P2 C-DARKART-002** (uiMode-sync in `MainActivity` via `AppCompatDelegate.setDefaultNightMode` so ALL art follows the in-app theme; verified live across all 4 theme/art states) + flaky **TEST-002** (capsule determinism — injected clock) + content **P-GRAMMAR-001** (13 stress-Q subject-verb agreement errors → asset data fix). Live passes this round: **A** (premium gate, Desire Sync + premium pack → Paywall; free content reachable), **B** (Wheel playthrough end-to-end), **L** (chat E2E send/receive-decrypt/at-rest/receipt/no-leak), **E** (backgrounded FCM delivery, privacy-safe, deep-links to chat), **M-001 confirmed** (client mirror intact). **R18b (Future.md review): found+fixed a P0 — O-ONBOARD-001** onboarding/auth crash on EVERY fresh install (`painterResource` on the `<bitmap>` `ic_launcher_foreground`, a regression from icon-redesign commit 334cb07; invisible to logged-in QA emulators). Verified live before/after on fresh 5558 (API 34); fixed both `OnboardingScreen.CtaSlide` + `AuthVisuals.AuthLogoMark` → raster `closer_launcher_foreground`; added `scripts/painter-xml-scan.sh` guard (proven). Also closed BucketList FAB hardcoded color. **Board: 0 open P0/P1 · 1 open P2 (O-AGE-001 pre-ship age gate) · 1 open P3 (BRAND-DARK-COVERAGE); the full confirmed backlog was pruned this round** (O-ONBOARD-001, C-DARKART-002, 6× C-THEME, M-001, TEST-001/002, P-GRAMMAR-001, BucketList-FAB, BRAND-ICON-CUSTOM, and **C-ORIENT-001 → RESOLVED: portrait-locked in the manifest, verified live**). 0 FATAL. **R18b feature work (uncommitted; tests 209 unit + 24 functions green):** (1) **Game finish-gate** — no game can finish with unanswered questions (Wheel: skip allowed but Finish bounces to the first blank; the other 3 already require a pick); verified live end-to-end (full 2-player Wheel + This-or-That). (2) **"Partner joined your game" push + standardized durable in-app game banner** — new `partner_joined_game` (joiner's avatar) + all foreground game pushes routed through the themed banner (started/joined transient, your-turn/results persistent); verified live + Pass-E smoke 6/6 both emulators. **⚠ The join push needs `functions/` + `firestore.rules` deployed by the user to fire live.**
|
|
||||||
>
|
|
||||||
> **Scope expanded (plan review):** the playbook now has first-class passes **K–O** (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. **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
|
|
||||||
> confirmed (ID archived below); finished rounds collapse to the history line. (See Report hygiene in `ClaudeQAPlan.md`.)
|
|
||||||
|
|
||||||
## Status at a glance
|
|
||||||
| Pass | Coverage | Status |
|
|
||||||
|---|---|---|
|
|
||||||
| A — Couple-shared premium | R18: re-verified live (Sam free) — Desire Sync + premium **Boundaries** pack → Paywall; Mixed pack free prompt opens (gate not over-broad); Free filter graceful empty. R13 A-201 (Date Match) holds. | ✅ pass (multi-surface gate re-confirmed R18) |
|
|
||||||
| B — Games lifecycle | R12: 4 async games full 2-device end-to-end (ToT Light×5, Wheel mixed-types, How Well asym, Desire Sync shared/private) + start/join/first-finisher/finish/results/back-stack; CC+MemoryLane+DateMatch render/core (full per R10). **R18b: game FINISH-GATE added** — no game finishes with unanswered Qs (Wheel skip-then-bounce-to-blank; other 3 require a pick); verified live full 2-player Wheel + ToT to completion (no "Skipped" in reveal). **R18b hardening: E1 (P2) FIXED** — Wheel no longer swallows a submit failure (was navigating to a false reveal → data loss); now a retryable error (unit-tested). Wheel got a **"Quit game"** abandon so leaving mid-wheel doesn't strand the session. **R20: B-ABANDON-001 (P2) FIXED** — that abandon (+ ToT/HowWell Quit; all route through `abandonSession`) actually failed `PERMISSION_DENIED` server-side (full `saveSession` `doc.set()` dropped the server-only flags → session-update rule rejected the removed `affectedKeys`) so the session stayed stranded `active`; fix = targeted `update(status,completedAt)`; verified live (quit→active=0→a different game starts immediately). **R20: B-COPY-001 (P3) FIXED** — Home GAME_WAITING hero no longer falsely claims "partner already played their part." | ✅ pass · finish-gate + submit-retry + **abandon now actually works (B-ABANDON-001)** (first-finisher nudge + C-NAV-002 + Ready=Start re-verified live; MemoryLane title/preview run-on → Future.md) |
|
|
||||||
| C — Visual (light+dark) | R16: 9 theme-scan hits triaged → **3 reclassified** (1 @Preview false-pos [scanner now excludes], 2 dead `PlaceholderScreen` deleted) + **6 real FIXED** (BucketList/DateMatch/WheelHistory/QuestionThread tokens); theme-scan CRITICAL **9→0**. R18: **C-DARKART-002 FIXED** (all art follows in-app theme via uiMode-sync) — verified all 4 theme/art states; incidental confirms paywall/pack/Today/chat both directions. R18b: **C-ORIENT-001 RESOLVED** — `MainActivity` locked to portrait (no landscape design; verified `requestedOrientation=PORTRAIT` holds under forced rotation). C-DARKART-002 confirmed + pruned. | ✅ theme-scan CRIT 0 · C-DARKART-002 + C-ORIENT-001 resolved · ⚠️ **BRAND-DARK-COVERAGE (P3) open** — light-only illustrations, see `ClaudeBrandingReview.md` |
|
|
||||||
| D — Security & encryption | R13 LIVE (rules/functions unchanged this session): D3 non-member GET couple+messages → 403; D5 self-grant entitlement PATCH → 403; member GET own couple → 200; D1 chat at-rest `enc:v1:`. D2/D4/D6/D7 carried R7/R10 | ✅ clean — cornerstone holds |
|
|
||||||
| E — Notifications | **R18b LIVE full suite:** cold-start crash-triage smoke **6/6 both emulators** (launcher + 5 push types killed→tap→opens&stays); **routing verified background→tap for 7 types on Sam + 3 on QA (both clients)** — chat→exact conversation, answered/daily_question→Today, started_game/completed_part→game screen, finished_game→per-session results, date_match→Matches; **foreground** game-start banner + chat bubble ✅; **malformed/stale** intents all graceful; **payload privacy** P0 clean (code audit of all 6 trigger payloads + at-rest D1 all `enc:v1:`). 0 FATAL. R18 warm chat deep-link carried. **R18b: NEW `partner_joined_game` (joiner avatar) + standardized durable in-app game banner** — all foreground game pushes route through the themed banner (started/joined transient, your-turn/results persistent + tappable; foreground OS dupe suppressed, background OS unchanged); verified live per kind + Pass-E smoke 6/6 both emulators (join push pending functions+rules deploy). | ✅ pass · delivery + routing + privacy + both-client confirmed R18b · banner standardized · Doze/battery = needs-device |
|
|
||||||
| F — Resilience | R12: concurrency (F-RACE-001 atomic-start code + R8 live) · process-death (smoke `am kill`×5 → push → cold-start recovered each) · offline(R9) | ✅ pass · time-travel + deletion-cascade deferred |
|
|
||||||
| G — Account creation / fake-account | R10: abuse live via D3 (non-member denied, no self-grant) + invite rules; happy/validation R5-clean (unchanged) | ✅ pass |
|
|
||||||
| H — Branding & artwork | R13: ToT redesign + Premium-unlock modal done. **2026-06-27 brand audit opened 2 backlogs.** | ⚠️ **BRAND-DARK-COVERAGE** (light-only illustrations need dark variants) + **BRAND-ICON-CUSTOM** (~60 generic Material icons → bespoke `glyph_*`) — full asset lists in `ClaudeBrandingReview.md` |
|
|
||||||
| I — Performance & route efficiency | R10: core-tabs jank 5.53% (R8 6.3%), 90th 32ms, leak proxy bounded | ✅ no regression |
|
|
||||||
| J — Accessibility | R13: **J-OBS FIXED + verified live** (composer media/voice/retry buttons → 48dp; measured 126px=48dp both axes); font scale 2.0 reflows + reduce-motion×7 (R10) hold | ✅ done · J-OBS fixed (pending 1 confirm) |
|
|
||||||
| K — Billing & subscription lifecycle | Gate (couple-shared unlock) verified via admin toggle (Pass A) + Premium-unlock modal + `onEntitlementChanged` push live (R13/R14). **Real money path (purchase/restore/cancel→expiry-relock/refund/plan-switch) NOT tested** — needs a real device + Play sandbox. | ⚠️ **todo** — money path `blocked→needs-device`; gate ✅ |
|
|
||||||
| L — Messaging & chat (E2E) | R15: conversation render driven live — decrypt **both dirs**, attribution, timestamps, **Seen** receipt, ❤️ **reaction**, ordering, day-separators, voice-note + image bubbles, E2E composer lock glyphs; inbox decrypted previews **no `enc:` leak**; live QA→Sam send delivered; at-rest `enc:v1:`. **R18 re-verified live round-trip** (Sam→QA: received + decrypted on partner, `enc:v1:`(79) at rest, marker absent from server docs = no leak, Seen receipt). Remaining: failed-send/offline retry, delete-message, fresh image/voice send, Discuss-thread live send. | ✅ **pass (core)** — re-confirmed R18; 4 sub-items carry |
|
|
||||||
| M — Settings & account management | R15: **M-001 (quiet hours) FIXED + verified live** (server-side fail-open suppression); per-type notif toggle take-effect confirmed live (server-enforced; field flips in Firestore; toggle-off → 0 delivery); theme/DataStore persistence across relaunch ✅; biometric lock code-sound (cold-start re-lock; background-resume observation → Future.md). Remaining: edit-profile persist, unpair/delete-cascade (disruptive — deferred). **R18: M-001 re-confirmed** — toggling QH writes the client mirror (`quietHoursEnabled`/`StartMinutes 1320`/`EndMinutes 480`/`timezone`) to `users/{uid}` correctly; server suppression deployed + R15-verified. Recommend prune. | ✅ **pass (core)** — M-001 confirmed (prune next); unpair/delete deferred |
|
|
||||||
| N — Daily Q / reveal / check-ins / interactive | R15 (driven): daily-Q + **reveal both-answered gate** ✓; **Bucket List CRUD FIXED+verified (N-001)** — add(`enc:v1:`)/complete/delete/list; **Date Builder FIXED+verified (N-002)** — Create Plan → PLANNED `date_plan` (`enc:v1:`) → Home "Date coming up"; Outcomes/Your Progress code-correct (resolves coupleId, submits); Activity feed render-checked (prior). **R25: NEW Date Memories/Reflection feature landed (reverted-then-reinstated → slipped prior rounds); fixed 5 escaped bugs — DR-TYPING (imePadding), DR-DEEPLINK-BG (MainActivity dropped date_id), DR-FEED-ROUTE (Together `date`→DATE_MATCHES), DR-LOADER (DateMemories infinite spinner on read error), DR-LOCKED (blank dashes when vault locked); + notes field, edit-before-reveal, opened-push. NEEDS live QA pass (both devices, bg+fg notifications).** | ✅ **pass** — N-001/N-002 fixed; **Date Memories/Reflection CLOSED (R26 2-device live: reflect→reveal, edit-before-reveal, notes, bg/fg deep-link, ready/opened pushes + read-failure/timeout hardening)** |
|
|
||||||
| O — Release build & store readiness | **Not started.** All QA to date is on the **debug** APK. Minified release build, signing/AAB, App Check enforcement, i18n/RTL, App-Links, Play Data-Safety = pre-ship gate, not yet run. | ❌ **todo (pre-ship gate)** |
|
|
||||||
| P — Content, copy & language | R15: UI-microcopy swept (warm/inclusive; debug rows `BuildConfig.DEBUG`-gated; friendly error fallbacks; on-brand privacy copy) + **question-bank audit live: 6103 Qs — 0 empty, 0 exact dupes, 0 placeholder tokens, complete/mutually-exclusive answer configs, good type variety, consent-framed sensitive content.** No typos/off-voice/non-inclusive copy found. **R18: found+fixed P-GRAMMAR-001** — in-game wheel surfaced a subject-verb agreement error; bank scan found **13 stress-Q** from one template family where plural subjects hit a singular "{x} is …" frame ("busy weeks/health worries/… is affecting you"); fixed the 13 rows in asset `app.db` (data-only); root fix belongs in the content generator. | ✅ **pass** — copy clean; P-GRAMMAR-001 fixed (asset) + grammar-audit recommended |
|
|
||||||
|
|
||||||
**Archived issue IDs (fixed + confirmed, detail in git):** A-001 · A-003 · A-201 · A-OBS · B-001 · B-002 · B-003 · B-004 · C-CC-001 · C-DARKART-001 · C-DARK-UI-001 · C-DARK-UI-002 · C-DARK-UI-003 · C-DS-001 · C-ART-EDGE-001 · C-ART-EDGE-002 · C-HOME-001 · C-NAV-001 · C-NAV-002 · C-NAV-003 · C-PW-001 · C-SEC-001 · D-001 · E-001 · E-002 · E-003 · E-GAME-002 · E-GAME-003 · E-OBS · F-OBS · F-RACE-001 · I-001 · I-002 · J-OBS. **R18b: confirmed backlog fully pruned** — added C-THEME-001/002/004/005/008/009, C-DARKART-002, M-001, TEST-001/002, P-GRAMMAR-001, BucketList-FAB, BRAND-ICON-CUSTOM, O-ONBOARD-001, C-ORIENT-001 (portrait lock) to the archived set. **R28: HW-BREAKDOWN-001 FIXED+verified (humanize option-ID fallback) + BANNER-RESULTS-COPY-001 (P4) fixed + O-AGE-001 18+ gate IMPLEMENTED+live-verified → archived/mostly-closed.** **Open: 0 P2/P3 blockers.** Remaining non-blocking follow-ups: **O-AGE-001** rules DEPLOYED + birthDate persistence verified against the live rule → only the Play maturity questionnaire remains (product, not code); **BRAND-DARK-COVERAGE** effectively resolved (all 22 `illustration_*` have dark variants; only transparent celebration assets lack one and read fine on dark).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Pass A — Couple-shared premium (neither / partner-only / self)
|
|
||||||
| Feature | neither→locked | partner→both unlock | self→unlock | Status |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| Chat media + reactions | pass | pass | pass | ✅ pass (reference pattern) |
|
|
||||||
| Play: Desire Sync | pass | pass | pass | ✅ pass |
|
|
||||||
| Play: Memory Lane | pass | pass | pass | ✅ pass |
|
|
||||||
| Play: Connection Challenges | pass | pass | pass | ✅ pass |
|
|
||||||
| Question Packs (premium) | pass | pass | pass | ✅ pass |
|
|
||||||
| Wheel: Category Picker / Spin / History | pass | pass | pass | ✅ pass |
|
|
||||||
| Date Match / Plan Date | pass | pass | pass | ✅ pass |
|
|
||||||
| Subscription screen (own status) | n/a | n/a | n/a | ✅ pass (by-design per-user) |
|
|
||||||
|
|
||||||
Verified live: neither→paywall ("Go deeper together"); partner→couple-shared unlock (Sam free entered Desire Sync + Memory Lane); self→unlock; premium badges hidden under premium / shown when free. (A-001 couple-shared gap + A-003 badge fixed & confirmed.)
|
|
||||||
|
|
||||||
## Pass B — Games lifecycle (start / play / finish + results, 2-device, real user-nav)
|
|
||||||
All 7 played one complete time through on both devices via the real in-app path; gameplay all PASS.
|
|
||||||
| Game | starts | plays | finishes/results | no crash | Evidence |
|
|
||||||
|---|---|---|---|---|---|
|
|
||||||
| 1. This or That | pass | pass | pass | pass | 5/5 via Play hub, answers synced, results match both ("Two peas in a pod"). |
|
|
||||||
| 2. How Well Do You Know Me | pass | pass | pass | pass | QA answered 5 (incl. 1–5 scale); Sam predicted via hub → 4/5, wrong one marked ✗ on both, scoring accurate. |
|
|
||||||
| 3. Desire Sync | pass | pass | pass | pass | QA(free) entered w/o paywall; both answered 5 Yes/No → exactly 3 mutual desires, mismatches hidden, match on both. |
|
|
||||||
| 4. Connection Challenges | pass | pass | pass | pass | Gratitude Week → both did Day 1 → 🔥1, advanced to Day 2 synced. (7-day series time-gated; per-day cycle verified.) |
|
|
||||||
| 5. Memory Lane | pass | pass (create+seal) | pass (sealed) | pass | Capsule sealed "Opens in 29 days", encrypted at rest (title+content `enc:v1:`), cross-device. Unlock future-dated. |
|
|
||||||
| 6. Spin the Wheel | pass | pass | pass | pass | Spun → category → both answered all 10, per-Q You/partner breakdown matches both, session synced. |
|
|
||||||
| 7. Date Match | pass | pass | pass | pass | Both swiped deck, 3 mutual likes → 3 `date_matches`, "It's a match!" modal + live push, "Your Matches" shows all 3. |
|
|
||||||
|
|
||||||
Note: exit each game via "Back to Play" between games so the session closes (B-001 auto-completion fix verified). F-RACE-001 (simultaneous start) fixed — see Pass F.
|
|
||||||
|
|
||||||
**R18b — FINISH-GATE (every question must be answered before a game can finish):** Grounding found only **Spin the Wheel** let a player finish with blanks (explicit Skip; `Next` advanced when empty; `End session` submitted the rest as "Skipped"; it's the only game with text boxes). Per user decision ("Hybrid"): Wheel now uses an index-keyed nullable answer store + an **`attemptFinish()` gate** — skip/blank is allowed mid-play, but Finish bounces to the first unanswered prompt with an a11y "N left to finish" banner and submits only when none are blank (enforces non-empty text + ≥1 choice; scale always has a value). The other three already require a pick to advance (ToT/Desire Sync auto-advance on tap; How Well's Continue is disabled until selected) — verified by code + Pass-B observation. **Live (both emulators):** full 2-player Spin-the-Wheel (all 10, mixed written/choice) → completed reveal with **no "Skipped"**; gate bounce + persistent "N left" banner + walk-forward confirmed; then a full 2-player This-or-That ("5/5 in sync"). 3 new `WheelSessionViewModelTest` cases (gaps→bounce/no-submit; all-answered→submit no "Skipped"; completion-walk). 0 FATAL.
|
|
||||||
|
|
||||||
## Pass C — Visual (light + dark), all ~50 routes
|
|
||||||
~14 screen-types swept Dark (5554) + several Light (5556): all render clean, readable, no FATAL, no dark-mode contrast issues; **0 `enc:v1:` leaked to conversation UI**. Covered: Home, Play hub, all 7 game screens (setup/play/reveal), Paywall, Settings (+Subscription +Appearance), Today/daily-question (+answer detail), Messages inbox, Conversation (image+voice+text+reaction). Back-stack clean (deep→hub→Home→launcher, no double-back).
|
|
||||||
- **Theme-scan execution tracking (MANDATORY):** every Pass C round must record the counts exactly as
|
|
||||||
`scripts/theme-scan.sh` reports them in its `## Summary` section, then file the appropriate items:
|
|
||||||
| Round | Scanner run | CRITICAL | MAJOR | REVIEW | CRITICAL filed to ClaudeReport.md | MAJOR filed (if any break themes) |
|
|
||||||
|---|---|---|---|---|---|---|
|
|
||||||
| R15 | ✅ | 9 | 8 | 32 | ✅ (C-THEME-001..009) | — |
|
|
||||||
| R16 | — | — | — | — | — | — |
|
|
||||||
|
|
||||||
R15 scanner: 9 CRITICAL hardcoded surface/background colors filed as C-THEME-001..009; 8 MAJOR (1 component
|
|
||||||
color override + 7 direct `painterResource` hits) and 31 REVIEW items deferred to the visual sweep. Update this
|
|
||||||
row if any MAJOR is promoted to a filed defect.
|
|
||||||
- **R10 re-sweep (both themes where relevant):** Messages inbox ✅ (dark+light: conversations, avatars, unread dot, previews decrypted, no `enc:` leak), Conversation ✅ (image/voice/text/reaction/read-receipt/date-sep, E2E lock glyphs, correct attribution both dirs), per-question Discussion thread ✅, Today/daily-question ✅ (dark+light, paired-books art on-brand), Activity/Together ✅ (dark). **5 P2 found:** C-HOME-001 (Home shows top pending action twice — `primaryAction` hero + `buildPendingActions` row), C-NAV-002 (wheel results→BACK re-enters finished play screen, no popUpTo), C-NAV-003 (Wheel History/Past Games + PartnerHome double app-bar — route in `shellBackRoutes` while screen owns a TopAppBar; C-CC-001 class), C-PW-001 (dark paywall "What's included" pills light-on-light, `BenefitPill` onSurface text), C-SEC-001 (accepter recovery copy). Premium-locked Wheel History state renders. Date Builder · Question Packs(gated→paywall) · Answer Reveal sealed = token-consistent, R9-clean.
|
|
||||||
- **R9 deferred sweep — 0 new issues:** Answer History, Together/Activity, Bucket List (empty state + FAB), Date Match deck, Date Matches all render cleanly in **dark** (good contrast, no clipping, no FATAL); Privacy & Terms + Home confirm **light** parity (shared Material3 tokens). Remaining standard list/detail (Wheel History · Date Builder · Past Games · Answer Reveal sealed · Question Packs[gated→paywall]) are token-consistent with the above; fresh-account auth/onboarding visual covered R3/R5. No C findings.
|
|
||||||
|
|
||||||
## Pass D — Security & encryption (D1–D6) — clean, no P0/P1
|
|
||||||
- **D1 at-rest (admin ground-truth):** messages `text` + `lastMessagePreview`, all 4 game-answer collections (this_or_that/how_well/desire_sync/wheel, both users), capsule title+content, `date_swipes.actions` = `enc:v1:`; `wrappedCoupleKey` ciphertext (recovery-phrase-wrapped, **argon2id**); `encryptedRecoveryPhrase` server-blind + **wiped on acceptance**; plaintext `inviteCode` **not exploitable** (no code-encrypted secret persists; `/invites/{code}` readable only by inviter).
|
|
||||||
- **D2 rules:** no catch-all, no blanket `if true`; sessions update allowlist + immutable `startedByUserId` + monotonic status; `hasPremium` + entitlements server-only; ciphertext enforced on private fields; capsules/challenges member-scoped.
|
|
||||||
- **D3 raw-API negative (LIVE):** non-member ID token → Firestore REST on couple doc/conversation/messages/answers/session/capsules/partner-profile = **all 403**; non-member writes incl. real `users/{uid}/entitlements/premium` = **all 403 → no self-grant**. Member token reads 200 → **App Check not enforced on Firestore; rules are the sole gate and hold**.
|
|
||||||
- **D4/D5/D6:** wrapped couple key + KDF; App Check (client), gitignored SA JSONs, `allowBackup=false`; analytics metadata-only. Unchanged, hold.
|
|
||||||
- One hardening note → `Future.md` (App Check off on Firestore). _(R15 correction: `users/{uid}` update rule enforces a **field allowlist** — not arbitrary; extended R15 for `quietHours*`+`timezone`.)_
|
|
||||||
|
|
||||||
## Pass E — Notifications (type × {foreground / background / killed} + tap-to-open, both clients)
|
|
||||||
Full live two-device run (games + messages):
|
|
||||||
- **chat_message** ✅ end-to-end — channel `partner_activity`, title "Sam sent a message" (name, not private), body content-free, **text NOT in payload**; tap → exact conversation with content; white monochrome small icon.
|
|
||||||
- **partner_started_game** ✅ — channel `game_activity`, "QA is playing… Tap to join!" (content-free); tap → joins the active session.
|
|
||||||
- **partner_finished_game / results** ✅ — results push delivered to backgrounded partner, channel `game_activity`, content-free; tap → per-session results (per E-003 fix).
|
|
||||||
- **results-suppression** ✅ — partner foregrounded on the session received 0 pushes (ActiveGameSessionMonitor), while backgrounded partner got the results push. Delivery + suppression both confirmed.
|
|
||||||
- **New speculative types — `not implemented → Future.md` (R8 code check, 0 files each):** `join_game`/`game_invite`, `partner_joined_game`, `game_abandoned`/`game_ended`, `date_plan_update`, `memory_capsule_created`, `challenge_day_completed`, `subscription_entitlement_changed`. Worthwhile ones (couple-premium-unlock push; join/end pushes) logged to `Future.md` `## QA`. Not counted as pass.
|
|
||||||
- **partner_answered (couple-key reveal, 2026-06-26)** ✅ live both-client — `onAnswerWritten` fires on each answer; the second answer hits the **both-answered "Your answers are unlocked ✨"** copy (recipient already answered). `onAnswerRevealed` ✅ fires when `isRevealed` flips false→true → "opened your answers" push to the partner (witnessed live). Privacy gate (raw API): partner content **403** until both answer, **200** after; non-member **403** throughout. Reveal screen shows the partner's answer **both directions**. At-rest: content-free metadata + gated `enc:v1:` `secure/payload`.
|
|
||||||
- **Deferred (Round 9):** the full implemented-type × {fg/bg/killed} matrix isn't exhaustively re-run live — implemented types are routing-code-verified + centralized in `PartnerNotificationType`; chat/game start/finish/results + date_match verified live (R3/R5/R6).
|
|
||||||
- **R18b (2026-06-28) — full live re-run, 0 FATAL:**
|
|
||||||
- **Cold-start crash-triage smoke 6/6 on BOTH emulators** (`qa/entrypoint_smoke.sh`): launcher + `partner_started_game`/`partner_completed_part`/`partner_finished_game`/`chat_message`/`partner_answered` each killed (`am kill`) → real push → tapped from shade → **opens & stays** (0 fail, 0 blocked). This is the shared cold-start path (splash/onCreate) where the splash-exit crash class hid — clean.
|
|
||||||
- **Routing (background→tap, landed-screen verified, not just "opens"):** Sam received all 7 types; QA received 3 (both-client). chat_message(conv=main)→**exact conversation thread** (composer + Seen); partner_answered & daily_question→**Today/daily-question**; partner_started_game & partner_completed_part(tot)→**game screen**; partner_finished_game(wheel,session)→**per-session wheel results** ("Here's how you each answered", completed→results not a dead active session); date_match→**"Your Matches"**. Every tap: correct destination, app alive, **0 FATAL**.
|
|
||||||
- **Foreground in-app delivery (onMessageReceived intercepts; no OS tray):** `partner_started_game`→**in-app banner** "Your partner started a game · This or That" with Join/dismiss ✅; `chat_message`→**draggable chat-head bubble** ✅ (verified via real open-thread→back→Home→send flow + a distinct conv id; the `conversation_id=main` suppression seen on a process-death-restored back stack is **by-design read-suppression**, `ActiveThreadMonitor`, that clears on normal back-nav — not a defect).
|
|
||||||
- **Malformed / stale intents (all graceful, 0 FATAL):** unknown type→no nav, no crash; chat_message w/o `conversation_id`→**Messages inbox** (fallback); partner_started_game w/o `game_type`→**Play hub** (fallback); partner_finished_game w/ **deleted session id**→graceful "waiting" state w/ Back-to-Play/End escape (no crash, no dead-end).
|
|
||||||
- **Payload privacy (P0) — code audit of every sender + at-rest D1:** `onMessageWritten`, `onGameSessionUpdate` (+ part-finished), `onAnswerWritten`, `onAnswerRevealed`, `createDateMatch`, `onCoupleLeave` — each `data` block carries only routing IDs (type, couple_id, conversation/question/game/session id) + optional **public** avatar URL; titles use only the partner's display name; bodies are static. **No message/answer/date/swipe content, no keys/invite codes/recovery phrases.** At-rest cross-check: latest 6 `conversations/main/messages` all `enc:v1:` (server-blind source). Cross-checks D6.
|
|
||||||
- **Real `onMessageWritten` live re-drive NOT re-run this round** (UI-automation thrash on the composer send button) → carried from **R18 live** (exact copy "Sam sent a message"/"Tap to read and reply", no content) + this round's payload code audit + at-rest D1.
|
|
||||||
- **Doze / battery-optimization / App-Standby delivery → `blocked → needs-device`** (emulators never enter these states; the #1 real-world "notifications don't work" cause). Run on a physical device before any store push (`dumpsys deviceidle force-idle`, app Optimized→Restricted).
|
|
||||||
- **R18b — `partner_joined_game` + standardized durable in-app game banner (FEATURE):** new push when the non-starter opens an active session → the **starter** gets "<Name> joined your game" with the **joiner's avatar** (server-visible join via `joinedByUsers`; one-time `joinNotifiedAt`; `onGameSessionUpdate` branch). All four foreground game pushes now route through the themed `GamePromptBanner` with the partner's avatar + `sender_name`: **started/joined transient (~9s), your-turn/results persistent until tapped**; foreground OS duplicate suppressed; **background OS notification unchanged** (already shows the avatar large-icon — the purple banner is in-app only). **Live (5554):** all four kinds rendered correctly with avatar+name; **RESULTS still shown at 15s vs STARTED auto-dismissed by 12s**; 0 foreground OS dupes; 0 FATAL. **Pass-E cold-start smoke 6/6 on both emulators** (shared path regression-clean). `PartnerNotificationTypeTest` covers the new type's mapping + routing. **`blocked→deploy`: the join push only fires once `functions/` + `firestore.rules` are deployed** (the `joinedByUsers` client write is rule-gated; best-effort + swallowed until then). The banner standardization for already-deployed types works immediately.
|
|
||||||
|
|
||||||
## Pass F — Resilience / lifecycle / concurrency / time
|
|
||||||
- **Concurrency race:** F-RACE-001 (P1) fixed + **re-confirmed live (R8):** simultaneous mood-tap on both devices → **1 session** (was 2); race-loser landed on WaitingForPartner → **"Join the game"** → joined the winner's session at the **same Q1** (shared reveal preserved). Archived. *(Minor pre-existing note: loser can alternatively land on Play hub; not seen this run.)*
|
|
||||||
- **Offline:** airplane mode → Today renders from cache, no crash.
|
|
||||||
- **Lifecycle:** rotation/config-change → state preserved; ~6 cold restarts → clean to Home (auth persists).
|
|
||||||
- **Robustness:** malformed/abusive deep-link intents (unknown type, missing extras, injection/path-traversal) → 0 crash; killed-state cold-start chat deep-link → conversation loads.
|
|
||||||
- **R9 network resilience:** airplane-mode on → Date Match + Messages render from cache, **no crash, no error dead-end**; reconnect → inbox refreshes, no stuck state, 0 FATAL (extends R3 offline-Today-from-cache).
|
|
||||||
- **Deferred (Round 10, low-risk):** time-travel-gated content (capsule unlock, challenge day-gating — needs clock manipulation); account-lifecycle deletion-cascade deep run (disruptive on the baseline couple). Minor note: race-loser can land on Play hub vs WaitingForPartner (no dup/crash; pre-existing routing).
|
|
||||||
|
|
||||||
## Pass G — Account creation, validation & fake-account abuse
|
|
||||||
Sign-up end-to-end (email/pw/confirm → 3-step profile → unpaired home) ✅; weak-password → friendly "at least 8 characters" ✅; fresh-account isolation (zero couple data) ✅; **duplicate-email → `auth/email-already-exists`** rejected ✅; invite single-use + 24h expiry, **bogus code → "Invite not found."** ✅; recovery phrase client-generated ✅; sign-out → onboarding → debug-token restore ✅. **No security findings.** (Non-member READ denial = live D3 above + app-level isolation.)
|
|
||||||
|
|
||||||
## Pass I — Performance & route efficiency (R8, build `23dd6a7`, emulator-5554, debug build)
|
|
||||||
Route smoke-test checklist (re-runnable: `dumpsys gfxinfo closer.app reset` → drive route → read `gfxinfo`):
|
|
||||||
| Route / list | Jank / latency | Notes |
|
|
||||||
|---|---|---|
|
|
||||||
| Cold start → Home | 1253ms to first frame | acceptable (debug; release AOT-faster); no skipped frames, no ANR |
|
|
||||||
| Core tabs (Home/Today/Play/Messages/Settings) | 6.3% janky frames | smooth; no Choreographer-skip spam |
|
|
||||||
| Conversation (realtime listener) scroll | 90th 36ms / 95th 53ms | minor debug hitching; **no leak** |
|
|
||||||
| Play hub scroll | 90th 36ms / 95th 38ms | smooth |
|
|
||||||
- **Caching / lazy-load:** LazyColumn/Row/Grid in 17 files; Coil (AsyncImage) in 11; Room DAOs cache static question/category data locally — all in place, no load-all anti-patterns seen.
|
|
||||||
- **Leak check:** conversation open/close ×6 → ViewRootImpl=1, Activities=1, Views +2, PSS bounded after trim → no window/Activity/listener leak.
|
|
||||||
- **Redundant reads:** precise per-read counts need an instrumented/Perfetto build (Firestore success reads aren't in adb logcat); no failing-read spam **except I-001**; no leaked listeners.
|
|
||||||
- **Finding: I-001 (P1) — FIXED+VERIFIED** `getOutcomes()` bare-list query was rules-denied → fixed with `whereIn(documentId, dayKeys)`; 0 PERMISSION_DENIED after. **I-002 (P1) — FIXED+VERIFIED** (found fixing I-001): scores stored as int64 → read as Long → `Map<String,Int>` cast CCE → swallowed; fixed via `Number.toInt()`. Live: seeded day_0 → "Your Progress" shows "Baseline recorded". Both pending Round-9 confirm.
|
|
||||||
|
|
||||||
## Pass J — Accessibility (R8, emulator-5554)
|
|
||||||
- **Font scaling (font_scale 2.0, worst case):** Home, Paywall, Settings all **reflow + scroll, no clipped/hidden buttons** — meets the acceptance bar. Minor: long subtitles/email ellipsize, bottom-nav labels wrap ("Mess ages"). Restored to 1.0. ✅
|
|
||||||
- **TalkBack / semantics:** 0 `Icon()` calls without `contentDescription`; 111 explicit `null` (decorative silenced); meaningful labels on all key controls (Back ×26, Send, Close, Dismiss, photo actions, date-swipe Love/Maybe, capsule, edit/delete); loader uses `clearAndSetSemantics` + "Loading…" message. ✅
|
|
||||||
- **Touch targets:** most controls 48dp; **J-OBS (P3):** a few conversation icon-buttons measure ~42–45dp wide (48dp tall) — single-axis marginal, fully operable; bump to 48dp.
|
|
||||||
- **Reduce-motion (animator_duration_scale 0):** nav sweep + screens work, no hang/unreachable content, 0 FATAL; honored in code across 7 surfaces (LoadingState, CelebrationOverlay, AnswerReveal, DesireSync, ThisOrThat, BrandMessageRotator, LocalQuestionContent). Restored to 1. ✅
|
|
||||||
- **Contrast:** covered by Pass C both themes (C-DS-001 dark-contrast fixed); precise WCAG ratios need a measurement tool — spot-checks clean, no new dim areas.
|
|
||||||
- **Keyboard/IME:** text fields validated functionally in Pass G (sign-up/profile); full hardware-keyboard tab-order **deferred** (emulator HW-keyboard harness).
|
|
||||||
- **Findings:** J-OBS (P3) only; no P0/P1/P2 a11y blockers.
|
|
||||||
|
|
||||||
## Pass H
|
|
||||||
- **H Branding** — deliverable in `ClaudeBrandingReview.md` (consumer brand walk → ready-to-paste art prompts).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Round history (one line each)
|
|
||||||
- **R15** — gap-closing round (Passes L/M/N/P + regression smoke); **3 bugs found, 2 fixed.** **M-001 (P2 quiet hours)** — local-only window didn't suppress backgrounded/killed partner pushes; fixed via server-side fail-open `recipientInQuietHours()` in the 4 partner-action senders + client window/tz sync + rules allowlist; verified live (fn log suppress vs notify); deployed prod. **N-001 (P1) Bucket List fully non-functional** (coupleId never set → all CRUD no-ops) — FIXED (VM resolves couple in init) + verified live (add `enc:v1:`/complete/delete/render). **N-002 (P2) Date Builder "Create Plan" no-op** (wrote to unread prefs collection; ids never wired) — FIXED (re-pointed to create a PLANNED `DatePlan` via `savePlan` → Home "Date coming up") + verified live. L chat-core, P copy+question-bank (6103 Qs) clean; smoke 6/6 GREEN. Corrected stale "users/{uid} allows arbitrary fields" claim (there's an allowlist).
|
|
||||||
- **R14** — full fresh A–J ClaudeQAPlan run (pure QA, no code), FLAWLESS, 0 new findings: confirmation round on the R13 build — premium enforcement + couple-shared unlock + entitlement push (live); Desire Sync/How Well/Spin-the-Wheel full 2-device + first-finisher nudge; Memory Lane create+seal, CC resume, Date Match deck; decoupled-theme-art mandate; cornerstone live (403s + enc:v1:); offline + process-death; jank 5.25%; J-OBS 48dp holds. The 5 R13 fixes held → pruned (archived line).
|
|
||||||
- **R13** — open-backlog fix pass + full fresh A–J, FLAWLESS (0 open P0–P3): fixed C-DARK-UI-001 (ToT dark redesign), C-DARK-UI-002 (check-in label), C-DARK-UI-003 (bottom insets), C-ART-EDGE-002 (8 opaque heroes feathered), J-OBS (48dp targets); confirmed A-201 live→pruned; shipped Premium-unlock modal (one-time, both partners, couple-shared, verified live). Pass D cornerstone re-verified LIVE (non-member 403, self-grant 403, member 200, at-rest enc:v1:). Diff UI-only → E/F/G carried. 0 FATAL both emulators.
|
|
||||||
- **R12** — FRESH FULL A–J run + fix phase, FLAWLESS (0 open P0–P2): found+fixed **A-201** (P1 Date Match premium bypass — gated via CouplePremiumChecker→Paywall, verified live); 4 async games full 2-device E2E; security cornerstone live-clean (non-member 403 read+write, self-grant 403); smoke 6/6; jank 4.10%; new P3 C-ART-EDGE-002 (hero edges, deferred); C-DARKART-001+C-ART-EDGE-001 held→pruned; Pass A retrospective added (badge≠gate).
|
|
||||||
- **R11** — confirmation round, FLAWLESS (0 open P0–P2): fixed C-DARKART-001 (P2, art follows in-app theme via `LocalAppInDarkTheme` + config-overridden context) + C-ART-EDGE-001 (P3, edge feathering) in shared `BrandIllustration`/`EmptyState`, verified live both decoupled theme directions (system-light+app-Dark→dark art · system-dark+app-Light→light art), 0 FATAL; re-confirmed + pruned the 5 R10 P2 fixes (C-HOME-001/C-NAV-002/C-NAV-003/C-PW-001/C-SEC-001); entrypoint smoke 6/6 green on fresh APK (launcher + 5 notif cold-starts open & stay). Art fixes in working tree; rest committed `2cd0af6`.
|
|
||||||
- **R10** — FULL run A–J + fix phase: 5 P2 found+fixed+verified-live (C-HOME-001 dup card · C-NAV-002 wheel back-stack · C-NAV-003 dup app bar · C-PW-001 dark paywall · C-SEC-001 recovery wrong-store); E-GAME-002 confirmed live (start push+banner+Join) & pruned; concurrency double-start→1 session; security D1–D7 clean; perf/a11y no regression. 0 open P0–P2 (5×P2 pending 1 confirm).
|
|
||||||
- **R7** — security/concurrency deep dive (multi-angle): cornerstone clean; F-RACE-001 found+fixed+verified. 0 new open.
|
|
||||||
- **R6** — branding drop + Future.md backlog regression: 0 new open.
|
|
||||||
- **R5** — Cloud Functions deployed (E-OBS/E-003) + new Pass G clean: 0 open.
|
|
||||||
- **R2–R4** — play-as-user game restart + fix phase; all P0–P2 fixed + verified (archived IDs above).
|
|
||||||
1515
ClaudeQAPlan.md
1515
ClaudeQAPlan.md
File diff suppressed because it is too large
Load Diff
262
ClaudeReport.md
262
ClaudeReport.md
File diff suppressed because one or more lines are too long
140
ClaudeiOSPlan.md
140
ClaudeiOSPlan.md
|
|
@ -1,140 +0,0 @@
|
||||||
# Claude iOS Plan — Part 2 (build to parity) + Part 3 (iOS QA)
|
|
||||||
|
|
||||||
> Program: **Part 1** = Android QA (`ClaudeQAPlan.md`, in progress) → **Part 2** = build the iOS app to Android's
|
|
||||||
> CURRENT state → **Part 3** = run the same QA passes on iOS. This doc covers Parts 2 & 3.
|
|
||||||
|
|
||||||
## Decisions (locked)
|
|
||||||
- **Approach:** native **SwiftUI** + Firebase iOS SDK (the `iphone/` scaffold already chose this: SwiftPM + XcodeGen,
|
|
||||||
feature dirs mirroring Android, `ARCHITECTURE_AUDIT.md`). Do NOT switch to KMP/Compose-MP.
|
|
||||||
- **E2EE:** **full Tink-compatible** crypto — iOS must byte-match Android's wire formats so an **iOS↔Android couple
|
|
||||||
decrypts each other**. (Overrides the audit's "skip E2EE for MVP" note — encryption is the cornerstone.)
|
|
||||||
- **Scope:** **working parity build** (Simulator + real device, TestFlight optional). **No App Store submission.**
|
|
||||||
|
|
||||||
## ⚠️ Hard constraint — macOS required (we are on Linux)
|
|
||||||
iOS build/run/QA needs **macOS + Xcode + iOS Simulator**; there is **no adb-equivalent from Linux**. Therefore:
|
|
||||||
- **On this Linux box** I can only: author/refresh Swift source, refresh the audit, write the plan/specs, and reason
|
|
||||||
about the code. **I cannot compile, run the Simulator, or do interactive/visual QA here.**
|
|
||||||
- **Everything that requires building or running is deferred to a Mac** (a Mac, cloud-Mac, or macOS CI runner).
|
|
||||||
- Part 2 done-on-Linux = "code authored + self-reviewed"; Part 2 *truly* done = builds green in Xcode and runs on
|
|
||||||
Simulator + device (on a Mac). **Part 3 (iOS QA) is entirely macOS-gated.**
|
|
||||||
|
|
||||||
## Current iOS state (as found)
|
|
||||||
`iphone/` = scaffold only: ~19 stub Swift files, `Crypto/` **empty**, most feature dirs empty, one `FirestoreService`.
|
|
||||||
`ARCHITECTURE_AUDIT.md` is thorough but generated from Android **v0.2.0** — it **predates** Messages/conversations,
|
|
||||||
chat media (photo/GIF/sticker/voice), reactions/typing/read-receipts/pagination, and couple-shared premium (A-001).
|
|
||||||
So this is a near-full build, not a top-up.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Part 2 — Build iOS to current Android parity
|
|
||||||
|
|
||||||
### 2.0 Refresh the audit to CURRENT Android (do first)
|
|
||||||
Update `iphone/ARCHITECTURE_AUDIT.md` from the live Android code: add the **Messages** tab (conversations inbox +
|
|
||||||
conversation chat: text/photo/GIF/sticker/voice, reactions, delete/tombstone, typing, read-receipts, pagination,
|
|
||||||
chat-head bubble), the conversations Firestore model, the new notification types, and **couple-shared premium**
|
|
||||||
(`CouplePremiumChecker` — either partner premium unlocks all features for both). Re-tally screens/models/functions.
|
|
||||||
|
|
||||||
### 2.1 Project + dependencies (Mac)
|
|
||||||
XcodeGen/`project.yml`; SPM: Firebase Auth/Firestore/Functions/Messaging/Storage, RevenueCat (`purchases-ios`),
|
|
||||||
GoogleSignIn; `GoogleService-Info.plist`; entitlements (push, keychain); **App Check** via DeviceCheck/App Attest
|
|
||||||
(Android uses Play Integrity); APNs setup for FCM.
|
|
||||||
|
|
||||||
### 2.2 Core infrastructure
|
|
||||||
`AppDependencies` (manual DI), `AuthService` + `AuthRateLimiter` (mirror Android limits), `FirestoreService` +
|
|
||||||
per-domain services (User/Couple/Invite/Question/Answer/Game/Date/Conversation), `NotificationService`
|
|
||||||
(APNs+FCM + deep-link routing mirroring `PartnerNotificationType`), navigation (TabView + NavigationStack per tab,
|
|
||||||
`AppRoute` mirror, deep-link handler), `CloserTheme` (light+dark), reusable components.
|
|
||||||
|
|
||||||
### 2.3 E2EE — full Tink-compatible crypto (HIGHEST RISK; cornerstone)
|
|
||||||
Implement byte-compatible Swift crypto for every Android wire format:
|
|
||||||
- `SealedAnswerEncryptor` (AES-256-GCM, 96-bit IV, AAD `"{coupleId}|{questionId}|{userId}"`, `sealed:v1:`),
|
|
||||||
`FieldEncryptor` (`enc:v1:`, AAD=coupleId) — used by messages/previews/dates **and now profile metadata
|
|
||||||
(`displayName` + `sex` in `users/{uid}`)**,
|
|
||||||
`AnswerCommitment` (SHA-256, `sha256:`), `UserKeyManager` (ECIES P-256 HKDF-HMAC-SHA256 AES128-CTR-HMAC, keypair in
|
|
||||||
Keychain, `pub:v1:`), `ReleaseKeyEncryptor` (`keybox:v1:`), `RecoveryKeyManager` (**Argon2id m=46MiB, t=3, p=1**,
|
|
||||||
BIP39-style wordlist), `CoupleEncryptionManager`, Keychain-backed key stores.
|
|
||||||
- **Interop test harness (the acceptance gate):** (a) decrypt Android-produced fixtures (`sealed:v1:`/`enc:v1:`/
|
|
||||||
`keybox:v1:`) on iOS; (b) have Android decrypt iOS-produced ciphertext; (c) verify an Android-generated recovery
|
|
||||||
phrase unlocks on iOS and vice versa. Wire formats must match byte-for-byte. If a format can't be matched with
|
|
||||||
CryptoKit, use a vetted Swift lib (e.g. swift-sodium/SwiftArgon2) — never a non-interoperable shortcut.
|
|
||||||
- **Golden vectors** for the deterministic primitives (checked into both repos): `AnswerCommitment` SHA-256 and
|
|
||||||
`RecoveryKeyManager` Argon2id (fixed salt/params) must produce **identical bytes** on both platforms — assert in unit
|
|
||||||
tests on each side. AEAD/ECIES use random IVs so they can't be golden-matched; cover those with the **round-trip**
|
|
||||||
harness above. Generate the Android fixtures now (on Linux) so iOS has them ready.
|
|
||||||
- **⛔ Profile-metadata decrypt-on-read (REQUIRED before iOS launch — R22).** Android now encrypts `displayName` +
|
|
||||||
`sex` under the couple key. iOS reads these raw today ([HomeViews](iphone/Closer/Home/HomeViews.swift),
|
|
||||||
[SettingsViews](iphone/Closer/Settings/SettingsViews.swift)) and will show `enc:v1:…` until it decrypts them on
|
|
||||||
read (tolerant of legacy plaintext; show a 🔒 placeholder when the key is missing) — mirror Android's
|
|
||||||
`FirestoreUserDataSource` chokepoint + the pairing/legacy **migration** and **unpair-revert**. Until this ships,
|
|
||||||
iOS shows the locked placeholder for name/gender (acceptable in dev; **not** for release).
|
|
||||||
- **⛔ Date reflections (REQUIRED before iOS launch — R22).** The new Date Memories & Replay feature adds an E2EE
|
|
||||||
`couples/{id}/date_reflections/{dateId}/answers/{uid}` collection with the same couple-key + read-gated
|
|
||||||
`secure/payload` reveal as the daily question (`date_history` is plaintext). iOS must implement the matching
|
|
||||||
reflection write/decrypt/reveal (mirror `FirestoreDateReflectionDataSource`) and the `date_reflection_*` /
|
|
||||||
`date_logged` notification types; until then iOS can't participate in date reflections.
|
|
||||||
- **⛔ Conversation backup + full partner-assisted restore (REQUIRED before iOS launch — R24).** Android now keeps a
|
|
||||||
couple-key-encrypted conversation backup (`couples/{id}/backup/manifest` + `.../chunks/{seq}` `enc:v1:`; snapshot
|
|
||||||
blob at Storage `users/{uid}/backups/{id}`) and a partner-assist flow (`couples/{id}/restore_requests/{uid}` with
|
|
||||||
a fresh `pub:v1:` + partner-written `keybox:v1:`; ECIES context `"{coupleId}|restore|{sender}|{recipient}|{nonce}"`;
|
|
||||||
6-digit OOB code = `truncate6(SHA-256(pubkey‖nonce))`). For **cross-platform** restore, iOS must byte-match the
|
|
||||||
`BackupCodec` JSON envelope, the couple-key wrap, and the keybox context/code so an iOS device can restore (self or
|
|
||||||
partner-assisted) from an Android partner and vice-versa — same class as the existing E2EE interop gate. Also wire
|
|
||||||
the `restore_requested` notification type. Until then iOS can't back up/restore or help a partner restore.
|
|
||||||
- **⛔ Partner-assist consent hardening (R24-b).** iOS's consent screen must mirror Android: resolve the recipient
|
|
||||||
via the user service and show their **email (plaintext anchor) + locally-decrypted display name**, and gate
|
|
||||||
Approve on **both the 6-digit code AND an explicit "I reached them" confirmation**. Mirror the two lifecycle
|
|
||||||
fixes — **delete any existing `restore_requests/{uid}` before re-creating** (a `set()` over an existing doc is a
|
|
||||||
rule-denied key-changing update) and **reject an expired request at fulfil**. Handle the new `restore_self_alert`
|
|
||||||
notification type (route to account security). The server (`onRestoreRequested`/`onRestoreFulfilled`) is shared,
|
|
||||||
so this is purely the iOS client half.
|
|
||||||
|
|
||||||
### 2.4 Screens & features to parity (~48 + new messaging)
|
|
||||||
All routes from the refreshed audit's screen map, **including the NEW Messages experience** (inbox + conversation
|
|
||||||
with full media/reactions/typing/read-receipts/pagination) and the games (full, not stubs), dates, wheel, settings,
|
|
||||||
paywall. Mirror the Android UX + the couple-shared premium gating (route gated features through the iOS
|
|
||||||
`EntitlementChecker` couple-shared equivalent).
|
|
||||||
|
|
||||||
### 2.5 Build & smoke (Mac)
|
|
||||||
Xcode build green; run on Simulator + a real device; smoke each major flow (auth → pair → home/today/play/messages/
|
|
||||||
settings) with no crashes. **This step + 2.1/2.5 runs on a Mac.**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Part 3 — iOS QA (reuse `ClaudeQAPlan.md` passes A–E on iOS)
|
|
||||||
Same methodology, severity scale (P0–P3), report-only→fix→re-QA-until-flawless loop, continuity (use
|
|
||||||
`ClaudeReport-iOS.md` + `ClaudeQACoverage-iOS.md` + run-state header), and **autonomous run-to-completion** mode.
|
|
||||||
**macOS-gated.** Tooling adaptation from Android:
|
|
||||||
|
|
||||||
| Android (Part 1) | iOS (Part 3) |
|
|
||||||
|---|---|
|
|
||||||
| `adb` install/tap/screencap | `xcrun simctl` install/io screenshot; XCUITest or `idb` to drive taps |
|
|
||||||
| 2 emulators (5554/5556) | 2 iOS Simulators (or Simulator + device) for the two partners |
|
|
||||||
| `adb logcat` FATAL/ANR | `xcrun simctl spawn booted log stream` / Console crash logs |
|
|
||||||
| `cmd uimode night` for theme | Simulator Appearance (light/dark) per device |
|
|
||||||
| premium toggle via `set_premium.js` | same admin script (server-side) |
|
|
||||||
|
|
||||||
Run all passes on iOS:
|
|
||||||
- **A** couple-shared premium (gates + paywall), **B** each game one full playthrough on both, **C** visual
|
|
||||||
light+dark all screens + navigation-from-every-entry + back-stack/double-back, **D** security/encryption, **E**
|
|
||||||
notifications (APNs/FCM delivery + tap-to-open every type).
|
|
||||||
- **NEW — cross-platform pass (X):** a **mixed couple (Android + iOS)** — messaging, answers, dates, premium, and
|
|
||||||
notifications work cross-platform, and **E2EE decrypts both ways** (the Part 2 interop gate, verified live on real
|
|
||||||
paired devices). This is the make-or-break for the cornerstone.
|
|
||||||
- **iOS-native dimensions (add to the passes):** Dynamic Type (largest sizes), **VoiceOver**, safe-area / notch /
|
|
||||||
Dynamic Island, multiple device sizes (SE → Pro Max; iPad if supported), **edge-swipe-back gesture** + interactive
|
|
||||||
pop (the iOS "double-back"/nav-stack analog), `scenePhase` background/foreground, dark/light.
|
|
||||||
- **Real-device / sandbox needs:** **App Attest/DeviceCheck** and **APNs push** don't fully work on the Simulator —
|
|
||||||
use a real device (or `xcrun simctl push` with a payload for local notif routing); **RevenueCat IAP** needs a
|
|
||||||
StoreKit config file or a sandbox Apple ID. Plan Pass A/E around this.
|
|
||||||
|
|
||||||
## Definition of done
|
|
||||||
- **Part 2:** iOS builds green + runs on Simulator + device at feature parity with current Android; E2EE interop
|
|
||||||
harness passes (Android↔iOS decrypt both ways).
|
|
||||||
- **Part 3:** an iOS QA round is flawless (zero P0–P2, D+E clean, every game fully played, nav/back verified) AND the
|
|
||||||
cross-platform pass X is clean.
|
|
||||||
|
|
||||||
## What can proceed now (Linux) vs needs a Mac
|
|
||||||
- **Now (Linux):** 2.0 audit refresh; author Swift for models/services/crypto/screens; write iOS QA tooling scripts;
|
|
||||||
prepare Android-produced crypto fixtures for the interop harness.
|
|
||||||
- **Needs a Mac:** 2.1 project/deps, all compiling, 2.5 build+run, and **all of Part 3**. Surface this as the blocker
|
|
||||||
when execution reaches it (per the autonomous-mode rule: do all non-gated work first, then flag the Mac requirement).
|
|
||||||
40
README.md
40
README.md
|
|
@ -27,6 +27,14 @@
|
||||||
</sub>
|
</sub>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img alt="Android" src="https://img.shields.io/badge/Android-reference%20app-3DDC84?style=flat-square&logo=android&logoColor=white" />
|
||||||
|
<img alt="iOS" src="https://img.shields.io/badge/iOS-scaffold%20in%20progress-007AFF?style=flat-square&logo=apple&logoColor=white" />
|
||||||
|
<img alt="Firebase" src="https://img.shields.io/badge/Firebase-backend-FFCA28?style=flat-square&logo=firebase&logoColor=black" />
|
||||||
|
<img alt="RevenueCat" src="https://img.shields.io/badge/RevenueCat-billing-6D5DF6?style=flat-square" />
|
||||||
|
<img alt="License" src="https://img.shields.io/badge/license-private-E5484D?style=flat-square" />
|
||||||
|
</p>
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
Closer is a native relationship app for couples who want a quieter way to check in with each other.
|
Closer is a native relationship app for couples who want a quieter way to check in with each other.
|
||||||
|
|
@ -40,7 +48,7 @@ likes, followers, or infinite-scroll loops. The product loop is small on purpose
|
||||||
answer honestly -> reveal together -> keep the conversation going
|
answer honestly -> reveal together -> keep the conversation going
|
||||||
```
|
```
|
||||||
|
|
||||||
## Screenshots
|
<h2 align="center">Screenshots</h2>
|
||||||
|
|
||||||
Fresh Android dark-mode captures from the current emulator build.
|
Fresh Android dark-mode captures from the current emulator build.
|
||||||
|
|
||||||
|
|
@ -72,7 +80,7 @@ Fresh Android dark-mode captures from the current emulator build.
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
## What makes it different
|
<h2 align="center">What makes it different</h2>
|
||||||
|
|
||||||
<table>
|
<table>
|
||||||
<tr>
|
<tr>
|
||||||
|
|
@ -87,7 +95,7 @@ Fresh Android dark-mode captures from the current emulator build.
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
## Product surface
|
<h2 align="center">Product surface</h2>
|
||||||
|
|
||||||
| Area | Free | Premium |
|
| Area | Free | Premium |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
|
|
@ -107,7 +115,7 @@ Fresh Android dark-mode captures from the current emulator build.
|
||||||
Data export is not currently offered. The in-app privacy copy intentionally says so until a real export
|
Data export is not currently offered. The in-app privacy copy intentionally says so until a real export
|
||||||
flow exists.
|
flow exists.
|
||||||
|
|
||||||
## Platform status
|
<h2 align="center">Platform status</h2>
|
||||||
|
|
||||||
| Platform | Status | Notes |
|
| Platform | Status | Notes |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
|
|
@ -119,7 +127,7 @@ flow exists.
|
||||||
Android is the product source of truth today. iOS has the app shell, Firebase/RevenueCat integration,
|
Android is the product source of truth today. iOS has the app shell, Firebase/RevenueCat integration,
|
||||||
and SwiftUI screens, but end-to-end pairing waits on CryptoKit/Tink interoperability.
|
and SwiftUI screens, but end-to-end pairing waits on CryptoKit/Tink interoperability.
|
||||||
|
|
||||||
## Architecture
|
<h2 align="center">Architecture</h2>
|
||||||
|
|
||||||
```text
|
```text
|
||||||
Android (Kotlin/Compose) iOS (SwiftUI)
|
Android (Kotlin/Compose) iOS (SwiftUI)
|
||||||
|
|
@ -141,7 +149,7 @@ Core rules:
|
||||||
- Server-verified premium. Clients observe entitlements; they do not self-grant access.
|
- Server-verified premium. Clients observe entitlements; they do not self-grant access.
|
||||||
- No anonymous auth. Accounts use email/password or Google sign-in.
|
- No anonymous auth. Accounts use email/password or Google sign-in.
|
||||||
|
|
||||||
## Security and privacy
|
<h2 align="center">Security and privacy</h2>
|
||||||
|
|
||||||
- Android encrypts answer content, chat messages, media, capsules, and backed-up conversation history
|
- Android encrypts answer content, chat messages, media, capsules, and backed-up conversation history
|
||||||
on-device before sync.
|
on-device before sync.
|
||||||
|
|
@ -154,7 +162,7 @@ Core rules:
|
||||||
The canonical technical reference is
|
The canonical technical reference is
|
||||||
[docs/Engineering_Reference_Manual.md](docs/Engineering_Reference_Manual.md).
|
[docs/Engineering_Reference_Manual.md](docs/Engineering_Reference_Manual.md).
|
||||||
|
|
||||||
## Local development
|
<h2 align="center">Local development</h2>
|
||||||
|
|
||||||
<details open>
|
<details open>
|
||||||
<summary><strong>Prerequisites</strong></summary>
|
<summary><strong>Prerequisites</strong></summary>
|
||||||
|
|
@ -228,22 +236,21 @@ npm run serve
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
## QA and release
|
<h2 align="center">QA and release</h2>
|
||||||
|
|
||||||
The repo keeps repeatable QA checks close to the code:
|
The repo keeps repeatable QA checks close to the code:
|
||||||
|
|
||||||
| Check | Purpose |
|
| Check | Purpose |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| [qa/README.md](qa/README.md) | Ava + Ben emulator fixtures and cold-start smoke notes |
|
|
||||||
| `scripts/theme-scan.sh` | Finds hardcoded theme/color regressions |
|
| `scripts/theme-scan.sh` | Finds hardcoded theme/color regressions |
|
||||||
| `scripts/wiring-scan.sh` | Finds orphaned routes, dead actions, and silent wiring breaks |
|
| `scripts/wiring-scan.sh` | Finds orphaned routes, dead actions, and silent wiring breaks |
|
||||||
| [docs/qa/](docs/qa/) | Manual QA playbooks and private MVP checklist |
|
| [docs/qa/](docs/qa/) | Manual QA playbooks and private MVP checklist |
|
||||||
| [docs/release/](docs/release/) | Internal testing, store assets, and release prep |
|
| [docs/release/](docs/release/) | Internal testing, store assets, and release prep |
|
||||||
|
|
||||||
Current Ava + Ben emulator sessions are documented in [qa/README.md](qa/README.md). Do not clear those
|
Live emulator fixtures and related credentials are intentionally local-only. Keep them in gitignored
|
||||||
app sessions unless you are intentionally rebuilding the fixture.
|
files under `qa/`, never in the tracked README or public docs.
|
||||||
|
|
||||||
## Repository map
|
<h2 align="center">Repository map</h2>
|
||||||
|
|
||||||
```text
|
```text
|
||||||
app/ Android app
|
app/ Android app
|
||||||
|
|
@ -252,12 +259,12 @@ functions/ Firebase Cloud Functions
|
||||||
server/ Optional Express service
|
server/ Optional Express service
|
||||||
seed/ Question content and seed tooling
|
seed/ Question content and seed tooling
|
||||||
scripts/ Static QA scanners
|
scripts/ Static QA scanners
|
||||||
qa/ Re-runnable emulator QA notes and scripts
|
qa/ Local-only emulator fixture notes and smoke helpers
|
||||||
docs/ Architecture, release, screenshots, and QA docs
|
docs/ Architecture, release, screenshots, and QA docs
|
||||||
firestore.rules Firestore rules source of truth
|
firestore.rules Firestore rules source of truth
|
||||||
```
|
```
|
||||||
|
|
||||||
## Roadmap
|
<h2 align="center">Roadmap</h2>
|
||||||
|
|
||||||
In progress:
|
In progress:
|
||||||
|
|
||||||
|
|
@ -273,12 +280,11 @@ Out of scope for now:
|
||||||
- Wear OS / watchOS companions
|
- Wear OS / watchOS companions
|
||||||
- Live video or voice sessions
|
- Live video or voice sessions
|
||||||
|
|
||||||
## Project docs
|
<h2 align="center">Project docs</h2>
|
||||||
|
|
||||||
| Doc | Purpose |
|
| Doc | Purpose |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| [docs/Engineering_Reference_Manual.md](docs/Engineering_Reference_Manual.md) | Architecture, security model, data model, and known landmines |
|
| [docs/Engineering_Reference_Manual.md](docs/Engineering_Reference_Manual.md) | Architecture, security model, data model, and known landmines |
|
||||||
| [qa/README.md](qa/README.md) | Emulator fixtures and autonomous QA smokes |
|
|
||||||
| [docs/brand/](docs/brand/) | Visual identity, asset system, generated art |
|
| [docs/brand/](docs/brand/) | Visual identity, asset system, generated art |
|
||||||
| [docs/release/](docs/release/) | Release prep and store assets |
|
| [docs/release/](docs/release/) | Release prep and store assets |
|
||||||
| [docs/qa/](docs/qa/) | Manual QA checklist |
|
| [docs/qa/](docs/qa/) | Manual QA checklist |
|
||||||
|
|
@ -286,6 +292,6 @@ Out of scope for now:
|
||||||
| [HISTORY.md](HISTORY.md) | Changelog and release notes |
|
| [HISTORY.md](HISTORY.md) | Changelog and release notes |
|
||||||
| [PROJECT.md](PROJECT.md) | Scope, feature matrix, architectural decisions |
|
| [PROJECT.md](PROJECT.md) | Scope, feature matrix, architectural decisions |
|
||||||
|
|
||||||
## License
|
<h2 align="center">License</h2>
|
||||||
|
|
||||||
Private project. All rights reserved.
|
Private project. All rights reserved.
|
||||||
|
|
|
||||||
40
qa/README.md
40
qa/README.md
|
|
@ -1,40 +0,0 @@
|
||||||
# qa/ — re-runnable QA smokes (autonomous)
|
|
||||||
|
|
||||||
Durable, committed QA tooling so each round re-checks the fragile paths cheaply instead of by hand.
|
|
||||||
See `ClaudeQAPlan.md` (Pass E crash-triage, Pass F cold-start ownership, Definition of Done) for how these fit the run.
|
|
||||||
|
|
||||||
## entrypoint_smoke.sh — cold-start / entry-point launch integrity
|
|
||||||
The smoke that catches the **"app opens and immediately closes"** class (e.g. the splash-exit `iconView` NPE on a
|
|
||||||
notification cold-start). That class breaks **every** notification at once (shared cold-start path) and is **invisible to
|
|
||||||
`adb am start`** — only a REAL push tapped from the shade on a genuinely killed app reproduces it.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# both emulators; run after ANY change to MainActivity / splash / theme / manifest / nav / notifications, and every round
|
|
||||||
bash qa/entrypoint_smoke.sh emulator-5554 Y05AKO2IlTPMa0JQW1BiNIM0uzK2
|
|
||||||
bash qa/entrypoint_smoke.sh emulator-5556 imDjjOTTQvXGGjyUhUc5JSeHWkU2
|
|
||||||
```
|
|
||||||
Asserts each entry (launcher icon · each notification type tapped from a killed app · …) **opens AND stays** (process
|
|
||||||
alive, 0 FATAL, off the launcher). `PASS`/`FAIL`/`BLOCK`:
|
|
||||||
- **FAIL** = a real app crash (the bug). Pull the stack: `adb -s <serial> logcat -d | grep -E "FATAL EXCEPTION|getIconView"`.
|
|
||||||
- **BLOCK** = the push didn't reach the killed app (flaky emulator FCM / FcmRetry) — environmental, just rerun.
|
|
||||||
- Runtime ~3–7 min (FCM retries); run in the background and read the matrix. Exit 0 only if 0 FAIL.
|
|
||||||
|
|
||||||
## qa_push.js — send a real push (helper)
|
|
||||||
Sends a faithful `notification`+`data` FCM to a user's token so a killed app shows it and tapping cold-starts via the
|
|
||||||
real OS handover. **Use `am kill` (NOT `am force-stop`)** to kill — force-stopped apps are excluded from FCM.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
NODE_PATH=functions/node_modules node qa/qa_push.js <uid> partner_started_game game_type=this_or_that
|
|
||||||
NODE_PATH=functions/node_modules node qa/qa_push.js <uid> partner_finished_game game_type=wheel # auto-resolves a completed session
|
|
||||||
NODE_PATH=functions/node_modules node qa/qa_push.js <uid> chat_message conversation_id=main
|
|
||||||
```
|
|
||||||
|
|
||||||
## Test couple (current emulators)
|
|
||||||
- coupleId `Xal3Kw3gjSdn0niERYKJ`
|
|
||||||
- QA (emulator-5554) `Y05AKO2IlTPMa0JQW1BiNIM0uzK2` · Sam (emulator-5556) `imDjjOTTQvXGGjyUhUc5JSeHWkU2`
|
|
||||||
- Admin SA JSON: `~/.config/closer/closer-admin-sa.json` (kept OUTSIDE the repo; override path via `SA_JSON`). For tools using Application Default Credentials: `export GOOGLE_APPLICATION_CREDENTIALS=~/.config/closer/closer-admin-sa.json`.
|
|
||||||
|
|
||||||
## Fixture accounts & passwords
|
|
||||||
Live test-account emails, uids, AVD mapping, and passwords are kept OUT of this repo in the
|
|
||||||
gitignored `qa/fixtures.local.md` (dev-project throwaway creds). Ask the operator or read that
|
|
||||||
local file. Never commit credentials.
|
|
||||||
|
|
@ -12,8 +12,8 @@
|
||||||
# Emulator FCM-to-killed-app is flaky (FcmRetry); undeliverable cases are reported BLOCKED, not FAIL —
|
# Emulator FCM-to-killed-app is flaky (FcmRetry); undeliverable cases are reported BLOCKED, not FAIL —
|
||||||
# only an actual open-and-close / crash is a FAIL.
|
# only an actual open-and-close / crash is a FAIL.
|
||||||
#
|
#
|
||||||
# Usage: qa/entrypoint_smoke.sh <serial> [recipient_uid]
|
# Usage: qa/entrypoint_smoke.sh <serial> <recipient_uid>
|
||||||
# qa/entrypoint_smoke.sh emulator-5556 imDjjOTTQvXGGjyUhUc5JSeHWkU2
|
# qa/entrypoint_smoke.sh emulator-5556 "$QA_RECIPIENT_UID"
|
||||||
# Env overrides: PKG, SA_JSON, COUPLE_ID.
|
# Env overrides: PKG, SA_JSON, COUPLE_ID.
|
||||||
# Runtime: ~3–7 min (flaky emulator FCM adds retries). Run in the background and read the matrix.
|
# Runtime: ~3–7 min (flaky emulator FCM adds retries). Run in the background and read the matrix.
|
||||||
# Exit 0 only if zero FAIL (BLOCK = undeliverable push, environmental — rerun, not an app bug).
|
# Exit 0 only if zero FAIL (BLOCK = undeliverable push, environmental — rerun, not an app bug).
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@
|
||||||
// node qa/qa_push.js <uid> partner_finished_game game_type=wheel # auto-resolves a completed session
|
// node qa/qa_push.js <uid> partner_finished_game game_type=wheel # auto-resolves a completed session
|
||||||
// node qa/qa_push.js <uid> chat_message conversation_id=main
|
// node qa/qa_push.js <uid> chat_message conversation_id=main
|
||||||
// node qa/qa_push.js <uid> partner_answered
|
// node qa/qa_push.js <uid> partner_answered
|
||||||
// Env overrides: SA_JSON, COUPLE_ID.
|
// Env: SA_JSON, COUPLE_ID.
|
||||||
const admin = require('firebase-admin')
|
const admin = require('firebase-admin')
|
||||||
const path = require('path')
|
const path = require('path')
|
||||||
const os = require('os')
|
const os = require('os')
|
||||||
|
|
@ -19,7 +19,11 @@ const os = require('os')
|
||||||
// working tree). Override with SA_JSON if yours is elsewhere.
|
// working tree). Override with SA_JSON if yours is elsewhere.
|
||||||
const SA = process.env.SA_JSON ||
|
const SA = process.env.SA_JSON ||
|
||||||
path.join(os.homedir(), '.config', 'closer', 'closer-admin-sa.json')
|
path.join(os.homedir(), '.config', 'closer', 'closer-admin-sa.json')
|
||||||
const COUPLE = process.env.COUPLE_ID || 'Xal3Kw3gjSdn0niERYKJ'
|
const COUPLE = process.env.COUPLE_ID
|
||||||
|
if (!COUPLE) {
|
||||||
|
console.error('COUPLE_ID is required. Keep fixture IDs in a local, gitignored env/file.')
|
||||||
|
process.exit(2)
|
||||||
|
}
|
||||||
|
|
||||||
admin.initializeApp({ credential: admin.credential.cert(require(SA)) })
|
admin.initializeApp({ credential: admin.credential.cert(require(SA)) })
|
||||||
const db = admin.firestore()
|
const db = admin.firestore()
|
||||||
|
|
|
||||||
|
|
@ -1,59 +0,0 @@
|
||||||
// D1 at-rest ciphertext verifier (READ-ONLY). Prints only field -> enc✓ / NOT-ENC + length.
|
|
||||||
// NEVER prints decrypted/plaintext content (per the QA logging rule).
|
|
||||||
const admin = require('firebase-admin')
|
|
||||||
const path = require('path')
|
|
||||||
const SA = process.env.SA_JSON || path.join(__dirname, '..', 'closer-app-22014-firebase-adminsdk-fbsvc-ed20bf6003.json')
|
|
||||||
const COUPLE = process.env.COUPLE_ID || 'Xal3Kw3gjSdn0niERYKJ'
|
|
||||||
admin.initializeApp({ credential: admin.credential.cert(require(SA)) })
|
|
||||||
const db = admin.firestore()
|
|
||||||
|
|
||||||
const CIPHER = /^(enc:v1:|sealed:v1:)/
|
|
||||||
// fields that MUST be ciphertext if present
|
|
||||||
const PRIVATE_FIELDS = ['text','content','lastMessagePreview','title','description','duration','note','message','answer','body','preview']
|
|
||||||
|
|
||||||
function checkVal(v) {
|
|
||||||
if (typeof v !== 'string') return null
|
|
||||||
if (CIPHER.test(v)) return `enc✓(${v.length})`
|
|
||||||
// base64 Tink media or short metadata? flag anything that isn't enc/sealed
|
|
||||||
return `NOT-ENC?(len=${v.length})`
|
|
||||||
}
|
|
||||||
|
|
||||||
async function sampleColl(ref, label, limit = 3) {
|
|
||||||
try {
|
|
||||||
const snap = await ref.limit(limit).get()
|
|
||||||
if (snap.empty) { console.log(` [${label}] empty`); return }
|
|
||||||
let i = 0
|
|
||||||
snap.forEach(doc => {
|
|
||||||
if (i++ >= limit) return
|
|
||||||
const d = doc.data()
|
|
||||||
const hits = []
|
|
||||||
for (const f of PRIVATE_FIELDS) {
|
|
||||||
if (d[f] !== undefined) hits.push(`${f}=${checkVal(d[f])}`)
|
|
||||||
}
|
|
||||||
console.log(` [${label}] ${doc.id}: ${hits.length ? hits.join(' ') : '(no private fields)'}`)
|
|
||||||
})
|
|
||||||
} catch (e) { console.log(` [${label}] ERR ${e.code || e.message}`) }
|
|
||||||
}
|
|
||||||
|
|
||||||
(async () => {
|
|
||||||
console.log('=== D1 at-rest check, couple', COUPLE, '===')
|
|
||||||
// 1) couple doc subcollections
|
|
||||||
const coupleRef = db.collection('couples').doc(COUPLE)
|
|
||||||
const subs = await coupleRef.listCollections().catch(() => [])
|
|
||||||
console.log('couple subcollections:', subs.map(s => s.id).join(', ') || '(none)')
|
|
||||||
for (const s of subs) await sampleColl(s, `couples/${COUPLE}/${s.id}`)
|
|
||||||
// 2) common top-level collections keyed by coupleId
|
|
||||||
for (const c of ['messages','conversations','answers','bucket_list','date_plans','date_swipes','memory_capsules','capsules','date_plan_preferences','game_sessions']) {
|
|
||||||
const ref = db.collection(c).where('coupleId','==',COUPLE)
|
|
||||||
await sampleColl(ref, c)
|
|
||||||
}
|
|
||||||
// 3) conversations -> messages (nested)
|
|
||||||
try {
|
|
||||||
const convs = await db.collection('conversations').where('coupleId','==',COUPLE).limit(2).get()
|
|
||||||
for (const cv of convs.docs) {
|
|
||||||
await sampleColl(cv.ref.collection('messages'), `conversations/${cv.id}/messages`)
|
|
||||||
}
|
|
||||||
} catch (e) { console.log(' conv->messages ERR', e.code || e.message) }
|
|
||||||
console.log('=== done ===')
|
|
||||||
process.exit(0)
|
|
||||||
})().catch(e => { console.error('FATAL', e); process.exit(1) })
|
|
||||||
|
|
@ -1,42 +0,0 @@
|
||||||
// D1 deep probe: dump FIELD NAMES + per-string enc-status/length (NEVER values).
|
|
||||||
const admin = require('firebase-admin')
|
|
||||||
const path = require('path')
|
|
||||||
const SA = process.env.SA_JSON || path.join(__dirname, '..', 'closer-app-22014-firebase-adminsdk-fbsvc-ed20bf6003.json')
|
|
||||||
const COUPLE = process.env.COUPLE_ID || 'Xal3Kw3gjSdn0niERYKJ'
|
|
||||||
admin.initializeApp({ credential: admin.credential.cert(require(SA)) })
|
|
||||||
const db = admin.firestore()
|
|
||||||
const CIPHER = /^(enc:v1:|sealed:v1:)/
|
|
||||||
const MEDIA = /^(01|AAAA|CiQ|Ei)/ // tink/base64-ish prefixes seen for media
|
|
||||||
|
|
||||||
function fieldReport(d) {
|
|
||||||
return Object.entries(d).map(([k, v]) => {
|
|
||||||
if (typeof v === 'string') {
|
|
||||||
const tag = CIPHER.test(v) ? 'enc✓' : (v.length > 40 ? `RAWSTR?(${v.length})` : `meta(${v.length})`)
|
|
||||||
return `${k}:${tag}`
|
|
||||||
}
|
|
||||||
if (Array.isArray(v)) return `${k}:[${v.length}]${v.every(x => typeof x === 'string' && CIPHER.test(x)) && v.length ? 'enc✓' : ''}`
|
|
||||||
if (v && typeof v === 'object') return `${k}:{${Object.keys(v).join(',')}}`
|
|
||||||
return `${k}:${typeof v}`
|
|
||||||
}).join(' ')
|
|
||||||
}
|
|
||||||
|
|
||||||
async function dump(ref, label, n = 2) {
|
|
||||||
try {
|
|
||||||
const s = await ref.limit(n).get()
|
|
||||||
if (s.empty) return console.log(`[${label}] empty`)
|
|
||||||
s.docs.slice(0, n).forEach(doc => console.log(`[${label}] ${doc.id}\n ${fieldReport(doc.data())}`))
|
|
||||||
} catch (e) { console.log(`[${label}] ERR ${e.code || e.message}`) }
|
|
||||||
}
|
|
||||||
|
|
||||||
(async () => {
|
|
||||||
const c = db.collection('couples').doc(COUPLE)
|
|
||||||
// messages nested under each conversation
|
|
||||||
const convs = await c.collection('conversations').limit(3).get()
|
|
||||||
for (const cv of convs.docs) {
|
|
||||||
await dump(cv.ref.collection('messages'), `conv:${cv.id}/messages`, 2)
|
|
||||||
}
|
|
||||||
for (const g of ['this_or_that','desire_sync','how_well','wheel','date_swipes','sessions']) {
|
|
||||||
await dump(c.collection(g), g, 1)
|
|
||||||
}
|
|
||||||
process.exit(0)
|
|
||||||
})().catch(e => { console.error('FATAL', e); process.exit(1) })
|
|
||||||
|
|
@ -1,33 +0,0 @@
|
||||||
// D1 nested-answer probe: for game answers map, report each uid's answer value enc-status (NEVER values).
|
|
||||||
const admin = require('firebase-admin')
|
|
||||||
const path = require('path')
|
|
||||||
const SA = process.env.SA_JSON || path.join(__dirname, '..', 'closer-app-22014-firebase-adminsdk-fbsvc-ed20bf6003.json')
|
|
||||||
const COUPLE = process.env.COUPLE_ID || 'Xal3Kw3gjSdn0niERYKJ'
|
|
||||||
admin.initializeApp({ credential: admin.credential.cert(require(SA)) })
|
|
||||||
const db = admin.firestore()
|
|
||||||
const CIPHER = /^(enc:v1:|sealed:v1:)/
|
|
||||||
|
|
||||||
function classify(v) {
|
|
||||||
if (typeof v === 'string') return CIPHER.test(v) ? `enc✓(${v.length})` : `RAW?(${v.length})`
|
|
||||||
if (Array.isArray(v)) return `[${v.length}: ${v.map(classify).join(',')}]`
|
|
||||||
if (v && typeof v === 'object') return '{' + Object.entries(v).map(([k, x]) => `${k}=${classify(x)}`).join(' ') + '}'
|
|
||||||
return typeof v
|
|
||||||
}
|
|
||||||
|
|
||||||
async function probe(coll, mapField) {
|
|
||||||
const c = db.collection('couples').doc(COUPLE)
|
|
||||||
const s = await c.collection(coll).limit(1).get()
|
|
||||||
if (s.empty) return console.log(`[${coll}] empty`)
|
|
||||||
const d = s.docs[0].data()
|
|
||||||
const m = d[mapField]
|
|
||||||
if (!m) return console.log(`[${coll}] no ${mapField}`)
|
|
||||||
for (const [uid, val] of Object.entries(m)) {
|
|
||||||
console.log(`[${coll}] ${mapField}.${uid.slice(0, 6)}…: ${classify(val)}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
(async () => {
|
|
||||||
for (const g of ['this_or_that', 'desire_sync', 'how_well', 'wheel']) await probe(g, 'answers')
|
|
||||||
await probe('date_swipes', 'actions')
|
|
||||||
process.exit(0)
|
|
||||||
})().catch(e => { console.error('FATAL', e); process.exit(1) })
|
|
||||||
|
|
@ -1,53 +0,0 @@
|
||||||
// D3 negative access (LIVE): mint a NON-MEMBER token → raw Firestore REST → expect 403/PERMISSION_DENIED.
|
|
||||||
const admin = require('firebase-admin')
|
|
||||||
const path = require('path')
|
|
||||||
const SA = process.env.SA_JSON || path.join(__dirname, '..', 'closer-app-22014-firebase-adminsdk-fbsvc-ed20bf6003.json')
|
|
||||||
const PROJECT = 'closer-app-22014'
|
|
||||||
const APIKEY = 'AIzaSyDAD7FnEYzhMsil41SzJ1XMjUNnJWmjie8'
|
|
||||||
const COUPLE = process.env.COUPLE_ID || 'Xal3Kw3gjSdn0niERYKJ'
|
|
||||||
const FAKE_UID = 'qa-nonmember-' + Date.now()
|
|
||||||
admin.initializeApp({ credential: admin.credential.cert(require(SA)) })
|
|
||||||
|
|
||||||
const docBase = `https://firestore.googleapis.com/v1/projects/${PROJECT}/databases/(default)/documents`
|
|
||||||
|
|
||||||
async function getIdToken() {
|
|
||||||
const custom = await admin.auth().createCustomToken(FAKE_UID)
|
|
||||||
const r = await fetch(`https://identitytoolkit.googleapis.com/v1/accounts:signInWithCustomToken?key=${APIKEY}`, {
|
|
||||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ token: custom, returnSecureToken: true })
|
|
||||||
})
|
|
||||||
const j = await r.json()
|
|
||||||
if (!j.idToken) throw new Error('no idToken: ' + JSON.stringify(j))
|
|
||||||
return j.idToken
|
|
||||||
}
|
|
||||||
|
|
||||||
async function tryGet(token, p, label) {
|
|
||||||
const r = await fetch(`${docBase}/${p}`, { headers: { Authorization: `Bearer ${token}` } })
|
|
||||||
const ok = r.status === 200
|
|
||||||
const verdict = ok ? '❌❌ LEAK (200)' : `✅ DENIED (${r.status})`
|
|
||||||
console.log(` ${label}: ${verdict} [${p}]`)
|
|
||||||
return ok
|
|
||||||
}
|
|
||||||
async function trySelfGrant(token, uid) {
|
|
||||||
const r = await fetch(`${docBase}/users/${uid}/entitlements/premium?updateMask.fieldPaths=active`, {
|
|
||||||
method: 'PATCH', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ fields: { active: { booleanValue: true } } })
|
|
||||||
})
|
|
||||||
console.log(` self-grant premium: ${r.status === 200 ? '❌❌ ALLOWED (200)' : `✅ DENIED (${r.status})`}`)
|
|
||||||
return r.status === 200
|
|
||||||
}
|
|
||||||
|
|
||||||
(async () => {
|
|
||||||
console.log('=== D3 non-member negative access (uid', FAKE_UID, ') ===')
|
|
||||||
const tok = await getIdToken()
|
|
||||||
let leaks = 0
|
|
||||||
if (await tryGet(tok, `couples/${COUPLE}`, 'read couple doc')) leaks++
|
|
||||||
if (await tryGet(tok, `couples/${COUPLE}/conversations/main/messages`, 'list messages')) leaks++
|
|
||||||
if (await tryGet(tok, `couples/${COUPLE}/capsules`, 'list capsules')) leaks++
|
|
||||||
if (await tryGet(tok, `couples/${COUPLE}/desire_sync`, 'list desire_sync')) leaks++
|
|
||||||
if (await trySelfGrant(tok, FAKE_UID)) leaks++
|
|
||||||
// cleanup the throwaway auth user
|
|
||||||
await admin.auth().deleteUser(FAKE_UID).catch(() => {})
|
|
||||||
console.log(leaks === 0 ? '=== D3 PASS: all denied ===' : `=== D3 FAIL: ${leaks} LEAK(S) — P0 ===`)
|
|
||||||
process.exit(leaks === 0 ? 0 : 1)
|
|
||||||
})().catch(e => { console.error('FATAL', e.message); process.exit(2) })
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
L_R18_pass_1782681418
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
// Pass L / D1: read latest messages in couples/{COUPLE}/conversations/main/messages.
|
|
||||||
// Print ONLY metadata + enc-status (never decrypted/plaintext content). Also assert a plaintext
|
|
||||||
// marker does NOT appear anywhere (at-rest leak check).
|
|
||||||
const admin = require('firebase-admin')
|
|
||||||
const path = require('path')
|
|
||||||
const fs = require('fs')
|
|
||||||
const SA = path.join(__dirname, '..', 'closer-app-22014-firebase-adminsdk-fbsvc-ed20bf6003.json')
|
|
||||||
const COUPLE = process.env.COUPLE_ID || 'Xal3Kw3gjSdn0niERYKJ'
|
|
||||||
const MARKER = (fs.existsSync(path.join(__dirname,'last_marker.txt')) ? fs.readFileSync(path.join(__dirname,'last_marker.txt'),'utf8').trim() : '')
|
|
||||||
admin.initializeApp({ credential: admin.credential.cert(require(SA)) })
|
|
||||||
const db = admin.firestore()
|
|
||||||
const CIPHER = /^(enc:v1:|sealed:v1:)/
|
|
||||||
const enc = v => typeof v==='string' ? (CIPHER.test(v)?`enc✓(${v.length})`:`RAW?(${v.length})`) : typeof v
|
|
||||||
;(async () => {
|
|
||||||
const col = db.collection('couples').doc(COUPLE).collection('conversations').doc('main').collection('messages')
|
|
||||||
const snap = await col.orderBy('createdAt','desc').limit(6).get()
|
|
||||||
console.log(`marker=${MARKER||'(none)'} latest ${snap.size} messages (newest first):`)
|
|
||||||
let leak = false
|
|
||||||
snap.forEach(d => {
|
|
||||||
const m = d.data()
|
|
||||||
const ts = m.createdAt && m.createdAt.toDate ? m.createdAt.toDate().toISOString() : m.createdAt
|
|
||||||
console.log(` ${ts} sender=${(m.senderId||'').slice(0,6)}… text=${enc(m.text)}`)
|
|
||||||
// leak check across the whole doc JSON
|
|
||||||
if (MARKER) { const blob = JSON.stringify(m); if (blob.includes(MARKER)) { leak = true; console.log(' ❌❌ PLAINTEXT MARKER LEAK in this doc') } }
|
|
||||||
})
|
|
||||||
if (MARKER) console.log(leak ? '=== LEAK: marker found in plaintext ===' : '=== OK: marker NOT present in plaintext (encrypted or not yet sent) ===')
|
|
||||||
process.exit(0)
|
|
||||||
})().catch(e=>{console.error('FATAL',e.message);process.exit(1)})
|
|
||||||
|
|
@ -1,26 +0,0 @@
|
||||||
const admin = require('firebase-admin')
|
|
||||||
const path = require('path')
|
|
||||||
const SA = path.join(__dirname, '..', 'closer-app-22014-firebase-adminsdk-fbsvc-ed20bf6003.json')
|
|
||||||
const COUPLE = process.env.COUPLE_ID || 'Xal3Kw3gjSdn0niERYKJ'
|
|
||||||
admin.initializeApp({ credential: admin.credential.cert(require(SA)) })
|
|
||||||
const db = admin.firestore()
|
|
||||||
function shallow(v){ if(typeof v==='string') return v.length>40?`str(${v.length})`:v; if(Array.isArray(v)) return `[${v.length}]`; if(v&&typeof v==='object') return '{'+Object.keys(v).join(',')+'}'; return v }
|
|
||||||
;(async () => {
|
|
||||||
const c = await db.collection('couples').doc(COUPLE).get()
|
|
||||||
const cd = c.data() || {}
|
|
||||||
console.log('=== couple top-level keys ===')
|
|
||||||
for (const k of Object.keys(cd)) console.log(` ${k} = ${shallow(cd[k])}`)
|
|
||||||
// find uid-like values
|
|
||||||
const uids = new Set()
|
|
||||||
for (const [k,v] of Object.entries(cd)) {
|
|
||||||
if (typeof v==='string' && /^[A-Za-z0-9]{20,}$/.test(v)) uids.add(v)
|
|
||||||
if (Array.isArray(v)) v.forEach(x=>{ if(typeof x==='string'&&/^[A-Za-z0-9]{20,}$/.test(x)) uids.add(x) })
|
|
||||||
if (v&&typeof v==='object'&&!Array.isArray(v)) Object.keys(v).forEach(x=>{ if(/^[A-Za-z0-9]{20,}$/.test(x)) uids.add(x) })
|
|
||||||
}
|
|
||||||
console.log('=== candidate member uids ===', [...uids].map(u=>u.slice(0,10)+'…'))
|
|
||||||
for (const uid of uids) {
|
|
||||||
const ent = await db.collection('users').doc(uid).collection('entitlements').doc('premium').get()
|
|
||||||
console.log(` ${uid.slice(0,10)}… entitlements/premium:`, ent.exists?JSON.stringify(ent.data()):'MISSING(free)')
|
|
||||||
}
|
|
||||||
process.exit(0)
|
|
||||||
})().catch(e=>{console.error('FATAL',e.message);process.exit(1)})
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
const admin = require('firebase-admin'); const path=require('path')
|
|
||||||
admin.initializeApp({credential:admin.credential.cert(require(path.join(__dirname,'..','closer-app-22014-firebase-adminsdk-fbsvc-ed20bf6003.json')))})
|
|
||||||
const db=admin.firestore()
|
|
||||||
const QA='Y05AKO2IlTPMa0JQW1BiNIM0uzK2', SAM='imDjjOTTQv'
|
|
||||||
;(async()=>{
|
|
||||||
for(const uid of [QA]){
|
|
||||||
const d=await db.collection('users').doc(uid).get(); const x=d.data()||{}
|
|
||||||
const keys=Object.keys(x).filter(k=>/quiet|timezone|tz|notif/i.test(k))
|
|
||||||
console.log(uid.slice(0,8)+'… quiet/tz keys:', keys.length?JSON.stringify(Object.fromEntries(keys.map(k=>[k,x[k]]))):'(none present)')
|
|
||||||
}
|
|
||||||
process.exit(0)
|
|
||||||
})().catch(e=>{console.error(e.message);process.exit(1)})
|
|
||||||
Loading…
Reference in New Issue