Compare commits

..

3 Commits

Author SHA1 Message Date
null 4103c43393 docs(manual): drop dead references to files removed by the 21392ec2 dead-file sweep
The 2026-07-09 Tier 1 dead-file sweep (commit 21392ec2) removed
8 files the manual still cited. Each was verified unreferenced by
symbol-level git-grep + a clean :app:compileDebugKotlin + unit-test
compile. This patch aligns the manual with the post-sweep source.

Five stale references fixed:

1. Android Repository layout (line 102): dropped NotificationHelper +
   NotificationPermissionHelper from the core/notifications/ file
   list. Both removed (dead duplicates of NotificationChannelSetup).

2. Android Repository layout (line 108): dropped the data/questions/
   package from the layout. The directory is gone (QuestionJsonParser
   was the only file; the path is now empty + dead).

3. Android Repository layout (line 118): dropped PartnerNotificationScheduler
   from the top-level notifications/ package list. Removed as
   superseded by PartnerNotificationManager.

4. 'Note on the manual's older description' paragraph (line 147):
   rewritten to record the swept files (NotificationHelper,
   NotificationPermissionHelper, PartnerNotificationScheduler, the
   domain/model/ Entitlment.kt / InviteStatus.kt / QuestionSessionStatus.kt
   trio, and the data/questions/ package) with one-line reasons
   pulled from the 21392ec2 commit body, and to point QuestionDao at
   data/local/QuestionDao.kt as the now-canonical question-content
   source.

5. Logging section (line 1220): replaced the dead QuestionJsonParser
   BuildConfig.DEBUG example pointer with the live
   analytics/CompositeRetentionAnalytics.kt (one of the few current
   Android files that still guards android.util.Log with
   BuildConfig.DEBUG). The historical QJP pointer is preserved as a
   one-line breadcrumb so the next reader knows why it changed.

6. R23-DQ-001 landmine (line 1355): the parenthetical said
   'QuestionDetailViewModel (pack questions) is local-only and not
   affected' - QuestionDetailViewModel is gone, pack questions are
   now read directly by QuestionPackLibraryViewModel. Updated the
   parenthetical + added a one-line breadcrumb pointing at the
   21392ec2 retirement.

Anchor check: 30/30 internal anchors still resolve (Python GFM slug
checker).

Deliberately NOT in this commit:
- No mention of the CloserCopy catalog added - that's a documentation
  addition, not a stale-reference fix, and belongs in its own batch
  (the brand/ package is currently undocumented in the manual; a
  future pass should add CloserBrandCopy + CloserCopy to the
  Android Repository layout).
- No mention of the strings.xml purge (59e03369) - the manual
  doesn't reference any specific string resources, so the purge is
  invisible to the manual.
2026-07-09 00:29:26 -05:00
null 51efff3f78 refactor(copy): start CloserCopy catalog — migrate paywall + subscription voice copy
Establishes a typed Kotlin copy catalog (ui/brand/CloserCopy.kt), sibling to the existing
CloserBrandCopy (privacy rotator). Rationale over strings.xml: the app is English-only and
pre-launch, so a typed catalog gives one-place brand-voice review + compile-time safety
without Compose stringResource() friction or orphaned-string drift. strings.xml's real value
is the localization pipeline, which we migrate to in one pass when i18n is on the roadmap
(gate recorded in Future.md).

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

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 00:20:32 -05:00
6 changed files with 81 additions and 105 deletions

View File

@ -224,6 +224,7 @@ no `auth.user().onDelete`). Deploy-day learnings + deferred items:
- **Skeleton/loading states over bare spinners (P8).** `LoadingState` exists but many screens still use a bare `CircularProgressIndicator`. Add skeletons for question lists, game histories, home modules, paywall offerings, sync/reveal waits. Acceptance: no primary route shows an isolated spinner on an otherwise blank screen.
- **Paywall / store value framing (P12).** Paywall has benefits/restore/legal/RevenueCat; needs stronger value framing, real offering/trial clarity, screenshots/previews, and test coverage before release. (Overlaps Pass K/O.)
- **Content metadata & personalization (P13).** The bank is clean; the next leap is *routing*, not more questions — tag mood/depth/relationship-stage/conflict-safe/intimacy-level/time-needed and extend the selection APIs so prompts adapt to skipped topics, relationship length, and recent answers.
- **Copy convention + i18n gate.** The app renders copy from hardcoded Compose literals; a half-built `strings.xml` catalog (55 unwired entries) was purged (2026-07-09), and voice copy is being centralized into typed Kotlin catalogs (`ui/brand/CloserBrandCopy.kt` = privacy rotator, `ui/brand/CloserCopy.kt` = product/paywall voice) — one reviewable home, compile-safe, no `stringResource()` friction. Convention: **voice copy → catalog; generic chrome ("Continue"/"Restore") stays inline.** Deliberately NOT localized. **Hard pre-localization gate:** the day translation is on the roadmap, migrate the Kotlin copy catalogs → `res/values/strings.xml` in one pass (retrofitting i18n across hardcoded Compose after launch is far more expensive). First slice done: paywall + subscription; remaining voice copy (onboarding, invite share, age-gate) migrates incrementally as those screens are touched. (Also surfaced: the paywall vs. subscription **benefit lists diverge** — noted in `CloserCopy.kt` for a copy decision.)
## Release / pre-ship (consolidated + re-verified 2026-06-28)
- **Real release config before any store submission.** Confirmed still open: `app/build.gradle.kts` `versionCode = 1` / `versionName = "0.1.0"` and `core/navigation/ExternalLinks.kt` legal URLs are placeholder TODOs (`https://closer.app/privacy|terms|subscription-terms`). _Already done:_ a release-blocking Gradle check now fails the build if `RC_API_KEY` is unset/placeholder (`build.gradle.kts:106110`), so the old "add a gradle guard" sub-item is closed. Remaining: set real version, real legal/support URLs, real RC key + verify offerings/purchase/restore on internal testing. (Tracked by **Pass O** release-readiness.)

View File

@ -0,0 +1,58 @@
package app.closer.ui.brand
/**
* Product / brand-voice copy, centralized so the app's tone lives in one reviewable place.
* Sibling to [CloserBrandCopy] (which holds the privacy-rotator lines specifically).
*
* Scope VOICE copy only: value propositions, benefit lists, headlines, the lines whose exact
* wording is a product decision. Generic UI chrome ("Continue", "Restore", "Manage subscription",
* error-retry labels) intentionally stays inline at the call site it isn't brand voice and
* gains nothing from centralization.
*
* NOT localized these are Kotlin consts, not string resources. Deliberate for an English-only
* pre-launch app: a typed catalog gives one-place review + compile-time safety without the
* Compose `stringResource()` friction, and no orphaned-string drift. When localization is on the
* roadmap, migrate this catalog to res/values/strings.xml in one pass (tracked in Future.md).
*/
object CloserCopy {
/** Reused verbatim across the paywall and the subscription screen. */
const val whatsIncluded = "What's included"
object Paywall {
const val headline = "Go deeper together"
const val subhead = "Unlock everything Closer has built for couples."
const val choosePlanTitle = "Choose your plan"
const val coupleShared = "One subscription covers you both — treat your partner."
const val thankYouTitle = "You're all set"
const val thankYouBody = "Thank you for supporting Closer."
val benefits = listOf(
"Unlimited questions every day",
"Every premium question pack",
"Date planning and bucket list",
"Full answer history and growth",
"Custom questions and private notes",
"Exportable memories",
)
}
object Subscription {
const val upgradeHeadline = "Unlock Premium"
const val premiumTitle = "You're Premium"
const val coupleShared = "One subscription for both partners — no double billing."
// NOTE (copy drift surfaced by centralizing): this benefit list diverges from
// [Paywall.benefits] — it swaps "Custom questions and private notes" + "Exportable
// memories" for "Connection Challenges and Desire Sync" + "Memory Lane time capsules",
// and reorders. Left verbatim on purpose; unify the two lists if that's a copy decision.
val benefits = listOf(
"Unlimited questions every day",
"Every premium question pack",
"Full answer history and growth",
"Date planning and bucket list",
"Connection Challenges and Desire Sync",
"Memory Lane time capsules",
)
}
}

View File

@ -69,17 +69,9 @@ import app.closer.ui.components.ErrorState
import app.closer.ui.components.LoadingState
import app.closer.ui.theme.CloserPalette
import com.revenuecat.purchases.Package
import app.closer.ui.brand.CloserCopy
import app.closer.ui.components.CloserGlyphs
private val BENEFITS = listOf(
"Unlimited questions every day",
"Every premium question pack",
"Date planning and bucket list",
"Full answer history and growth",
"Custom questions and private notes",
"Exportable memories"
)
@Composable
fun PaywallScreen(
onNavigate: (String) -> Unit = {},
@ -207,13 +199,13 @@ private fun HeaderSection(
)
Column(modifier = Modifier.weight(1f)) {
Text(
text = "Go deeper together",
text = CloserCopy.Paywall.headline,
style = MaterialTheme.typography.headlineLarge.copy(fontWeight = FontWeight.SemiBold),
color = MaterialTheme.colorScheme.onSurface
)
Spacer(modifier = Modifier.height(6.dp))
Text(
text = "Unlock everything Closer has built for couples.",
text = CloserCopy.Paywall.subhead,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@ -243,7 +235,7 @@ private fun BenefitsCard(modifier: Modifier = Modifier) {
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(
text = "What's included",
text = CloserCopy.whatsIncluded,
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold),
color = MaterialTheme.colorScheme.onSurface
)
@ -252,7 +244,7 @@ private fun BenefitsCard(modifier: Modifier = Modifier) {
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
BENEFITS.forEach { benefit ->
CloserCopy.Paywall.benefits.forEach { benefit ->
BenefitPill(benefit)
}
}
@ -305,14 +297,14 @@ private fun PlanOptions(
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(
text = "Choose your plan",
text = CloserCopy.Paywall.choosePlanTitle,
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold),
color = MaterialTheme.colorScheme.onSurface
)
// Couple-shared premium: one purchase unlocks BOTH partners (CouplePremiumChecker).
// Framed as a gift — the real, honest hook, no billing changes needed.
Text(
text = "One subscription covers you both — treat your partner.",
text = CloserCopy.Paywall.coupleShared,
style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.Medium),
color = Color(0xFF56306F)
)
@ -510,12 +502,12 @@ private fun ThankYouOverlay(onDismiss: () -> Unit) {
modifier = Modifier.size(48.dp)
)
Text(
text = "You're all set",
text = CloserCopy.Paywall.thankYouTitle,
style = MaterialTheme.typography.headlineSmall.copy(fontWeight = FontWeight.SemiBold),
color = MaterialTheme.colorScheme.onSurface
)
Text(
text = "Thank you for supporting Closer.",
text = CloserCopy.Paywall.thankYouBody,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center

View File

@ -67,17 +67,9 @@ import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import app.closer.ui.brand.CloserCopy
import app.closer.ui.components.CloserGlyphs
private val BENEFITS = listOf(
"Unlimited questions every day",
"Every premium question pack",
"Full answer history and growth",
"Date planning and bucket list",
"Connection Challenges and Desire Sync",
"Memory Lane time capsules",
)
data class SubscriptionUiState(
val isLoading: Boolean = true,
val isPremium: Boolean = false,
@ -213,7 +205,7 @@ private fun PremiumContent(
) {
SubscriptionHeroImage()
Text(
text = "You're Premium",
text = CloserCopy.Subscription.premiumTitle,
style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.SemiBold),
color = MaterialTheme.colorScheme.onSurface
)
@ -238,11 +230,11 @@ private fun PremiumContent(
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(
text = "What's included",
text = CloserCopy.whatsIncluded,
style = MaterialTheme.typography.labelLarge.copy(fontWeight = FontWeight.SemiBold),
color = MaterialTheme.colorScheme.onSurfaceVariant
)
BENEFITS.forEach { benefit ->
CloserCopy.Subscription.benefits.forEach { benefit ->
Row(
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.CenterVertically
@ -319,14 +311,14 @@ private fun FreeContent(
SubscriptionHeroImage()
Spacer(Modifier.height(10.dp))
Text(
text = "Unlock Premium",
text = CloserCopy.Subscription.upgradeHeadline,
style = MaterialTheme.typography.headlineSmall.copy(fontWeight = FontWeight.SemiBold),
color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center
)
Spacer(Modifier.height(4.dp))
Text(
text = "One subscription for both partners — no double billing.",
text = CloserCopy.Subscription.coupleShared,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
@ -343,7 +335,7 @@ private fun FreeContent(
modifier = Modifier.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
BENEFITS.forEach { benefit ->
CloserCopy.Subscription.benefits.forEach { benefit ->
Row(
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.CenterVertically

View File

@ -6,27 +6,8 @@
<!-- ── Common actions ─────────────────────────────────────────── -->
<string name="action_back">Back</string>
<string name="action_cancel">Cancel</string>
<string name="action_continue">Continue</string>
<string name="action_copy">Copy</string>
<string name="action_share">Share</string>
<string name="action_save">Save</string>
<string name="action_confirm">Confirm</string>
<string name="action_ok">OK</string>
<string name="action_done">Done</string>
<string name="action_retry">Retry</string>
<string name="action_sign_out">Sign out</string>
<string name="action_delete_account">Delete account</string>
<string name="action_invite_partner">Invite partner</string>
<string name="action_back_home">Back home</string>
<string name="action_create_plan">Create Plan</string>
<!-- ── Settings nav labels ────────────────────────────────────── -->
<string name="settings_account">Account</string>
<string name="settings_appearance">Appearance</string>
<string name="settings_notifications">Notifications</string>
<string name="settings_privacy">Privacy &amp; Terms</string>
<string name="settings_subscription">Subscription</string>
<string name="settings_answer_history">Answer History</string>
<!-- ── Appearance screen ──────────────────────────────────────── -->
<string name="appearance_title">Appearance</string>
@ -88,7 +69,6 @@
<string name="privacy_capsule_sealed">What\'s inside a capsule stays sealed until the unlock date both partners agreed on.</string>
<string name="privacy_desire_mutual">Desire Sync: shared yes answers only</string>
<string name="privacy_desire_mutual_body">Questions where only one of you said yes are never shown to either partner. Only mutual overlap is revealed.</string>
<string name="privacy_progress">Your Progress</string>
<string name="privacy_streak">Streak and shared wins</string>
<string name="privacy_streak_body">Your couple\'s streak count and challenge completions are shared between both of you.</string>
<string name="privacy_no_export">No data export</string>
@ -97,57 +77,10 @@
<string name="privacy_usage_data_section">Anonymous usage data</string>
<string name="privacy_usage_data_title">Share anonymous usage data</string>
<string name="privacy_usage_data_body">Helps us see which features couples use — never what you write. Your answers, messages, and prompts are end-to-end encrypted and can\'t be in this data even if we wanted them.</string>
<string name="privacy_legal_section">Legal</string>
<string name="privacy_legal_documents">Legal documents</string>
<string name="privacy_policy">Privacy Policy</string>
<string name="privacy_terms">Terms of Service</string>
<string name="privacy_subscription_terms">Subscription Terms</string>
<string name="privacy_support">Support</string>
<!-- ── Pairing — Create invite ────────────────────────────────── -->
<string name="pairing_create_title">Invite your person</string>
<string name="pairing_create_subtitle">Share this code with your partner so they can connect with you.</string>
<string name="pairing_create_expiry">Code expires in 24 hours</string>
<string name="pairing_create_share_title">Share invite code</string>
<string name="pairing_create_already_has_code">Partner already has a code? Accept instead</string>
<string name="pairing_create_no_code">No invite code yet</string>
<string name="pairing_create_no_code_body">Tap back and try creating an invite again.</string>
<string name="pairing_code_copied">Code copied!</string>
<!-- ── Pairing — Accept invite ────────────────────────────────── -->
<string name="pairing_accept_title">Enter the code</string>
<string name="pairing_accept_subtitle">Ask your partner to share their 6-character invite code.</string>
<string name="pairing_accept_create_instead">Need to create an invite instead?</string>
<!-- ── Pairing — Confirm invite ───────────────────────────────── -->
<string name="pairing_confirm_body">Once you confirm, you\'ll be connected and can start exploring questions together.</string>
<string name="pairing_confirm_action">Pair up</string>
<string name="pairing_confirm_wrong_code">That\'s not right — enter a different code</string>
<string name="pairing_recovery_phrase_title">Recovery phrase</string>
<string name="pairing_recovery_phrase_body">Write this down and share it with your partner. You\'ll both need it to access your answers on a new phone.</string>
<!-- ── Home screen ─────────────────────────────────────────────── -->
<string name="home_for_tonight">For tonight</string>
<string name="home_streak_start">Start a new streak today</string>
<string name="home_streak_one_day">1 day streak</string>
<string name="home_streak_n_days">%d day streak</string>
<string name="home_moment_cue_title">This space is yours</string>
<string name="home_moment_cue_body">Birthdays, anniversaries, and planned moments will sit here as gentle cues once they are saved.</string>
<string name="home_all_packs">All packs</string>
<!-- ── Partner home ───────────────────────────────────────────── -->
<string name="partner_home_today">Today</string>
<string name="partner_home_answered">answered</string>
<string name="partner_home_not_answered">hasn\'t answered yet</string>
<string name="partner_home_complete">Today\'s question is complete</string>
<string name="partner_home_waiting">Waiting for today\'s question</string>
<string name="partner_home_nudge">Send a gentle nudge</string>
<string name="partner_home_nudge_sending">Sending…</string>
<string name="partner_home_view_question">View today\'s question</string>
<string name="partner_home_streak_zero">Start a streak together</string>
<string name="partner_home_streak_one">1 day streak</string>
<string name="partner_home_streak_n">%d day streak</string>
<string name="partner_home_reminder_sent">Reminder sent to %s.</string>
<string name="partner_home_reminder_error">Could not send reminder.</string>
</resources>

View File

@ -99,13 +99,13 @@ app/src/main/java/app/closer/
│ ├── firebase/ # FirebaseInitializer (manual App Check / init path)
│ ├── media/ # MediaCompressor (image/video compression before encrypted upload)
│ ├── navigation/ # AppRoute constants, NavHost, ExternalLinks
│ └── notifications/ # AppMessagingService, NotificationHelper, NotificationPermissionHelper, QuietHours, TokenRegistrar
│ └── notifications/ # AppMessagingService, QuietHours, TokenRegistrar
├── crypto/ # E2EE: Tink AEAD, BouncyCastle Argon2id, key stores
├── data/
│ ├── backup/ # BackupManager + BackupRestoreManager + RestoreManager (R24 E2EE backup + partner-assist restore)
│ ├── challenges/ # Connection Challenges data sources
│ ├── local/ # Room DAOs, DataStore, EncryptedSharedPreferences (RecoveryPhraseStore, SecurePreferencesFactory, SettingsDataStore); also converters/, entity/, mapper/ subdirs
│ ├── questions/ # Question pack data sources (`QuestionJsonParser`)
│ ├── (no `questions/` package — JSON parsing was retired; see note below)
│ ├── remote/ # Firestore data sources, Cloud Functions callable wrappers
│ ├── repository/ # Repository implementations
│ └── security/ # PlayIntegrityChecker
@ -115,7 +115,7 @@ app/src/main/java/app/closer/
│ ├── repository/ # Repository interfaces
│ ├── security/ # AuthRateLimiter, DeviceIntegrityChecker (interface for PlayIntegrityChecker)
│ └── usecase/ # Cross-VM use cases: DailyQuestionResolver, GameSessionManager, SoloAnswerMigrator
├── notifications/ # Notification surfaces: ActiveGameSessionMonitor, GamePromptController, PartnerNotificationManager, PartnerNotificationScheduler, QuietHoursManager, NotificationChannelSetup, NotificationRateLimiter, MessageBubbleController, ActiveThreadMonitor
├── notifications/ # Notification surfaces: ActiveGameSessionMonitor, GamePromptController, PartnerNotificationManager, QuietHoursManager, NotificationChannelSetup, NotificationRateLimiter, MessageBubbleController, ActiveThreadMonitor
└── ui/ # Compose screens + ViewModels
├── activity/ # Together / Activity screen
├── answers/ # Answer write/reveal/history
@ -144,7 +144,7 @@ app/src/main/java/app/closer/
└── wheel/ # Spin the wheel
```
**Note on the manual's older description**: a `core/security/` package was documented in earlier revisions of this manual but doesn't exist in the current source. The auth rate limiter is in `domain/security/AuthRateLimiter.kt`, and `QuestionJsonParser` is at `data/questions/QuestionJsonParser.kt` while `QuestionDao` is in `data/local/QuestionDao.kt`.
**Note on the manual's older description**: a `core/security/` package was documented in earlier revisions of this manual but doesn't exist in the current source. The auth rate limiter is in `domain/security/AuthRateLimiter.kt`, and question content is loaded from the bundled Room/asset-DB (`QuestionDao` in `data/local/QuestionDao.kt`) — the old `data/questions/QuestionJsonParser.kt` was retired by commit `21392ec2` (Tier 1 dead-file sweep, 2026-07-09). The `core/notifications/` package no longer contains `NotificationHelper.kt` or `NotificationPermissionHelper.kt` (both removed by the same sweep as dead duplicates of `NotificationChannelSetup`); the `notifications/` package no longer contains `PartnerNotificationScheduler.kt` (superseded by `PartnerNotificationManager`); and the `domain/model/` package no longer contains `Entitlement.kt`, `InviteStatus.kt`, or `QuestionSessionStatus.kt` (entitlement state is read as Firestore booleans, not a domain model). All removals were verified with symbol-level git-grep + a clean `:app:compileDebugKotlin` + `compileDebugUnitTestKotlin`.
The Android settings package contains: `SettingsScreen`, `SettingsViewModel`, `SettingsVisuals`, `AccountScreen`, `EditProfileScreen` + `EditProfileViewModel`, `AppearanceScreen`, `DeleteAccountScreen`, `NotificationSettingsScreen`, `PrivacyScreen`, `RelationshipSettingsScreen`, `SecurityScreen`, `SubscriptionScreen`. The `SecurityScreen` is biometric-gated for the recovery phrase reveal.
@ -1217,7 +1217,7 @@ SCRIPTS.md
### Logging
- Cloud Functions prefix every log line with the function name: `[acceptInviteCallable] ...`.
- Android production builds must not log secrets, recovery phrases, keyset bytes, or invite codes. Wrap `android.util.Log` calls in `BuildConfig.DEBUG` guards (see `app/src/main/java/app/closer/data/questions/QuestionJsonParser.kt` for an example).
- Android production builds must not log secrets, recovery phrases, keyset bytes, or invite codes. Wrap `android.util.Log` calls in `BuildConfig.DEBUG` guards (see `app/src/main/java/app/closer/analytics/CompositeRetentionAnalytics.kt` for a current example). The historical reference `data/questions/QuestionJsonParser.kt` is gone (retired in commit `21392ec2`); the same guard pattern is used wherever logging survives the dead-file sweep.
- Crashlytics is the production observability path. Do not log to both Crashlytics and console in production.
### Error handling
@ -1352,7 +1352,7 @@ OOB code = `truncate6(SHA-256(pubkey ‖ nonce))`.
### R23-DQ-001 - sourcing "already answered?" from local Room only → silent re-answer data loss against the immutable `secure/payload`
**Symptom (R23)**: on a device whose local answer store was empty while Firestore still held the user's daily answer (fresh device / reinstall with cleared data / wiped prefs), Home showed a stale **"your turn"** and the daily-question screen offered an **editable re-answer form**. Submitting logged `Write failed at couples/{id}/daily_question/{date}/answers/{uid}/secure/payload: PERMISSION_DENIED` (swallowed). If the user picked a *different* answer it was **silently lost** - the `secure/payload` doc is immutable (`allow update: if false`), so the overwrite is denied and the reveal keeps the *old* content while the UI claimed "saved".
**Root cause**: `DailyQuestionViewModel.loadDailyQuestion` and `HomeViewModel` derived answered-state from **local Room/prefs only** (`localAnswerRepository.getAnswer` / `observeAnswers``answeredQuestionIds`), with no fallback to Firestore. Room and Firestore can legitimately diverge (Auth + Firestore persist across a reinstall; the local answer store does not), so the app offered an action the rules forbid.
**Fix (R23)**: `reconcileLocalAnswerFromFirestore` ([`ui/questions/LocalAnswerMapping.kt`](../app/src/main/java/app/closer/ui/questions/LocalAnswerMapping.kt)) - **Room-first** (returns the local answer immediately when present, so the common path is unchanged and network-free); otherwise reads the answer metadata + decrypts the owner's own read-gated couple-key payload, rebuilds the `LocalAnswer` (mapping option-texts), and **writes it back to Room** (only when it actually decrypts, so a transient key-miss never poisons Room). Wired into `DailyQuestionViewModel.loadDailyQuestion` (awaited → screen shows submitted/reveal) and `HomeViewModel.loadHome` (non-blocking heal → no stale "your turn"). Covered by `ReconcileLocalAnswerTest` (5 branches). `QuestionDetailViewModel` (pack questions) is **local-only** and not affected.
**Fix (R23)**: `reconcileLocalAnswerFromFirestore` ([`ui/questions/LocalAnswerMapping.kt`](../app/src/main/java/app/closer/ui/questions/LocalAnswerMapping.kt)) - **Room-first** (returns the local answer immediately when present, so the common path is unchanged and network-free); otherwise reads the answer metadata + decrypts the owner's own read-gated couple-key payload, rebuilds the `LocalAnswer` (mapping option-texts), and **writes it back to Room** (only when it actually decrypts, so a transient key-miss never poisons Room). Wired into `DailyQuestionViewModel.loadDailyQuestion` (awaited → screen shows submitted/reveal) and `HomeViewModel.loadHome` (non-blocking heal → no stale "your turn"). Covered by `ReconcileLocalAnswerTest` (5 branches). Pack-question loading (now via `QuestionPackLibraryViewModel` + bundled Room) is **local-only** and not affected. Note: the older landmine draft referenced `QuestionDetailViewModel` for pack questions, but that VM was retired by commit `21392ec2` (Tier 1 dead-file sweep) - pack questions are now read directly by `QuestionPackLibraryViewModel`.
**Re-introduction risk**: any UI that decides whether to offer a **submit-once / immutable** write (daily answers, date reflections, anything with a read-gated `secure/payload` whose `allow update:false`) must treat **Firestore as authoritative for existence**, not local cache - a local-only check re-offers the action after the local DB is lost and the immutable rule rejects the re-write silently. The date feature already does this (its `hasReflected` reads Firestore). When adding a new private→reveal collection, reconcile from Firestore on load and verify the fresh-device path (Pass N **R23-DQ-001** check), since `pm clear` (the faithful repro) is blocked in our emulator setup (App Check token).
### B-ABANDON-001 - never UPDATE an existing session via the full-document `saveSession()`; the rule rejects dropped server-only keys