feat: security hardening, CI, analytics, invite deep links, solo mode, weekly recap

Roadmap phases 0-3.1 (see plan). Verified: unit tests, functions tests,
firestore rules 119/119, live emulator smokes.

Security + CI:
- Move App Check debug token out of committed build.gradle.kts to
  local.properties (secret() resolver: local.properties -> -P -> env);
  admin service-account key relocated outside the repo.
- Add GitHub Actions (android unit/scan/gitleaks, backend functions +
  rules emulator via emulators:exec) with a CI google-services.json stub
  and a reviewed .gitleaksignore baseline.
- Fix firestore rules tests to the current deployed contract (was 108/118,
  now 119/119).

Analytics + consent:
- FirebaseRetentionAnalytics behind a fail-safe composite sink, single-
  sourced hashing (AnalyticsHashing), Settings -> Privacy opt-out toggle
  gating Firebase + the sink, and the ~11 missing event call sites.
- NoPlaintextInAnalyticsTest guards the content-free rule at build time.

Funnel:
- Invite deep links (https App Link + closer:// fallback), JoinLink parser,
  PendingJoinCodeStore, prefilled/already-paired pairing, richer share text.
- Solo pre-pairing: UnpairedLockedCard on Play, SoloAnswerMigrator + consent
  dialog moving pre-pairing answers into Couple Lore.

Weekly recap:
- WeeklyRecapScreen driven by the existing generator from local answer data,
  Home card rewired, content-free shareable card.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
null 2026-07-06 20:33:38 -05:00
parent f68cab5cf2
commit 55699e17ed
54 changed files with 1918 additions and 196 deletions

30
.github/ci/google-services.json vendored Normal file
View File

@ -0,0 +1,30 @@
{
"_comment": "CI-only stub. The real google-services.json is gitignored (contains live Firebase project config). This file exists solely so the com.google.gms.google-services Gradle plugin can process the debug variant for JVM unit tests in CI. All values are dummies; the fake api key deliberately does NOT match the AIza… format so secret scanners stay quiet.",
"project_info": {
"project_number": "000000000000",
"project_id": "closer-ci-stub",
"storage_bucket": "closer-ci-stub.appspot.com"
},
"client": [
{
"client_info": {
"mobilesdk_app_id": "1:000000000000:android:0000000000000000000000",
"android_client_info": {
"package_name": "closer.app"
}
},
"oauth_client": [],
"api_key": [
{
"current_key": "ci-stub-not-a-real-key-0000000000000000"
}
],
"services": {
"appinvite_service": {
"other_platform_oauth_client": []
}
}
}
],
"configuration_version": "1"
}

76
.github/workflows/android-ci.yml vendored Normal file
View File

@ -0,0 +1,76 @@
# Android CI — JVM unit tests + static repo scans + secret scan.
#
# Secrets are intentionally ABSENT here: the debug build compiles with an empty
# APP_CHECK_DEBUG_TOKEN and a placeholder RC_API_KEY (see app/build.gradle.kts
# `secret()` resolution). CI-built APKs therefore cannot pass App Check once
# enforcement is on — expected; CI only runs JVM tests.
#
# app/google-services.json is gitignored (live Firebase config), but the
# google-services Gradle plugin hard-fails without one, so CI copies the dummy
# stub from .github/ci/google-services.json before any Gradle task.
name: Android CI
on:
push:
branches: [main, dev]
pull_request:
jobs:
unit-tests:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: "17"
- uses: gradle/actions/setup-gradle@v4
- name: Provide stub google-services.json (real one is gitignored)
run: |
if [ ! -f app/google-services.json ]; then
cp .github/ci/google-services.json app/google-services.json
fi
- name: Unit tests
run: ./gradlew testDebugUnitTest
- name: Upload test reports on failure
if: failure()
uses: actions/upload-artifact@v4
with:
name: unit-test-reports
path: app/build/reports/tests/
repo-scans:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
# Static scanners that gate known regression classes (theme tokens,
# navigation wiring, IME flags, painter/XML drawable misuse).
- name: Scan scripts
run: |
bash scripts/theme-scan.sh
bash scripts/wiring-scan.sh
bash scripts/ime-scan.sh
bash scripts/painter-xml-scan.sh
gitleaks:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # full history so the scan covers every commit
- name: Run gitleaks
env:
GITLEAKS_VERSION: "8.21.2"
run: |
curl -sSfL "https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" | tar -xz gitleaks
./gitleaks detect --source . --no-banner --redact

68
.github/workflows/backend-ci.yml vendored Normal file
View File

@ -0,0 +1,68 @@
# Backend CI — Cloud Functions build+tests and Firestore security-rules tests.
#
# The rules job needs BOTH Node and Java: the Firestore emulator is a JVM
# process. jest does NOT boot the emulator itself (its globalSetup only points
# FIRESTORE_EMULATOR_HOST at port 8180 per firebase.json) — so the suite runs
# under `firebase emulators:exec`, which boots the emulator, runs the tests,
# and tears it down. `--project demo-closer` keeps it fully offline (demo-*
# projects never touch production).
name: Backend CI
on:
push:
branches: [main, dev]
pull_request:
jobs:
functions:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
cache: npm
cache-dependency-path: functions/package-lock.json
- name: Install
working-directory: functions
run: npm ci
- name: Build (tsc)
working-directory: functions
run: npm run build
- name: Test
working-directory: functions
run: npm test
firestore-rules:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: "17"
- uses: actions/setup-node@v4
with:
node-version: "20"
cache: npm
cache-dependency-path: firestore-tests/package-lock.json
- name: Install
working-directory: firestore-tests
run: npm ci
# Runs from the repo root: emulators:exec reads firebase.json (rules path +
# emulator port 8180) there, boots the emulator, runs the suite, tears down.
- name: Rules tests (under emulators:exec)
run: >
firestore-tests/node_modules/.bin/firebase emulators:exec
--only firestore --project demo-closer
"cd firestore-tests && npx jest --runInBand"

16
.gitleaksignore Normal file
View File

@ -0,0 +1,16 @@
# Known historical findings, reviewed 2026-07-06 — all benign, baselined so CI stays strict
# on anything NEW. Format: <commit>:<file>:<rule>:<line>.
#
# 1-3) scratchpad QA probe scripts embed the Firebase WEB API key (AIzaSyDAD7…). That key is
# a project identifier, not a bearer secret (access control = Auth + rules + App Check),
# and it ships inside every APK anyway. Owner hardening (optional): add application
# restrictions to the key in GCP Console → Credentials.
62696a69ea2de499b3b28204bdb4e0ffa9fdfe25:scratchpad/rules_arrays.js:gcp-api-key:8
62696a69ea2de499b3b28204bdb4e0ffa9fdfe25:scratchpad/rules_pos.js:gcp-api-key:3
38ff166598d6d85145bbeea53e651f7ef0cbc9f6:scratchpad/d3_negative.js:gcp-api-key:6
#
# 4-6) iOS crypto unit-test fixtures — fixed base64 test vectors (e.g. "test-keybox-
# ciphertext-blob"), not credentials.
ade4667db723986c2d0185cfa71aa7808d559a8b:iphone/CloserTests/CryptoTests/KeyboxCallableTests.swift:generic-api-key:44
ade4667db723986c2d0185cfa71aa7808d559a8b:iphone/CloserTests/CryptoTests/KeyboxCallableTests.swift:generic-api-key:73
60c0003114fe0677858b0b2de7331a2a3699f901:iphone/CloserTests/CryptoTests/AES_GCM_KnownVectorTests.swift:generic-api-key:37

View File

@ -35,6 +35,27 @@ Non-blocking ideas: things that work today but could be better, plus feature ide
messages; today mitigated by the `generation` counter + a Phase-1 Firestore cross-check. Add a signed/monotonic
freshness signal for the Option-B world.
## Solo / pre-pairing (Batch 2.2 follow-ons)
The core of the pre-pairing experience shipped (R29): unpaired users can answer the daily question solo
(`DailyQuestionViewModel.submitAnswer` already saves to `LocalAnswerRepository` and skips the Firestore
sync when there's no couple), reach it via the Home secondary card, and see an honest `UnpairedLockedCard`
on the Play hub instead of a silent bounce to invite. On pairing, `SoloAnswerMigrator` offers a one-time
"bring your answers along?" consent dialog (`PairingSuccessScreen`) that writes the solo answers into
**Couple Lore** (encrypted) or discards them. Remaining, deliberately deferred:
- **Read-only date-ideas browse when unpaired.** The Date Match / Plan Date tiles still route unpaired users
to invite (they need a couple for swipes). A standalone read-only date-ideas list (the seed is client-side
and `date_ideas` rules already allow any authed read) would let a solo user browse while they wait. Pairs
well with the Firestore-backed catalog work (Batch 4.2).
- **Remote Config kill switch `solo_mode_enabled`.** Not yet wired; `firebase-config-ktx` is already a dep.
- **Convert the remaining silent bounces to `UnpairedLockedCard`.** Wheel / Messages / Answer-history / the
daily-question "Discuss" button still redirect unpaired users to `CREATE_INVITE` without explanation; reuse
the new `ui/components/UnpairedLockedCard.kt` on those surfaces for consistency.
- **Migrate-on-pairing robustness.** `SoloAnswerMigrator` is best-effort per entry (a not-yet-ready couple
key leaves the answer local for a retry), but there's no later retry trigger beyond the pairing-success
screen — add one if telemetry shows keys aren't ready at that moment.
## UI
_(No open UI defects. The P0 onboarding/auth crash filed here 2026-06-28 was fixed + verified live and moved to

View File

@ -1,3 +1,5 @@
import java.util.Properties
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
@ -8,6 +10,18 @@ plugins {
id("com.google.devtools.ksp")
}
// Secrets resolve from local.properties (gitignored) first, then -P/gradle.properties, then env.
// local.properties is NOT auto-loaded into Gradle properties, so read it explicitly here.
val localProperties = Properties().apply {
val file = rootProject.file("local.properties")
if (file.exists()) file.inputStream().use { load(it) }
}
fun secret(name: String): String? =
localProperties.getProperty(name)?.takeIf { it.isNotBlank() }
?: (project.findProperty(name) as? String)?.takeIf { it.isNotBlank() }
?: System.getenv(name)?.takeIf { it.isNotBlank() }
android {
namespace = "app.closer"
compileSdk = 35
@ -26,7 +40,7 @@ android {
buildConfigField(
"String",
"RC_API_KEY",
"\"${properties["RC_API_KEY"]?.toString() ?: System.getenv("RC_API_KEY") ?: "PLACEHOLDER_RC_API_KEY"}\""
"\"${secret("RC_API_KEY") ?: "PLACEHOLDER_RC_API_KEY"}\""
)
}
@ -60,10 +74,17 @@ android {
buildTypes {
debug {
// Stable debug token registered in Firebase Console > App Check.
// Pre-seeded into SharedPreferences by FirebaseInitializer so all installs
// use the same token without manual re-registration.
buildConfigField("String", "APP_CHECK_DEBUG_TOKEN", "\"e2dc8256-403f-449b-846e-76614a7297cc\"")
// Stable debug token registered in Firebase Console > App Check, pre-seeded into
// SharedPreferences by FirebaseInitializer so all installs use the same token.
// NEVER hardcode it here — a committed token defeats App Check the moment
// enforcement is enabled. Set APP_CHECK_DEBUG_TOKEN in local.properties; when
// absent (e.g. CI), the field is empty and FirebaseInitializer skips seeding
// (each install then mints its own token, which won't be console-registered).
buildConfigField(
"String",
"APP_CHECK_DEBUG_TOKEN",
"\"${secret("APP_CHECK_DEBUG_TOKEN") ?: ""}\""
)
}
release {
buildConfigField("String", "APP_CHECK_DEBUG_TOKEN", "\"\"")
@ -105,8 +126,7 @@ tasks.matching { it.name.let { n ->
(n.startsWith("assemble") || n.startsWith("bundle")) && n.contains("Release", ignoreCase = true)
}}.configureEach {
doFirst {
val key = (findProperty("RC_API_KEY") as? String)?.takeIf { it.isNotBlank() }
?: System.getenv("RC_API_KEY")?.takeIf { it.isNotBlank() }
val key = secret("RC_API_KEY")
if (key == null || key == "PLACEHOLDER_RC_API_KEY") {
throw GradleException(
"RC_API_KEY is not set. Add it to local.properties or export RC_API_KEY before running a release build."
@ -216,6 +236,8 @@ dependencies {
// Unit tests — JVM only (no device/emulator required)
testImplementation("junit:junit:4.13.2")
// Reflection over the RetentionEvent sealed hierarchy (NoPlaintextInAnalyticsTest schema check)
testImplementation(kotlin("reflect"))
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0")
testImplementation("io.mockk:mockk:1.13.14")
// Real org.json on the JVM test classpath so codec round-trips run (Android's stubbed JSONObject

View File

@ -33,6 +33,23 @@
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!-- Invite deep link: verified https App Link (opens the app directly once
assetlinks.json is hosted; pre-verification it appears in the app chooser). -->
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" android:host="closer.app" android:pathPrefix="/join/" />
</intent-filter>
<!-- Custom-scheme fallback for contexts that can't launch an App Link. -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="closer" android:host="closer.app" android:pathPrefix="/join/" />
</intent-filter>
</activity>
<service

View File

@ -1,6 +1,7 @@
package app.closer
import android.app.Application
import app.closer.analytics.AnalyticsConsentApplier
import app.closer.core.firebase.FirebaseInitializer
import app.closer.data.repository.ActivityProvider
import app.closer.domain.security.DeviceIntegrityChecker
@ -19,6 +20,7 @@ class CloserApp : Application() {
@Inject lateinit var firebaseInitializer: FirebaseInitializer
@Inject lateinit var deviceIntegrityChecker: DeviceIntegrityChecker
@Inject lateinit var analyticsConsentApplier: AnalyticsConsentApplier
private val appScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
@ -30,5 +32,8 @@ class CloserApp : Application() {
NotificationChannelSetup.createChannels(applicationContext)
firebaseInitializer.initialize()
appScope.launch { deviceIntegrityChecker.runCheck() }
// Keep Firebase collection + the retention sink in step with the user's
// "share anonymous usage data" setting (Settings → Privacy), incl. across restarts.
appScope.launch { analyticsConsentApplier.observe() }
}
}

View File

@ -37,6 +37,8 @@ import android.content.pm.PackageManager
import android.os.Build
import app.closer.BuildConfig
import app.closer.core.navigation.AppNavigation
import app.closer.core.navigation.AppRoute
import app.closer.core.navigation.JoinLink
import app.closer.core.notifications.TokenRegistrar
import app.closer.domain.model.AuthState
import app.closer.notifications.PartnerNotificationPayload
@ -69,6 +71,8 @@ class MainActivity : AppCompatActivity() {
@Inject lateinit var settingsRepository: SettingsRepository
@Inject lateinit var authRepository: AuthRepository
@Inject lateinit var tokenRegistrar: TokenRegistrar
@Inject lateinit var retentionAnalytics: app.closer.analytics.RetentionAnalytics
@Inject lateinit var pendingJoinCodeStore: app.closer.data.local.PendingJoinCodeStore
private val notificationPermissionLauncher =
registerForActivityResult(ActivityResultContracts.RequestPermission()) { }
@ -213,7 +217,22 @@ class MainActivity : AppCompatActivity() {
// already-resolved app route as an extra. Prefer it so routing never depends on per-route
// navDeepLink registration — game/challenge/date/capsule routes aren't registered as Uri
// deep links, so those taps used to fall through to Home when the app was foregrounded.
intent.getStringExtra("app_route")?.takeIf { it.isNotBlank() }?.let { return it }
intent.getStringExtra("app_route")?.takeIf { it.isNotBlank() }?.let { route ->
trackPushOpened(intent.getStringExtra("type") ?: "app_route")
return route
}
// Invite deep link (https://closer.app/join/CODE or closer://…/join/CODE). Stash the
// code so it survives a process death during sign-up, and route to the pairing screen
// prefilled. pendingDeepLink then holds this until the user is past onboarding.
JoinLink.parseCode(intent.dataString)?.let { code ->
lifecycleScope.launch {
runCatching { pendingJoinCodeStore.setPendingCode(code) }
.onFailure { Log.w("MainActivity", "Failed to stash invite code", it) }
}
return AppRoute.acceptInvite(code)
}
// Any OTHER inbound Uri (our own foreground closer:// PendingIntents) is left for the
// NavHost's registered navDeepLinks — do not resolve it from extras.
if (intent.data != null) return null
val type = intent.getStringExtra("type") ?: return null
val coupleId = intent.getStringExtra("couple_id") ?: ""
@ -228,6 +247,15 @@ class MainActivity : AppCompatActivity() {
avatarUrl = intent.getStringExtra("sender_avatar_url")
)
return PartnerNotificationType.fromRemoteType(type)?.routeFor(payload, coupleId)
?.also { trackPushOpened(type) }
}
/** A notification tap resolved to a route the push_notification_opened signal. The
* notification `type` (an app-defined constant, never content) rides as categoryId. */
private fun trackPushOpened(type: String) {
retentionAnalytics.track(
app.closer.analytics.RetentionEvent.PushNotificationOpened(categoryId = type)
)
}
/**

View File

@ -0,0 +1,32 @@
package app.closer.analytics
import app.closer.domain.repository.SettingsRepository
import com.google.firebase.analytics.FirebaseAnalytics
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import javax.inject.Inject
import javax.inject.Singleton
/**
* Applies the user's "share anonymous usage data" setting (Settings Privacy) to every
* analytics surface: the Firebase SDK's own collection flag AND the retention sink.
*
* Launched once from [app.closer.CloserApp]'s application scope. Off = zero events; the
* flag persists across restarts (DataStore), and Firebase honors it before first dispatch.
*/
@Singleton
class AnalyticsConsentApplier @Inject constructor(
private val settingsRepository: SettingsRepository,
private val firebaseAnalytics: FirebaseAnalytics,
private val retentionAnalytics: RetentionAnalytics,
) {
suspend fun observe() {
settingsRepository.settings
.map { it.analyticsEnabled }
.distinctUntilChanged()
.collect { enabled ->
runCatching { firebaseAnalytics.setAnalyticsCollectionEnabled(enabled) }
runCatching { retentionAnalytics.setEnabled(enabled) }
}
}
}

View File

@ -0,0 +1,47 @@
package app.closer.analytics
import java.security.MessageDigest
/**
* Single source of truth for the transforms that make identifiers safe to log.
*
* Every analytics sink (Firebase, Logcat) MUST route identifiers through these before
* emitting them. Raw question IDs, category names, or couple IDs never leave the device.
*/
object AnalyticsHashing {
/**
* Hashes an identifier for analytics logging.
*
* SHA-256 truncated to the first 16 hex characters: one-way and deterministic
* enough for aggregate correlation while making it infeasible to recover the
* original value.
*/
fun hashForAnalytics(value: String): String {
if (value.isBlank()) return "empty"
return try {
MessageDigest.getInstance("SHA-256")
.digest(value.toByteArray(Charsets.UTF_8))
.joinToString("", limit = 8, truncated = "") { "%02x".format(it) }
} catch (e: Exception) {
"hash_error"
}
}
/**
* Maps fine-grained category identifiers to a coarse-grained set safe for analytics.
*
* Preserves useful aggregate behavioral data (which coarse areas drive engagement)
* without leaking the specific, often intimate, category names.
*/
fun coarseCategory(category: String): String {
return when (category.lowercase().trim()) {
"communication", "conflict", "trust", "intimacy", "emotional_intimacy",
"physical_intimacy", "sex", "desire", "rebuilding_trust" -> "intimacy"
"fun", "play", "adventure", "date_night", "travel", "creativity" -> "fun"
"money", "future", "goals", "family", "values", "lifestyle" -> "life"
"daily_check_in", "app_usage" -> "meta"
else -> "other"
}
}
}

View File

@ -0,0 +1,18 @@
package app.closer.analytics
/**
* Fans a [RetentionEvent] out to multiple sinks, isolating each: one sink throwing must
* never stop the others or reach the caller (analytics can't break user flows).
*/
class CompositeRetentionAnalytics(
private val sinks: List<RetentionAnalytics>,
) : RetentionAnalytics {
override fun track(event: RetentionEvent) {
sinks.forEach { sink -> runCatching { sink.track(event) } }
}
override fun setEnabled(enabled: Boolean) {
sinks.forEach { sink -> runCatching { sink.setEnabled(enabled) } }
}
}

View File

@ -0,0 +1,46 @@
package app.closer.analytics
import com.google.firebase.analytics.FirebaseAnalytics
import com.google.firebase.analytics.logEvent
import javax.inject.Inject
import javax.inject.Singleton
/**
* Firebase Analytics sink for [RetentionEvent]s.
*
* Event name = the lowercased [RetentionEventType] (e.g. `daily_question_answered`).
* Params carry ONLY safe metadata: the feature surface, a SHA-256-truncated category
* hash, and the (already-hashed) couple hash. Raw IDs and content never leave the device
* see the standing rule on [RetentionEvent].
*
* Fail-safe: analytics must never crash or block a user flow, so [track] swallows all
* sink errors. Consent: [setEnabled] is driven by [AnalyticsConsentApplier]; disabled
* means zero events.
*/
@Singleton
class FirebaseRetentionAnalytics @Inject constructor(
private val analytics: FirebaseAnalytics,
) : RetentionAnalytics {
@Volatile
private var enabled = true
override fun track(event: RetentionEvent) {
if (!enabled) return
try {
analytics.logEvent(event.eventType.name.lowercase()) {
param("feature", event.featureName)
event.categoryId?.let {
param("category_hash", AnalyticsHashing.hashForAnalytics(it))
}
event.coupleIdHash?.let { param("couple_hash", it) }
}
} catch (_: Exception) {
// Never let analytics break the app.
}
}
override fun setEnabled(enabled: Boolean) {
this.enabled = enabled
}
}

View File

@ -1,7 +1,6 @@
package app.closer.analytics
import android.util.Log
import java.security.MessageDigest
import javax.inject.Inject
import javax.inject.Singleton
@ -36,7 +35,7 @@ class LogcatRetentionAnalytics @Inject constructor() : RetentionAnalytics {
buildString {
append("feature=${event.featureName}")
append(", event=${event.eventType}")
event.categoryId?.let { append(", category_hash=${hashForAnalytics(it)}") }
event.categoryId?.let { append(", category_hash=${AnalyticsHashing.hashForAnalytics(it)}") }
event.coupleIdHash?.let { append(", couple_hash=${it}") }
append(", timestamp=${event.timestamp}")
}
@ -46,15 +45,4 @@ class LogcatRetentionAnalytics @Inject constructor() : RetentionAnalytics {
override fun setEnabled(enabled: Boolean) {
this.enabled = enabled
}
private fun hashForAnalytics(value: String): String {
if (value.isBlank()) return "empty"
return try {
MessageDigest.getInstance("SHA-256")
.digest(value.toByteArray(Charsets.UTF_8))
.joinToString("", limit = 8, truncated = "") { "%02x".format(it) }
} catch (e: Exception) {
"hash_error"
}
}
}

View File

@ -30,6 +30,8 @@ enum class RetentionEventType {
DAILY_TINY_ACTION_VIEWED,
DAILY_TINY_ACTION_SAVED,
COUPLE_LORE_SAVED,
INVITE_SHARED,
INVITE_LINK_OPENED,
}
/**
@ -326,4 +328,30 @@ sealed class RetentionEvent(
coupleIdHash = coupleIdHash,
timestamp = timestamp,
)
/** The inviter opened the share sheet for their invite (top of the acquisition funnel). */
data class InviteShared(
override val categoryId: String? = null,
override val coupleIdHash: String? = null,
override val timestamp: Long = System.currentTimeMillis(),
) : RetentionEvent(
featureName = "pairing",
eventType = RetentionEventType.INVITE_SHARED,
categoryId = categoryId,
coupleIdHash = coupleIdHash,
timestamp = timestamp,
)
/** The invitee opened the app from an invite deep link (the code prefills the pairing screen). */
data class InviteLinkOpened(
override val categoryId: String? = null,
override val coupleIdHash: String? = null,
override val timestamp: Long = System.currentTimeMillis(),
) : RetentionEvent(
featureName = "pairing",
eventType = RetentionEventType.INVITE_LINK_OPENED,
categoryId = categoryId,
coupleIdHash = coupleIdHash,
timestamp = timestamp,
)
}

View File

@ -1,9 +1,9 @@
package app.closer.core.analytics
import android.os.Bundle
import app.closer.analytics.AnalyticsHashing
import com.google.firebase.analytics.FirebaseAnalytics
import com.google.firebase.analytics.logEvent
import java.security.MessageDigest
import javax.inject.Inject
import javax.inject.Singleton
@ -12,40 +12,11 @@ class FirebaseAnalyticsTracker @Inject constructor(
private val analytics: FirebaseAnalytics
) : AnalyticsTracker {
/**
* Hashes an identifier for analytics logging.
*
* Uses SHA-256 and truncates to the first 16 hex characters. This is a one-way,
* deterministic transform enough for aggregate analytics correlation while making
* it infeasible to recover the original question content from the logged value.
*/
private fun hashForAnalytics(value: String): String {
if (value.isBlank()) return "empty"
return try {
val digest = MessageDigest.getInstance("SHA-256")
.digest(value.toByteArray(Charsets.UTF_8))
digest.joinToString("", limit = 8, truncated = "") { "%02x".format(it) }
} catch (e: Exception) {
"hash_error"
}
}
// Hashing + category coarsening live in AnalyticsHashing so every sink applies the
// exact same safe transforms (raw IDs/categories never leave the device).
private fun hashForAnalytics(value: String): String = AnalyticsHashing.hashForAnalytics(value)
/**
* Maps fine-grained category identifiers to a coarse-grained set safe for analytics.
*
* This preserves useful aggregate behavioral data (e.g. which coarse areas drive
* engagement) without leaking the specific, often intimate, category names.
*/
private fun coarseCategory(category: String): String {
return when (category.lowercase().trim()) {
"communication", "conflict", "trust", "intimacy", "emotional_intimacy",
"physical_intimacy", "sex", "desire", "rebuilding_trust" -> "intimacy"
"fun", "play", "adventure", "date_night", "travel", "creativity" -> "fun"
"money", "future", "goals", "family", "values", "lifestyle" -> "life"
"daily_check_in", "app_usage" -> "meta"
else -> "other"
}
}
private fun coarseCategory(category: String): String = AnalyticsHashing.coarseCategory(category)
override fun trackScreenViewed(screenName: String) {
analytics.logEvent(FirebaseAnalytics.Event.SCREEN_VIEW) {

View File

@ -346,6 +346,10 @@ fun AppNavigation(
AnswerHistoryScreen(onNavigate = navigateRoute)
}
composable(route = AppRoute.WEEKLY_RECAP) {
app.closer.ui.recap.WeeklyRecapScreen(onNavigate = navigateRoute)
}
// Pairing
composable(route = AppRoute.CREATE_INVITE) {
CreateInviteScreen(
@ -353,8 +357,21 @@ fun AppNavigation(
onBack = { navController.popBackStack() }
)
}
composable(route = AppRoute.ACCEPT_INVITE) {
composable(
route = AppRoute.ACCEPT_INVITE_PATTERN,
// Optional prefill code (empty for the manual "have a code?" entry, populated
// when arriving from an invite deep link). Bare "accept_invite" still matches.
// NOTE: no navDeepLink here on purpose — MainActivity.deepLinkRouteFromIntent
// resolves the join URI and drives navigation through pendingDeepLink, which
// waits until the user is past onboarding. Registering a navDeepLink would let
// the NavController jump here before sign-up finishes.
arguments = listOf(navArgument("code") {
type = NavType.StringType
defaultValue = ""
})
) {
AcceptInviteScreen(
initialCode = it.arguments?.getString("code").orEmpty(),
onNavigate = navigateRoute,
onBack = navigateBackOrHome
)

View File

@ -20,8 +20,12 @@ object AppRoute {
const val QUESTION_COMPOSER = "question_composer"
const val ANSWER_REVEAL = "answer_reveal/{questionId}"
const val ANSWER_HISTORY = "answer_history"
const val WEEKLY_RECAP = "weekly_recap"
const val CREATE_INVITE = "create_invite"
const val ACCEPT_INVITE = "accept_invite"
// Registered route pattern with an optional prefill code (deep-link entry). Navigating to
// the bare ACCEPT_INVITE above still matches this (code defaults to "").
const val ACCEPT_INVITE_PATTERN = "accept_invite?code={code}"
const val INVITE_CONFIRM = "invite_confirm/{inviteCode}"
const val CATEGORY_PICKER = "category_picker"
const val SPIN_WHEEL = "spin_wheel/{categoryId}"
@ -210,6 +214,9 @@ object AppRoute {
fun inviteConfirm(inviteCode: String): String = "invite_confirm/${inviteCode.asRouteArg()}"
/** Pairing screen with a code prefilled from an invite deep link. */
fun acceptInvite(code: String): String = "accept_invite?code=${code.asRouteArg()}"
fun questionCategory(categoryId: String): String = "question_category/${categoryId.asRouteArg()}"
fun spinWheel(categoryId: String): String = "spin_wheel/${categoryId.asRouteArg()}"

View File

@ -0,0 +1,45 @@
package app.closer.core.navigation
import java.net.URI
/**
* Invite deep links: `https://closer.app/join/{CODE}` (App Link, autoVerify) and the
* `closer://closer.app/join/{CODE}` scheme fallback.
*
* Pure String in/out so it's fully JVM-unit-testable (no android.net.Uri). The share
* payload builder deliberately carries ONLY the code never the recovery phrase shown on
* the same screen (asserted in JoinLinkTest).
*/
object JoinLink {
const val WEB_HOST = "closer.app"
private const val WEB_SCHEME = "https"
private const val APP_SCHEME = "closer"
private const val PATH_PREFIX = "/join/"
// Must match the code alphabet the invite system generates (createInviteCallable
// CODE_CHARS / AcceptInviteViewModel): no I, O, 0, or 1.
private const val CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
private const val CODE_LENGTH = 6
/** The shareable https link for an invite code. */
fun webUrl(code: String): String = "$WEB_SCHEME://$WEB_HOST$PATH_PREFIX$code"
/**
* Extracts a normalized invite code from a join URL, or null when the string is not a
* valid join link (wrong scheme/host/path, or a code that isn't a well-formed 6-char
* code). Callers pass `intent.dataString`; a null/blank/garbage value yields null so
* non-join intents fall through untouched.
*/
fun parseCode(uriString: String?): String? {
if (uriString.isNullOrBlank()) return null
val uri = runCatching { URI(uriString) }.getOrNull() ?: return null
val scheme = uri.scheme?.lowercase()
val host = uri.host?.lowercase()
val schemeOk = (scheme == WEB_SCHEME || scheme == APP_SCHEME)
if (!schemeOk || host != WEB_HOST) return null
val path = uri.path ?: return null
if (!path.startsWith(PATH_PREFIX)) return null
val code = path.removePrefix(PATH_PREFIX).trim('/').uppercase()
return code.takeIf { it.length == CODE_LENGTH && it.all { c -> c in CODE_ALPHABET } }
}
}

View File

@ -0,0 +1,36 @@
package app.closer.data.local
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import javax.inject.Inject
import javax.inject.Singleton
/**
* Persists ONE invite code captured from a deep link (the INVITEE side), so it survives
* process death during a long sign-up/onboarding the in-memory pendingDeepLink alone does
* not. The pairing screen prefills from here, then clears it. Holds nothing but the code.
*
* Distinct from [PendingInviteStore], which is the INVITER's encrypted code + recovery-phrase
* stash. This one has no phrase (the invitee has none yet).
*/
@Singleton
class PendingJoinCodeStore @Inject constructor(
private val dataStore: DataStore<Preferences>
) {
private val key = stringPreferencesKey("pending_join_code")
suspend fun setPendingCode(code: String) {
dataStore.edit { it[key] = code }
}
/** The stashed code, or null if none / already consumed. */
suspend fun peek(): String? = dataStore.data.map { it[key] }.first()
suspend fun clear() {
dataStore.edit { it.remove(key) }
}
}

View File

@ -40,6 +40,7 @@ class SettingsDataStore @Inject constructor(
private val BIOMETRIC_LOGIN = booleanPreferencesKey("biometric_login")
private val LAST_CELEBRATED_STREAK = intPreferencesKey("last_celebrated_streak_milestone")
private val PREMIUM_UNLOCK_CELEBRATED = booleanPreferencesKey("premium_unlock_celebrated")
private val ANALYTICS_ENABLED = booleanPreferencesKey("analytics_enabled")
override val settings: Flow<AppSettings> = dataStore.data.map { prefs ->
AppSettings(
@ -63,7 +64,8 @@ class SettingsDataStore @Inject constructor(
outcomeLastPromptedDay = prefs[OUTCOME_LAST_PROMPTED_DAY] ?: "",
biometricLoginEnabled = prefs[BIOMETRIC_LOGIN] ?: false,
lastCelebratedStreakMilestone = prefs[LAST_CELEBRATED_STREAK] ?: 0,
premiumUnlockCelebrated = prefs[PREMIUM_UNLOCK_CELEBRATED] ?: false
premiumUnlockCelebrated = prefs[PREMIUM_UNLOCK_CELEBRATED] ?: false,
analyticsEnabled = prefs[ANALYTICS_ENABLED] ?: true
)
}
@ -120,4 +122,7 @@ class SettingsDataStore @Inject constructor(
override suspend fun setPremiumUnlockCelebrated(celebrated: Boolean) =
dataStore.edit { it[PREMIUM_UNLOCK_CELEBRATED] = celebrated }.let {}
override suspend fun setAnalyticsEnabled(enabled: Boolean) =
dataStore.edit { it[ANALYTICS_ENABLED] = enabled }.let {}
}

View File

@ -1,6 +1,9 @@
package app.closer.data.remote
import android.util.Log
import app.closer.analytics.AnalyticsHashing
import app.closer.analytics.RetentionAnalytics
import app.closer.analytics.RetentionEvent
import app.closer.crypto.CoupleEncryptionManager
import app.closer.crypto.FieldEncryptor
import app.closer.domain.model.TimeCapsule
@ -18,7 +21,8 @@ private const val TAG = "FirestoreCapsuleDS"
class FirestoreCapsuleDataSource @Inject constructor(
private val db: FirebaseFirestore,
private val encryptionManager: CoupleEncryptionManager,
private val fieldEncryptor: FieldEncryptor
private val fieldEncryptor: FieldEncryptor,
private val retentionAnalytics: RetentionAnalytics
) {
private fun col(coupleId: String) =
@ -104,6 +108,11 @@ class FirestoreCapsuleDataSource @Inject constructor(
"status" to "sealed"
)
).await()
retentionAnalytics.track(
RetentionEvent.MemoryCapsuleCreated(
coupleIdHash = AnalyticsHashing.hashForAnalytics(capsule.coupleId)
)
)
return ref.id
}
@ -119,6 +128,11 @@ class FirestoreCapsuleDataSource @Inject constructor(
suspend fun unlockCapsule(coupleId: String, capsuleId: String) {
col(coupleId).document(capsuleId).update("status", "unlocked").await()
retentionAnalytics.track(
RetentionEvent.MemoryCapsuleUnlocked(
coupleIdHash = AnalyticsHashing.hashForAnalytics(coupleId)
)
)
}
suspend fun updateCapsule(capsule: TimeCapsule) {

View File

@ -1,5 +1,8 @@
package app.closer.data.remote
import app.closer.analytics.AnalyticsHashing
import app.closer.analytics.RetentionAnalytics
import app.closer.analytics.RetentionEvent
import app.closer.domain.model.ChallengeProgressState
import com.google.firebase.firestore.FieldValue
import com.google.firebase.firestore.FirebaseFirestore
@ -11,7 +14,10 @@ import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class FirestoreChallengeDataSource @Inject constructor(private val db: FirebaseFirestore) {
class FirestoreChallengeDataSource @Inject constructor(
private val db: FirebaseFirestore,
private val retentionAnalytics: RetentionAnalytics
) {
private fun challengeRef(coupleId: String, challengeId: String) =
db.collection(FirestoreCollections.COUPLES)
@ -55,16 +61,34 @@ class FirestoreChallengeDataSource @Inject constructor(private val db: FirebaseF
"completions" to emptyMap<String, List<Int>>()
)
).await()
retentionAnalytics.track(
RetentionEvent.ChallengeStarted(
categoryId = challengeId,
coupleIdHash = AnalyticsHashing.hashForAnalytics(coupleId)
)
)
}
suspend fun markDayComplete(coupleId: String, challengeId: String, userId: String, day: Int) {
challengeRef(coupleId, challengeId)
.update("completions.$userId", FieldValue.arrayUnion(day))
.await()
retentionAnalytics.track(
RetentionEvent.ChallengeDayCompleted(
categoryId = challengeId,
coupleIdHash = AnalyticsHashing.hashForAnalytics(coupleId)
)
)
}
suspend fun finishChallenge(coupleId: String, challengeId: String) {
challengeRef(coupleId, challengeId).update("status", "completed").await()
retentionAnalytics.track(
RetentionEvent.ChallengeCompleted(
categoryId = challengeId,
coupleIdHash = AnalyticsHashing.hashForAnalytics(coupleId)
)
)
}
suspend fun abandonChallenge(coupleId: String, challengeId: String) {

View File

@ -1,18 +1,34 @@
package app.closer.di
import app.closer.BuildConfig
import app.closer.analytics.CompositeRetentionAnalytics
import app.closer.analytics.FirebaseRetentionAnalytics
import app.closer.analytics.LogcatRetentionAnalytics
import app.closer.analytics.RetentionAnalytics
import dagger.Binds
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
abstract class AnalyticsModule {
object AnalyticsModule {
@Binds
/**
* Firebase is the production sink; debug builds additionally mirror events to Logcat
* (the previous Logcat-only behavior, kept for on-device QA). The composite isolates
* sink failures so analytics can never break a user flow.
*/
@Provides
@Singleton
abstract fun bindRetentionAnalytics(impl: LogcatRetentionAnalytics): RetentionAnalytics
fun provideRetentionAnalytics(
firebase: FirebaseRetentionAnalytics,
logcat: LogcatRetentionAnalytics,
): RetentionAnalytics =
if (BuildConfig.DEBUG) {
CompositeRetentionAnalytics(listOf(firebase, logcat))
} else {
firebase
}
}

View File

@ -36,7 +36,13 @@ data class AppSettings(
* current active entitlement. Set when the modal is dismissed; reset when couple premium goes
* inactive again, so a fresh activation celebrates once more.
*/
val premiumUnlockCelebrated: Boolean = false
val premiumUnlockCelebrated: Boolean = false,
/**
* "Share anonymous usage data" (Settings Privacy). Gates Firebase Analytics collection
* AND the retention-event sink. Content is never in events regardless; this controls the
* metadata stream. Opt-out; default on.
*/
val analyticsEnabled: Boolean = true
)
interface SettingsRepository {
@ -56,4 +62,5 @@ interface SettingsRepository {
suspend fun setBiometricLogin(enabled: Boolean)
suspend fun setLastCelebratedStreakMilestone(milestone: Int)
suspend fun setPremiumUnlockCelebrated(celebrated: Boolean)
suspend fun setAnalyticsEnabled(enabled: Boolean)
}

View File

@ -1,5 +1,8 @@
package app.closer.domain.usecase
import app.closer.analytics.AnalyticsHashing
import app.closer.analytics.RetentionAnalytics
import app.closer.analytics.RetentionEvent
import app.closer.domain.model.Couple
import app.closer.domain.model.GameType
import app.closer.domain.model.QuestionSession
@ -30,7 +33,8 @@ class GameSessionManager @Inject constructor(
private val sessionRepository: QuestionSessionRepository,
private val authRepository: AuthRepository,
private val coupleRepository: CoupleRepository,
private val userRepository: UserRepository
private val userRepository: UserRepository,
private val retentionAnalytics: RetentionAnalytics
) {
val currentUserId: String?
get() = authRepository.currentUserId
@ -97,7 +101,17 @@ class GameSessionManager @Inject constructor(
return sessionRepository.startSessionAtomically(session).fold(
onSuccess = { outcome ->
when (outcome) {
is SessionStartOutcome.Created -> Result.success(outcome.session)
is SessionStartOutcome.Created -> {
// All four games start here, so this is the single game_started site.
// gameType goes through as categoryId (the sink hashes it like any ID).
retentionAnalytics.track(
RetentionEvent.GameStarted(
categoryId = gameType,
coupleIdHash = AnalyticsHashing.hashForAnalytics(couple.id)
)
)
Result.success(outcome.session)
}
is SessionStartOutcome.AlreadyActive -> {
val partnerId = couple.userIds.firstOrNull { it != userId }
val partnerName = partnerId
@ -145,6 +159,12 @@ class GameSessionManager @Inject constructor(
// couple out of new games. abandonSession performs the correct write. (B-ABANDON-001)
// getOrThrow propagates a failed write (otherwise the outer runCatching falsely reports success).
sessionRepository.abandonSession(coupleId).getOrThrow()
retentionAnalytics.track(
RetentionEvent.GameCompleted(
categoryId = currentSession.gameType,
coupleIdHash = AnalyticsHashing.hashForAnalytics(coupleId)
)
)
}
/**
@ -167,7 +187,15 @@ class GameSessionManager @Inject constructor(
suspend fun markUserComplete(sessionId: String, coupleId: String): Result<Unit> {
val userId = authRepository.currentUserId
?: return Result.failure(Exception("Not signed in"))
return sessionRepository.markUserComplete(sessionId, coupleId, userId)
return sessionRepository.markUserComplete(sessionId, coupleId, userId).onSuccess {
// This user finished playing (async games complete per-user; the session itself
// closes once both are in). gameType isn't at hand here — the count is the KPI.
retentionAnalytics.track(
RetentionEvent.GameCompleted(
coupleIdHash = AnalyticsHashing.hashForAnalytics(coupleId)
)
)
}
}
/**

View File

@ -0,0 +1,65 @@
package app.closer.domain.usecase
import app.closer.data.remote.FirestoreAnswerDataSource
import app.closer.domain.model.LocalAnswer
import app.closer.domain.repository.LocalAnswerRepository
import kotlinx.coroutines.flow.first
import javax.inject.Inject
import javax.inject.Singleton
/**
* Moves answers a user wrote SOLO (before pairing) into the couple's shared Couple Lore once they
* pair, so the "5 minutes of value while you wait" isn't lost. At the pairing-success moment, any
* locally-stored answer is by definition a pre-couple solo answer (paired answers sync to Firestore
* and are reconciled from there), so migrating the local set exactly once at pairing is correct.
*
* Requires the couple key to be ready [FirestoreAnswerDataSource.saveLoreEntry] encrypts with it.
* Per-entry best-effort: a failure (e.g. key not yet ready) leaves that answer in local storage so a
* later attempt can retry rather than silently dropping it.
*/
@Singleton
class SoloAnswerMigrator @Inject constructor(
private val localAnswerRepository: LocalAnswerRepository,
private val answerDataSource: FirestoreAnswerDataSource,
) {
/** How many solo answers are waiting to be brought into the relationship. */
suspend fun pendingCount(): Int = localAnswerRepository.observeAnswers().first().size
/** Writes each solo answer into Couple Lore and clears the ones that succeeded. Returns the count moved. */
suspend fun migrateToLore(coupleId: String): Int {
var migrated = 0
localAnswerRepository.observeAnswers().first().forEach { answer ->
val text = answer.displayText() ?: return@forEach
runCatching {
answerDataSource.saveLoreEntry(
coupleId = coupleId,
questionId = answer.questionId,
questionText = answer.questionText,
ownAnswer = text,
partnerAnswer = null,
modeTag = null,
date = answer.answerDate
)
}.onSuccess {
localAnswerRepository.deleteAnswer(answer.questionId)
migrated++
}
}
return migrated
}
/** The user chose to start fresh — drop the solo answers. */
suspend fun discard() {
localAnswerRepository.observeAnswers().first().forEach {
localAnswerRepository.deleteAnswer(it.questionId)
}
}
/** Flattens a stored answer to shareable text; null when there's nothing meaningful to carry. */
private fun LocalAnswer.displayText(): String? = when {
!writtenText.isNullOrBlank() -> writtenText
selectedOptionTexts.isNotEmpty() -> selectedOptionTexts.joinToString(", ")
scaleValue != null -> scaleValue.toString()
else -> null
}
}

View File

@ -0,0 +1,56 @@
package app.closer.ui.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
/**
* Honest "this needs your partner" affordance for unpaired users, shown IN PLACE of a silent
* redirect to the invite screen. Explains why the feature is locked and offers a clear invite CTA.
*
* Used on couple-only surfaces (Play hub, etc.) so an unpaired user understands the app rather than
* being bounced without explanation.
*/
@Composable
fun UnpairedLockedCard(
onInvite: () -> Unit,
modifier: Modifier = Modifier,
title: String = "Better with your person",
body: String = "Games, reveals, and messages open up the moment your partner joins. It takes a minute.",
) {
Surface(
modifier = modifier.fillMaxWidth(),
shape = RoundedCornerShape(CloserRadii.Card),
color = MaterialTheme.colorScheme.primaryContainer,
contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
) {
Column(
modifier = Modifier.padding(18.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
Text(
text = title,
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold)
)
Text(
text = body,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.82f)
)
CloserActionButton(
label = "Invite your partner",
onClick = onInvite,
modifier = Modifier.fillMaxWidth()
)
}
}
}

View File

@ -3,6 +3,9 @@ package app.closer.ui.dates
import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import app.closer.analytics.AnalyticsHashing
import app.closer.analytics.RetentionAnalytics
import app.closer.analytics.RetentionEvent
import app.closer.core.billing.CouplePremiumChecker
import app.closer.domain.model.DateIdea
import app.closer.domain.model.DateMatch
@ -52,7 +55,8 @@ class DateMatchViewModel @Inject constructor(
private val authRepository: AuthRepository,
private val coupleRepository: CoupleRepository,
private val userRepository: UserRepository,
private val premiumChecker: CouplePremiumChecker
private val premiumChecker: CouplePremiumChecker,
private val retentionAnalytics: RetentionAnalytics
) : ViewModel() {
private val _uiState = MutableStateFlow(DateMatchUiState())
@ -152,6 +156,15 @@ class DateMatchViewModel @Inject constructor(
val result = repository.recordSwipe(coupleId, userId, current.id, action)
result.fold(
onSuccess = {
// A LOVE swipe is the "saved this date idea" signal.
if (action == SwipeAction.LOVE) {
retentionAnalytics.track(
RetentionEvent.DateIdeaSaved(
categoryId = current.category,
coupleIdHash = AnalyticsHashing.hashForAnalytics(coupleId)
)
)
}
// Advance to the next idea. A resulting match (if both partners
// loved this idea) is created server-side and surfaces via the
// observeMatches flow, which sets justMatched.

View File

@ -218,7 +218,12 @@ fun HomeScreen(
onAcceptInvite = { onNavigate(AppRoute.ACCEPT_INVITE) },
onReminder = viewModel::sendGentleReminder,
onReveal = { state.dailyQuestion?.id?.let { onNavigate(AppRoute.answerReveal(it)) } },
onFollowUp = { state.dailyQuestion?.let { onNavigate(AppRoute.questionThread(state.coupleId ?: "", it.id)) } },
onFollowUp = {
state.dailyQuestion?.let {
viewModel.onFollowUpOpened()
onNavigate(AppRoute.questionThread(state.coupleId ?: "", it.id))
}
},
onRefresh = viewModel::loadHome,
onPartner = { if (state.isPaired) onNavigate(AppRoute.PARTNER_HOME) }
)
@ -243,6 +248,7 @@ private fun HomeCallbacks.toActionHandler(onNavigate: (String) -> Unit): (HomeAc
HomeActionTarget.InvitePartner -> onInvite()
HomeActionTarget.DailyQuestion -> onDailyQuestion()
HomeActionTarget.AnswerHistory -> onHistory()
HomeActionTarget.WeeklyRecap -> onNavigate(AppRoute.WEEKLY_RECAP)
HomeActionTarget.QuestionPacks -> action.categoryId?.let(onCategory) ?: onPacks()
HomeActionTarget.Settings -> onSettings()
HomeActionTarget.AnswerReveal -> onReveal()
@ -418,6 +424,7 @@ private fun homeActionGlyph(target: HomeActionTarget): Int = when (target) {
HomeActionTarget.DatePlan -> R.drawable.glyph_date_card_heart
HomeActionTarget.MemoryCapsule -> R.drawable.glyph_memory_capsule
HomeActionTarget.DateMemories -> R.drawable.glyph_date_replay
HomeActionTarget.WeeklyRecap -> R.drawable.glyph_paired_cards
}
@Composable

View File

@ -71,7 +71,8 @@ enum class HomeActionTarget {
Challenge,
DatePlan,
MemoryCapsule,
DateMemories
DateMemories,
WeeklyRecap
}
enum class HomeActionTone {
@ -232,7 +233,8 @@ class HomeViewModel @Inject constructor(
private val dateMemoryDataSource: app.closer.data.remote.FirestoreDateMemoryDataSource,
private val dateReflectionDataSource: app.closer.data.remote.FirestoreDateReflectionDataSource,
private val answerDataSource: FirestoreAnswerDataSource,
private val backupManager: app.closer.data.backup.BackupManager
private val backupManager: app.closer.data.backup.BackupManager,
private val retentionAnalytics: app.closer.analytics.RetentionAnalytics
) : ViewModel() {
private val _uiState = MutableStateFlow(HomeUiState())
@ -241,6 +243,10 @@ class HomeViewModel @Inject constructor(
private var coupleStateListener: com.google.firebase.firestore.ListenerRegistration? = null
private var partnerAnswerListener: com.google.firebase.firestore.ListenerRegistration? = null
// partner_answered fires once per date per VM lifetime (the listener re-fires on every
// snapshot, incl. the initial load — without this it would spam on each Home visit).
private var partnerAnsweredEventDate: String? = null
init {
loadHome()
observeAnswers()
@ -568,6 +574,16 @@ class HomeViewModel @Inject constructor(
fun consumeReminderSentEvent() = _uiState.update { it.copy(reminderSentEvent = false) }
/** Fired when the user taps "Keep the conversation going" after tonight's reveal. */
fun onFollowUpOpened() {
val coupleId = _uiState.value.coupleId ?: return
retentionAnalytics.track(
app.closer.analytics.RetentionEvent.FollowUpQuestionOpened(
coupleIdHash = app.closer.analytics.AnalyticsHashing.hashForAnalytics(coupleId)
)
)
}
/**
* Send a "thinking of you 💜" nudge to the partner via the callable (rate-limited + quiet-hours-aware
* server-side). Fails gracefully: maps the error to a friendly one-shot message, never crashes, and
@ -654,6 +670,14 @@ class HomeViewModel @Inject constructor(
// so reveal-ready reflects this question rather than "any answer exists for today" —
// the daily question can rotate. See refreshDailyQuestionState().
val partnerQuestionId = snapshot?.getString("questionId")
if (hasPartnerAnswer && partnerAnsweredEventDate != today) {
partnerAnsweredEventDate = today
retentionAnalytics.track(
app.closer.analytics.RetentionEvent.PartnerAnswered(
coupleIdHash = app.closer.analytics.AnalyticsHashing.hashForAnalytics(cId)
)
)
}
_uiState.update {
it.copy(
hasPartnerAnsweredToday = hasPartnerAnswer,
@ -815,7 +839,7 @@ class HomeViewModel @Inject constructor(
title = "Look back at what you built this week.",
body = "Reveals, answers, and small rituals are summarized for just the two of you.",
cta = "See recap",
target = HomeActionTarget.AnswerHistory,
target = HomeActionTarget.WeeklyRecap,
tone = HomeActionTone.Reflection
)

View File

@ -71,6 +71,7 @@ import app.closer.ui.components.CloserGlyphs
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AcceptInviteScreen(
initialCode: String = "",
onNavigate: (String) -> Unit = {},
onBack: () -> Unit = {},
viewModel: AcceptInviteViewModel = hiltViewModel()
@ -79,6 +80,9 @@ fun AcceptInviteScreen(
val snackbar = remember { SnackbarHostState() }
val focusManager = LocalFocusManager.current
// Seed a deep-link code (or a code stashed across a sign-up) exactly once.
LaunchedEffect(Unit) { viewModel.onEntered(initialCode) }
LaunchedEffect(state.navigateTo) {
state.navigateTo?.let { onNavigate(it); viewModel.onNavigated() }
}
@ -117,6 +121,11 @@ fun AcceptInviteScreen(
) {
Spacer(Modifier.height(24.dp))
if (state.alreadyPaired) {
AlreadyPairedNotice(onGoHome = { onNavigate(AppRoute.HOME) })
return@Column
}
Text(
"Enter the code",
style = MaterialTheme.typography.headlineSmall,
@ -187,6 +196,38 @@ fun AcceptInviteScreen(
}
}
@Composable
private fun AlreadyPairedNotice(onGoHome: () -> Unit) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
"You're already paired 💜",
style = MaterialTheme.typography.headlineSmall,
color = SettingsInk,
textAlign = TextAlign.Center,
fontWeight = FontWeight.SemiBold
)
Spacer(Modifier.height(8.dp))
Text(
"This account is already connected with your partner, so there's no invite to accept.",
style = MaterialTheme.typography.bodyMedium,
color = SettingsMuted,
textAlign = TextAlign.Center
)
Spacer(Modifier.height(24.dp))
Button(
onClick = onGoHome,
modifier = Modifier.fillMaxWidth().heightIn(min = 52.dp),
shape = RoundedCornerShape(16.dp),
colors = ButtonDefaults.buttonColors(
containerColor = SettingsPrimary,
contentColor = SettingsOnPrimary
)
) {
Text("Go to Home", style = MaterialTheme.typography.labelLarge)
}
}
}
@Composable
private fun InviteCodeEntryCard(
value: String,

View File

@ -2,7 +2,12 @@ package app.closer.ui.pairing
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import app.closer.analytics.RetentionAnalytics
import app.closer.analytics.RetentionEvent
import app.closer.core.navigation.AppRoute
import app.closer.data.local.PendingJoinCodeStore
import app.closer.domain.repository.AuthRepository
import app.closer.domain.repository.CoupleRepository
import app.closer.domain.repository.InviteRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
@ -16,20 +21,52 @@ data class AcceptInviteUiState(
val code: String = "",
val isLoading: Boolean = false,
val error: String? = null,
val navigateTo: String? = null
val navigateTo: String? = null,
/** True once we've confirmed this account is already in a couple — show a friendly wall. */
val alreadyPaired: Boolean = false
)
@HiltViewModel
class AcceptInviteViewModel @Inject constructor(
private val inviteRepository: InviteRepository
private val inviteRepository: InviteRepository,
private val authRepository: AuthRepository,
private val coupleRepository: CoupleRepository,
private val pendingJoinCodeStore: PendingJoinCodeStore,
private val retentionAnalytics: RetentionAnalytics
) : ViewModel() {
private val _uiState = MutableStateFlow(AcceptInviteUiState())
val uiState: StateFlow<AcceptInviteUiState> = _uiState.asStateFlow()
/**
* Seeds the screen with a code arriving from an invite deep link. Falls back to a code
* stashed by [PendingJoinCodeStore] (survives a process death during sign-up). Also
* detects the already-paired account so a tapped link shows a clear message instead of a
* confusing pairing attempt. Called once from the screen with the nav-arg code.
*/
fun onEntered(initialCode: String) {
viewModelScope.launch {
// Already in a couple? A tapped invite link shouldn't try to re-pair.
val uid = authRepository.currentUserId
if (uid != null && coupleRepository.getCoupleForUser(uid) != null) {
pendingJoinCodeStore.clear()
_uiState.update { it.copy(alreadyPaired = true) }
return@launch
}
val fromLink = initialCode.ifBlank { pendingJoinCodeStore.peek().orEmpty() }
if (fromLink.isNotBlank()) {
pendingJoinCodeStore.clear()
retentionAnalytics.track(RetentionEvent.InviteLinkOpened())
_uiState.update {
if (it.code.isBlank()) it.copy(code = normalize(fromLink)) else it
}
}
}
}
fun updateCode(raw: String) {
val filtered = raw.uppercase().filter { it in CODE_CHARS }.take(6)
_uiState.update { it.copy(code = filtered, error = null) }
_uiState.update { it.copy(code = normalize(raw), error = null) }
}
fun lookupCode() {
@ -50,6 +87,8 @@ class AcceptInviteViewModel @Inject constructor(
fun onNavigated() = _uiState.update { it.copy(navigateTo = null) }
fun dismissError() = _uiState.update { it.copy(error = null) }
private fun normalize(raw: String) = raw.uppercase().filter { it in CODE_CHARS }.take(6)
companion object {
private const val CODE_CHARS = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
}

View File

@ -88,9 +88,8 @@ fun CreateInviteScreen(
}
val formattedCode = state.inviteCode?.chunked(3)?.joinToString(" ")
val shareMessage = state.inviteCode?.let { code ->
"Join me on Closer! Here's my invite code: ${code.chunked(3).joinToString(" - ")}"
}
// Code-only share text (link + raw code). Never includes state.recoveryPhrase.
val shareMessage = state.inviteCode?.let { code -> InviteShareText.build(code) }
Scaffold(
snackbarHost = { SnackbarHost(snackbar) },
@ -200,11 +199,12 @@ fun CreateInviteScreen(
shareMessage?.let { message ->
Button(
onClick = {
viewModel.onInviteShared()
val intent = Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
putExtra(Intent.EXTRA_TEXT, message)
}
val chooser = Intent.createChooser(intent, "Share invite code")
val chooser = Intent.createChooser(intent, "Share invite")
context.startActivity(chooser)
},
modifier = Modifier.weight(1f).height(52.dp),

View File

@ -3,6 +3,8 @@ package app.closer.ui.pairing
import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import app.closer.analytics.RetentionAnalytics
import app.closer.analytics.RetentionEvent
import app.closer.core.navigation.AppRoute
import app.closer.domain.repository.AuthRepository
import app.closer.domain.repository.CoupleRepository
@ -29,7 +31,8 @@ data class CreateInviteUiState(
class CreateInviteViewModel @Inject constructor(
private val authRepository: AuthRepository,
private val inviteRepository: InviteRepository,
private val coupleRepository: CoupleRepository
private val coupleRepository: CoupleRepository,
private val retentionAnalytics: RetentionAnalytics
) : ViewModel() {
private val _uiState = MutableStateFlow(CreateInviteUiState())
@ -65,6 +68,9 @@ class CreateInviteViewModel @Inject constructor(
}
}
/** The inviter opened the share sheet — top of the acquisition funnel. */
fun onInviteShared() = retentionAnalytics.track(RetentionEvent.InviteShared())
fun onNavigated() = _uiState.update { it.copy(navigateTo = null) }
fun dismissError() = _uiState.update { it.copy(error = null) }

View File

@ -0,0 +1,17 @@
package app.closer.ui.pairing
import app.closer.core.navigation.JoinLink
/**
* Builds the invite share message. Pure + unit-tested critically, it carries ONLY the
* invite code (as a tappable link plus the raw code fallback) and MUST NEVER include the
* recovery phrase shown on the same screen (asserted in InviteShareTextTest).
*/
object InviteShareText {
fun build(code: String): String {
val pretty = code.chunked(3).joinToString(" ")
return "Let's try Closer together 💜 It's our own private space to actually talk. " +
"Tap to join me: ${JoinLink.webUrl(code)}\n\n" +
"Or enter this code in the app: $pretty"
}
}

View File

@ -27,8 +27,10 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.TextButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
@ -85,6 +87,8 @@ data class PairingSuccessUiState(
val myPhotoUrl: String = "",
val partnerName: String = "",
val partnerPhotoUrl: String = "",
/** >0 shows the "bring your solo answers along?" consent prompt. */
val soloAnswerCount: Int = 0,
val navigateTo: String? = null
)
@ -93,7 +97,8 @@ class PairingSuccessViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val authRepository: AuthRepository,
private val userRepository: UserRepository,
private val coupleRepository: CoupleRepository
private val coupleRepository: CoupleRepository,
private val soloAnswerMigrator: app.closer.domain.usecase.SoloAnswerMigrator
) : ViewModel() {
private val coupleId: String = savedStateHandle["coupleId"] ?: ""
@ -101,6 +106,9 @@ class PairingSuccessViewModel @Inject constructor(
val uiState: StateFlow<PairingSuccessUiState> = _uiState.asStateFlow()
init {
viewModelScope.launch {
_uiState.update { it.copy(soloAnswerCount = runCatching { soloAnswerMigrator.pendingCount() }.getOrDefault(0)) }
}
viewModelScope.launch {
val myId = authRepository.currentUserId ?: return@launch
val me = runCatching { userRepository.getUser(myId) }.getOrNull()
@ -129,6 +137,22 @@ class PairingSuccessViewModel @Inject constructor(
fun proceed() = _uiState.update { it.copy(navigateTo = AppRoute.HOME) }
fun onNavigated() = _uiState.update { it.copy(navigateTo = null) }
/** "Bring them along": move the solo answers into Couple Lore, then dismiss the prompt. */
fun keepSoloAnswers() {
viewModelScope.launch {
runCatching { soloAnswerMigrator.migrateToLore(coupleId) }
_uiState.update { it.copy(soloAnswerCount = 0) }
}
}
/** "Start fresh": discard the solo answers. */
fun discardSoloAnswers() {
viewModelScope.launch {
runCatching { soloAnswerMigrator.discard() }
_uiState.update { it.copy(soloAnswerCount = 0) }
}
}
}
// ── Screen ────────────────────────────────────────────────────────────────────
@ -145,6 +169,27 @@ fun PairingSuccessScreen(
state.navigateTo?.let { onNavigate(it); viewModel.onNavigated() }
}
if (state.soloAnswerCount > 0) {
val n = state.soloAnswerCount
AlertDialog(
onDismissRequest = { /* require an explicit choice */ },
title = { Text(if (n == 1) "Bring your answer along?" else "Bring your $n answers along?") },
text = {
Text(
"While you waited, you answered privately. Want to add " +
(if (n == 1) "it" else "them") +
" to your shared history with ${state.partnerName.ifBlank { "your partner" }}?"
)
},
confirmButton = {
TextButton(onClick = { viewModel.keepSoloAnswers() }) { Text("Bring them along") }
},
dismissButton = {
TextButton(onClick = { viewModel.discardSoloAnswers() }) { Text("Start fresh") }
}
)
}
val pulse by rememberInfiniteTransition(label = "heart").animateFloat(
initialValue = 0.92f,
targetValue = 1.12f,

View File

@ -43,6 +43,7 @@ import app.closer.ui.components.CloserButtonStyle
import app.closer.ui.components.CloserClickableCard
import app.closer.ui.components.CloserElevations
import app.closer.ui.components.CloserPill
import app.closer.ui.components.UnpairedLockedCard
import app.closer.ui.components.CloserRadii
import app.closer.ui.theme.closerBackgroundBrush
import app.closer.ui.theme.closerPlayCardBrush
@ -107,6 +108,15 @@ private fun PlayHubContent(
}
}
if (!isPaired) {
item {
UnpairedLockedCard(
onInvite = { onNavigate(AppRoute.CREATE_INVITE) },
body = "Games are made for two. Invite your partner and you'll both unlock everything here."
)
}
}
item {
FeaturedPlayCard(
onClick = { onPlay(AppRoute.SPIN_WHEEL_RANDOM) }

View File

@ -0,0 +1,137 @@
package app.closer.ui.recap
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawingPadding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import app.closer.core.navigation.AppRoute
import app.closer.domain.model.WeeklyRecap
import app.closer.ui.components.CloserActionButton
import app.closer.ui.components.CloserButtonStyle
import app.closer.ui.components.CloserHeartLoader
import app.closer.ui.theme.closerBackgroundBrush
@Composable
fun WeeklyRecapScreen(
onNavigate: (String) -> Unit = {},
viewModel: WeeklyRecapViewModel = hiltViewModel()
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
val context = LocalContext.current
Column(
modifier = Modifier
.fillMaxSize()
.background(closerBackgroundBrush())
.safeDrawingPadding()
.navigationBarsPadding()
.verticalScroll(rememberScrollState())
.padding(horizontal = 22.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Spacer(Modifier.height(20.dp))
Text(
text = "Your week together",
style = MaterialTheme.typography.displaySmall.copy(fontWeight = FontWeight.SemiBold),
color = MaterialTheme.colorScheme.onBackground
)
val recap = state.recap
when {
state.isLoading -> {
Spacer(Modifier.height(40.dp))
CloserHeartLoader(size = 28.dp)
}
recap == null || recap.questionsAnswered == 0 -> {
Text(
text = "A quiet week — no answers yet. Tonight's question is a good place to start again.",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
CloserActionButton(
label = "Answer tonight's question",
onClick = { onNavigate(AppRoute.DAILY_QUESTION) },
modifier = Modifier.fillMaxWidth()
)
}
else -> {
RecapStats(recap)
recap.strongestRhythmCopy?.let {
Text(
text = it,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
CloserActionButton(
label = "Share our week 💜",
onClick = { WeeklyRecapShareCard.share(context, recap) },
modifier = Modifier.fillMaxWidth()
)
CloserActionButton(
label = recap.ctaCopy,
onClick = { onNavigate(AppRoute.QUESTION_PACKS) },
style = CloserButtonStyle.Secondary,
modifier = Modifier.fillMaxWidth()
)
}
}
Spacer(Modifier.height(24.dp))
}
}
@Composable
private fun RecapStats(recap: WeeklyRecap) {
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth()) {
StatTile("Answered", recap.questionsAnswered, Modifier.weight(1f))
StatTile("Reveals", recap.revealsOpened, Modifier.weight(1f))
}
recap.favoriteCategoryDisplay?.let {
Text(
text = "You spent the most time in $it.",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface
)
}
}
@Composable
private fun StatTile(label: String, value: Int, modifier: Modifier = Modifier) {
Surface(
modifier = modifier,
shape = RoundedCornerShape(20.dp),
color = MaterialTheme.colorScheme.primaryContainer,
contentColor = MaterialTheme.colorScheme.onPrimaryContainer
) {
Column(
modifier = Modifier.padding(vertical = 22.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
Text(value.toString(), style = MaterialTheme.typography.displaySmall.copy(fontWeight = FontWeight.Bold))
Text(label, style = MaterialTheme.typography.labelLarge)
}
}
}

View File

@ -0,0 +1,111 @@
package app.closer.ui.recap
import android.content.ClipData
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.LinearGradient
import android.graphics.Paint
import android.graphics.Shader
import android.graphics.Typeface
import androidx.core.content.FileProvider
import app.closer.domain.model.WeeklyRecap
import java.io.File
/**
* A content-free, shareable "our week" card. Carries ONLY counts (questions answered, reveals
* opened, favorite category display name) never question or answer text so sharing it can't
* leak anything private. Doubles as an organic acquisition surface.
*/
object WeeklyRecapShareCard {
/**
* Pure share caption (counts only). Kept separate + testable so a regression that tried to put
* answer text in the share sheet fails a unit test rather than shipping.
*/
fun buildShareText(recap: WeeklyRecap): String {
val q = recap.questionsAnswered
val r = recap.revealsOpened
val answered = when (q) {
0 -> "We showed up"
1 -> "We answered 1 question"
else -> "We answered $q questions"
}
val reveals = if (r > 0) " and opened $r reveal${if (r == 1) "" else "s"}" else ""
return "$answered$reveals together this week on Closer 💜"
}
/** Renders the card to a bitmap using plain Canvas (no Compose-capture fragility). */
fun renderBitmap(recap: WeeklyRecap): Bitmap {
val w = 1080
val h = 1080
val bmp = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888)
val canvas = Canvas(bmp)
val bg = Paint().apply {
shader = LinearGradient(
0f, 0f, w.toFloat(), h.toFloat(),
Color.parseColor("#7C4DFF"), Color.parseColor("#B98AF4"),
Shader.TileMode.CLAMP
)
}
canvas.drawRect(0f, 0f, w.toFloat(), h.toFloat(), bg)
val title = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.WHITE
textSize = 64f
typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
textAlign = Paint.Align.CENTER
}
val big = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.WHITE
textSize = 220f
typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
textAlign = Paint.Align.CENTER
}
val label = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.parseColor("#F0E9FF")
textSize = 48f
textAlign = Paint.Align.CENTER
}
val cx = w / 2f
canvas.drawText("Our week", cx, 260f, title)
canvas.drawText(recap.questionsAnswered.toString(), cx, 620f, big)
canvas.drawText(
if (recap.questionsAnswered == 1) "question answered together" else "questions answered together",
cx, 700f, label
)
if (recap.revealsOpened > 0) {
canvas.drawText("${recap.revealsOpened} reveals opened", cx, 800f, label)
}
recap.favoriteCategoryDisplay?.let {
canvas.drawText("Most time in $it", cx, 880f, label)
}
canvas.drawText("Closer", cx, 1000f, title.apply { textSize = 44f })
return bmp
}
/** Renders + writes the card to the app cache and fires a share chooser. */
fun share(context: Context, recap: WeeklyRecap) {
val bmp = renderBitmap(recap)
// cacheDir root is covered by the FileProvider's cache-path (file_paths.xml).
val file = File(context.cacheDir, "recap_share.png")
file.outputStream().use { bmp.compress(Bitmap.CompressFormat.PNG, 100, it) }
val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
val intent = Intent(Intent.ACTION_SEND).apply {
type = "image/png"
putExtra(Intent.EXTRA_STREAM, uri)
putExtra(Intent.EXTRA_TEXT, buildShareText(recap))
// clipData carries the URI grant to the chooser's own preview loader (without it the
// share sheet thumbnail is blank, even though the file still transfers to the target).
clipData = ClipData.newUri(context.contentResolver, "Our week", uri)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
context.startActivity(Intent.createChooser(intent, "Share our week"))
}
}

View File

@ -0,0 +1,97 @@
package app.closer.ui.recap
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import app.closer.analytics.RetentionAnalytics
import app.closer.analytics.RetentionEvent
import app.closer.domain.WeeklyRecapGenerator
import app.closer.domain.model.WeeklyRecap
import app.closer.domain.model.WeeklyRecapAnswer
import app.closer.domain.model.WeeklyRecapInput
import app.closer.domain.model.WeeklyRecapReveal
import app.closer.domain.repository.AuthRepository
import app.closer.domain.repository.LocalAnswerRepository
import app.closer.domain.repository.QuestionRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneId
import javax.inject.Inject
data class WeeklyRecapUiState(
val isLoading: Boolean = true,
val recap: WeeklyRecap? = null
)
/**
* Backs the Weekly Recap screen. Assembles a [WeeklyRecapInput] from the accurate,
* on-device answer history (answers, reveals, categories) and runs the pure
* [WeeklyRecapGenerator]. Fuller couple-wide metrics (games/challenges/dates/capsules) are a
* tracked follow-on; the generator zeroes any event type it isn't given, and the screen renders
* only the tiles it has real data for, so nothing false is asserted.
*/
@HiltViewModel
class WeeklyRecapViewModel @Inject constructor(
private val authRepository: AuthRepository,
private val localAnswerRepository: LocalAnswerRepository,
private val questionRepository: QuestionRepository,
private val retentionAnalytics: RetentionAnalytics,
) : ViewModel() {
private val _uiState = MutableStateFlow(WeeklyRecapUiState())
val uiState: StateFlow<WeeklyRecapUiState> = _uiState.asStateFlow()
init {
retentionAnalytics.track(RetentionEvent.WeeklyRecapOpened())
load()
}
private fun load() {
viewModelScope.launch {
val zone = ZoneId.systemDefault()
val today = LocalDate.now(zone)
val weekStart = today.minusDays(6)
val userId = authRepository.currentUserId ?: ""
val answers = runCatching { localAnswerRepository.observeAnswers().first() }.getOrDefault(emptyList())
val categoryTitles = runCatching {
questionRepository.getCategories().associate { it.id to it.displayName }
}.getOrDefault(emptyMap())
val recapAnswers = answers.map { a ->
WeeklyRecapAnswer(
userId = userId,
questionId = a.questionId,
categoryId = a.category.ifBlank { null },
localDate = a.localDate(zone),
isRevealed = a.isRevealed,
hasText = !a.writtenText.isNullOrBlank()
)
}
val recapReveals = answers.filter { it.isRevealed }
.map { WeeklyRecapReveal(localDate = it.localDate(zone)) }
val recap = WeeklyRecapGenerator.generate(
WeeklyRecapInput(
weekStart = weekStart,
weekEnd = today,
answers = recapAnswers,
reveals = recapReveals,
categoryTitles = categoryTitles
)
)
_uiState.update { it.copy(isLoading = false, recap = recap) }
}
}
private fun app.closer.domain.model.LocalAnswer.localDate(zone: ZoneId): LocalDate =
runCatching { LocalDate.parse(answerDate) }.getOrElse {
Instant.ofEpochMilli(updatedAt).atZone(zone).toLocalDate()
}
}

View File

@ -26,6 +26,7 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
@ -34,7 +35,10 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.platform.LocalUriHandler
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
@ -46,9 +50,11 @@ import app.closer.ui.components.CloserGlyphs
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PrivacyScreen(
onNavigate: (String) -> Unit = {}
onNavigate: (String) -> Unit = {},
viewModel: PrivacyViewModel? = if (LocalInspectionMode.current) null else hiltViewModel()
) {
val uriHandler = LocalUriHandler.current
val analyticsEnabled = viewModel?.analyticsEnabled?.collectAsStateWithLifecycle()?.value ?: true
Scaffold(
containerColor = Color.Transparent,
@ -165,6 +171,49 @@ fun PrivacyScreen(
}
}
// ── Anonymous usage data (consent toggle) ─────────────────────────
PrivacySectionHeader(
icon = CloserGlyphs.Check,
iconTint = SettingsPrimary,
title = stringResource(R.string.privacy_usage_data_section)
)
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(containerColor = SettingsCard)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(3.dp)
) {
Text(
text = stringResource(R.string.privacy_usage_data_title),
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
color = SettingsInk
)
Text(
text = stringResource(R.string.privacy_usage_data_body),
style = MaterialTheme.typography.bodySmall,
color = SettingsMuted
)
}
Switch(
checked = analyticsEnabled,
onCheckedChange = { viewModel?.setAnalyticsEnabled(it) },
colors = settingsSwitchColors()
)
}
}
// ── Deleting your account ─────────────────────────────────────────
PrivacySectionHeader(
icon = CloserGlyphs.EyeOff,

View File

@ -0,0 +1,31 @@
package app.closer.ui.settings
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import app.closer.domain.repository.SettingsRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import javax.inject.Inject
/**
* Backs the Settings Privacy screen's "share anonymous usage data" toggle.
* The persisted flag is applied to Firebase + the retention sink by
* [app.closer.analytics.AnalyticsConsentApplier].
*/
@HiltViewModel
class PrivacyViewModel @Inject constructor(
private val settingsRepository: SettingsRepository,
) : ViewModel() {
val analyticsEnabled: StateFlow<Boolean> = settingsRepository.settings
.map { it.analyticsEnabled }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), true)
fun setAnalyticsEnabled(enabled: Boolean) {
viewModelScope.launch { settingsRepository.setAnalyticsEnabled(enabled) }
}
}

View File

@ -93,6 +93,9 @@
<string name="privacy_no_export">No data export</string>
<string name="privacy_no_export_body">Closer does not currently offer a data export. Your answers and history are deleted along with your account.</string>
<string name="privacy_rule_intro">Closer is built on one rule: answers stay private until both of you have answered. Here\'s exactly what that means.</string>
<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>

View File

@ -0,0 +1,89 @@
package app.closer.analytics
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import kotlin.reflect.full.primaryConstructor
/**
* Mechanically enforces the standing rule: retention analytics carry ONLY safe metadata
* never answer text, prompts, names, or any other content field.
*
* The schema check reflects over every [RetentionEvent] subclass, so adding a new event
* with a content-shaped field (e.g. `answerText`) fails this test immediately.
*/
class NoPlaintextInAnalyticsTest {
/** The only constructor parameters an event may declare. */
private val allowedParams = setOf("categoryId", "coupleIdHash", "timestamp")
@Test
fun `every retention event declares only the safe metadata fields`() {
val subclasses = RetentionEvent::class.sealedSubclasses
assertTrue("expected the sealed hierarchy to be non-empty", subclasses.isNotEmpty())
subclasses.forEach { subclass ->
val params = subclass.primaryConstructor!!.parameters.mapNotNull { it.name }.toSet()
val illegal = params - allowedParams
assertTrue(
"${subclass.simpleName} declares non-metadata constructor params: $illegal" +
"content must never ride on analytics events",
illegal.isEmpty()
)
}
}
@Test
fun `every event type maps to a valid GA4 event name`() {
RetentionEventType.entries.forEach { type ->
val name = type.name.lowercase()
assertTrue(
"event name '$name' must be snake_case (GA4 requirement)",
name.matches(Regex("^[a-z][a-z0-9_]*$"))
)
assertTrue(
"event name '$name' exceeds GA4's 40-char limit",
name.length <= 40
)
}
}
@Test
fun `hashing is one-way, deterministic, and never echoes the input`() {
val sensitive = "how-do-you-feel-about-intimacy-q-123"
val hashed = AnalyticsHashing.hashForAnalytics(sensitive)
assertNotEquals(sensitive, hashed)
assertTrue("hash must not contain the raw value", !hashed.contains(sensitive))
assertTrue("hash is 16 lowercase hex chars", hashed.matches(Regex("^[0-9a-f]{16}$")))
assertEquals(hashed, AnalyticsHashing.hashForAnalytics(sensitive))
assertEquals("empty", AnalyticsHashing.hashForAnalytics(" "))
}
@Test
fun `coarse category buckets intimate categories away from their raw names`() {
listOf("sex", "desire", "physical_intimacy", "rebuilding_trust").forEach {
assertEquals("intimacy", AnalyticsHashing.coarseCategory(it))
}
assertEquals("other", AnalyticsHashing.coarseCategory("some_new_category"))
}
@Test
fun `composite isolates a throwing sink and never propagates to the caller`() {
val received = mutableListOf<RetentionEvent>()
val throwing = object : RetentionAnalytics {
override fun track(event: RetentionEvent) = throw IllegalStateException("sink down")
override fun setEnabled(enabled: Boolean) = throw IllegalStateException("sink down")
}
val recording = object : RetentionAnalytics {
override fun track(event: RetentionEvent) { received.add(event) }
override fun setEnabled(enabled: Boolean) {}
}
val composite = CompositeRetentionAnalytics(listOf(throwing, recording))
// Must not throw, and the healthy sink must still receive the event.
composite.track(RetentionEvent.GameStarted(categoryId = "wheel"))
composite.setEnabled(false)
assertEquals(1, received.size)
}
}

View File

@ -0,0 +1,59 @@
package app.closer.core.navigation
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class JoinLinkTest {
@Test
fun `parses a valid https join link`() {
assertEquals("ABC234", JoinLink.parseCode("https://closer.app/join/ABC234"))
}
@Test
fun `parses the custom-scheme fallback`() {
assertEquals("ABC234", JoinLink.parseCode("closer://closer.app/join/ABC234"))
}
@Test
fun `normalizes case to uppercase`() {
assertEquals("ABC234", JoinLink.parseCode("https://closer.app/join/abc234"))
}
@Test
fun `rejects a wrong host`() {
assertNull(JoinLink.parseCode("https://evil.example/join/ABC234"))
}
@Test
fun `rejects a non-join path`() {
assertNull(JoinLink.parseCode("https://closer.app/home"))
assertNull(JoinLink.parseCode("https://closer.app/join"))
}
@Test
fun `rejects a wrong-length code`() {
assertNull(JoinLink.parseCode("https://closer.app/join/ABC"))
assertNull(JoinLink.parseCode("https://closer.app/join/ABC2345"))
}
@Test
fun `rejects codes with characters outside the invite alphabet`() {
// I, O, 0, 1 are excluded from the invite alphabet.
assertNull(JoinLink.parseCode("https://closer.app/join/ABC0O1"))
}
@Test
fun `returns null for blank, null, and non-URI input`() {
assertNull(JoinLink.parseCode(null))
assertNull(JoinLink.parseCode(""))
assertNull(JoinLink.parseCode("not a uri at all"))
}
@Test
fun `webUrl round-trips through parseCode`() {
val url = JoinLink.webUrl("XYZ789")
assertEquals("XYZ789", JoinLink.parseCode(url))
}
}

View File

@ -0,0 +1,75 @@
package app.closer.domain.usecase
import app.closer.data.remote.FirestoreAnswerDataSource
import app.closer.domain.model.LocalAnswer
import app.closer.domain.repository.LocalAnswerRepository
import io.mockk.Runs
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.just
import io.mockk.mockk
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Test
class SoloAnswerMigratorTest {
private val local = mockk<LocalAnswerRepository>(relaxed = true)
private val answers = mockk<FirestoreAnswerDataSource>(relaxed = true)
private val migrator = SoloAnswerMigrator(local, answers)
private fun written(id: String, text: String) = LocalAnswer(
questionId = id, questionText = "Q $id", category = "fun",
answerType = "written", writtenText = text, answerDate = "2026-07-06"
)
@Test
fun `migrates written solo answers into lore and clears them`() = runTest {
coEvery { local.observeAnswers() } returns flowOf(listOf(written("q1", "hi"), written("q2", "yo")))
coEvery { answers.saveLoreEntry(any(), any(), any(), any(), any(), any(), any()) } just Runs
val moved = migrator.migrateToLore("couple1")
assertEquals(2, moved)
coVerify(exactly = 1) { answers.saveLoreEntry("couple1", "q1", "Q q1", "hi", null, null, "2026-07-06") }
coVerify { local.deleteAnswer("q1") }
coVerify { local.deleteAnswer("q2") }
}
@Test
fun `a failed lore write keeps that answer local for a later retry`() = runTest {
coEvery { local.observeAnswers() } returns flowOf(listOf(written("q1", "hi")))
coEvery { answers.saveLoreEntry(any(), any(), any(), any(), any(), any(), any()) } throws
IllegalStateException("couple key not ready")
val moved = migrator.migrateToLore("couple1")
assertEquals(0, moved)
coVerify(exactly = 0) { local.deleteAnswer(any()) }
}
@Test
fun `answers with no usable content are skipped`() = runTest {
val empty = LocalAnswer(
questionId = "q9", questionText = "Q", category = "fun",
answerType = "written", writtenText = null, answerDate = "2026-07-06"
)
coEvery { local.observeAnswers() } returns flowOf(listOf(empty))
assertEquals(0, migrator.migrateToLore("couple1"))
coVerify(exactly = 0) { answers.saveLoreEntry(any(), any(), any(), any(), any(), any(), any()) }
}
@Test
fun `discard clears all solo answers without writing lore`() = runTest {
coEvery { local.observeAnswers() } returns flowOf(listOf(written("q1", "hi"), written("q2", "yo")))
coEvery { local.deleteAnswer(any()) } just Runs
migrator.discard()
coVerify { local.deleteAnswer("q1") }
coVerify { local.deleteAnswer("q2") }
coVerify(exactly = 0) { answers.saveLoreEntry(any(), any(), any(), any(), any(), any(), any()) }
}
}

View File

@ -0,0 +1,36 @@
package app.closer.ui.pairing
import app.closer.core.navigation.JoinLink
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class InviteShareTextTest {
@Test
fun `share text contains a tappable join link and the raw code`() {
val text = InviteShareText.build("ABC234")
assertTrue("must contain the https join link", text.contains(JoinLink.webUrl("ABC234")))
assertTrue("must contain the human-typeable code", text.contains("ABC 234"))
}
@Test
fun `the link in the share text parses back to the same code`() {
val text = InviteShareText.build("XYZ789")
val url = text.lines().flatMap { it.split(" ") }.first { it.startsWith("https://") }
assertEquals("XYZ789", JoinLink.parseCode(url))
}
@Test
fun `share text never contains anything phrase-like`() {
// Guard against a future edit accidentally interpolating the recovery phrase.
// The recovery phrase is multiple lowercase dictionary words; the share text must
// stay limited to the code + link + fixed copy.
val phrase = "correct horse battery staple ocean velvet"
val text = InviteShareText.build("ABC234")
phrase.split(" ").forEach { word ->
assertFalse("share text must not contain phrase word '$word'", text.contains(word))
}
}
}

View File

@ -0,0 +1,48 @@
package app.closer.ui.recap
import app.closer.domain.model.WeeklyRecap
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import java.time.LocalDate
class WeeklyRecapShareCardTest {
private fun recap(q: Int, r: Int, fav: String? = null) = WeeklyRecap(
weekStart = LocalDate.of(2026, 6, 29),
weekEnd = LocalDate.of(2026, 7, 5),
questionsAnswered = q,
revealsOpened = r,
favoriteCategoryDisplay = fav
)
@Test
fun `share text reports counts only`() {
val text = WeeklyRecapShareCard.buildShareText(recap(q = 5, r = 3))
assertTrue(text.contains("5 questions"))
assertTrue(text.contains("3 reveals"))
assertTrue(text.contains("Closer"))
}
@Test
fun `singular grammar for one reveal`() {
val text = WeeklyRecapShareCard.buildShareText(recap(q = 1, r = 1))
assertTrue(text.contains("1 question"))
assertTrue(text.contains("1 reveal"))
assertFalse(text.contains("1 reveals"))
}
@Test
fun `omits reveals clause when none`() {
val text = WeeklyRecapShareCard.buildShareText(recap(q = 2, r = 0))
assertFalse(text.contains("reveal"))
}
@Test
fun `never leaks a category name or answer text into the share caption`() {
// The caption is counts + fixed copy only; even a favorite category (which IS shown in-app)
// must not ride into the shared text, since the share surface leaves the couple's control.
val text = WeeklyRecapShareCard.buildShareText(recap(q = 4, r = 2, fav = "Physical intimacy"))
assertFalse("category name must not appear in share text", text.contains("intimacy", ignoreCase = true))
}
}

View File

@ -1163,6 +1163,43 @@ SCRIPTS.md
These are bugs that cost real debugging time and are easy to re-introduce if you don't know they existed. Before changing the relevant area, re-read the linked fix commit and the QA report entry. Format: **ID** — what it was — where it lives now.
### INVITE-DEEPLINK — join links resolve through MainActivity, not NavHost navDeepLink (onboarding gate)
**What it is**: `https://closer.app/join/{CODE}` (autoVerify App Link) + `closer://closer.app/join/{CODE}`
(scheme fallback). `JoinLink.parseCode()` (pure, JVM-tested) extracts + validates the code;
`MainActivity.deepLinkRouteFromIntent` stashes it in `PendingJoinCodeStore` (survives sign-up process
death) and returns the `accept_invite?code=…` route, driven by the existing `pendingDeepLink` machinery.
**Re-introduction hazards**:
- **Do NOT add a `navDeepLink` for the join URI on the `accept_invite` composable.** The whole point of
routing through MainActivity is that `pendingDeepLink` waits until the user is past onboarding
(`currentRoute !in entryRoutes`). A NavHost navDeepLink would let the NavController jump to the pairing
screen while the user is still signed out / mid-signup — the destination can't load and the tap appears
to do nothing.
- `deepLinkRouteFromIntent` must keep parsing join links **before** the `if (intent.data != null) return null`
line, and that line must stay so all OTHER inbound `closer://` URIs (our own foreground FCM PendingIntents)
still fall through to the NavHost's per-route navDeepLinks. The order is load-bearing.
- **There are TWO stores**: `PendingInviteStore` (inviter's encrypted code + recovery phrase) vs
`PendingJoinCodeStore` (invitee's plain code from a link). Don't merge them — the invitee has no phrase.
- Share text is built by `InviteShareText.build()` and must carry the code ONLY (link + raw code), never the
recovery phrase shown on the same screen — `InviteShareTextTest` asserts this.
- **Owner action before App Links verify**: host `/.well-known/assetlinks.json` with the release cert
SHA-256, plus a `/join/{code}` web landing page (Play Store redirect when the app isn't installed). Until
then the https link still works via the app chooser; the `closer://` scheme always works.
### APPCHECK-TOKEN — debug token sourced from local.properties (never hardcode; post-enforcement build requirement)
**What it is**: the App Check debug token used to be hardcoded in `app/build.gradle.kts` (committed → anyone with
repo history holds a console-registered token, defeating App Check the moment enforcement turns on). It now resolves
via the `secret()` helper in `app/build.gradle.kts`: **local.properties → -P/gradle.properties → env**, empty when
absent; `FirebaseInitializer.seedDebugToken()` skips seeding on blank, so tokenless builds (CI) mint their own
unregistered token per install.
**Re-introduction hazards**:
- Never put the token (or any secret) back in a committed file. The historical value `e2dc8256-…` must be treated
as burned — **rotate it in Firebase Console before enabling App Check enforcement**, or enforcement is hollow.
- **After enforcement is ON**: only debug builds from machines whose `local.properties` carries the (rotated)
`APP_CHECK_DEBUG_TOKEN` can reach Firestore. QA emulator rounds must use locally-built APKs; a CI-built APK
(empty token) will be denied — expected, not a bug.
- `local.properties` is NOT auto-loaded by Gradle; the explicit `Properties()` load at the top of
`app/build.gradle.kts` is what makes it work. Don't "simplify" it back to `properties[...]`/`findProperty`.
### R24-BACKUP — E2EE conversation backup + full partner-assisted restore (design + re-introduction hazards)
**What it is**: devices keep a couple-key-encrypted backup of all conversations so a new/wiped device can restore
history, and a partner can fully restore for the other (key + content, no phrase). Files:

View File

@ -81,6 +81,10 @@ const COUPLE_DOC = {
kdfParams: "argon2id",
};
const CIPHERTEXT = "enc:v1:YWJj";
// Sealed partner-proof answer shapes (schemaVersion 3): sealed:v1: + ≥80 URL-safe base64
// chars; commitment = sha256: + exactly 43 URL-safe base64 chars (32-byte digest).
const SEALED_PAYLOAD = "sealed:v1:" + "Ab1-_".repeat(20); // 100 chars
const COMMITMENT_HASH = "sha256:" + "A".repeat(43);
/** Seed documents that rules' helper functions need (e.g. isCouplesMember reads the couple). */
async function seedCouple() {
@ -184,15 +188,47 @@ describe("users/{uid}/entitlements/{doc}", () => {
// ── users/{uid}/notification_queue/{id} ──────────────────────────────────────
describe("users/{uid}/notification_queue/{id}", () => {
test("no client read — denied", async () => {
await assertFails(
getDoc(
doc(alice().firestore(), `users/${UID_A}/notification_queue/notif1`)
)
// The owner may read their own activity feed and flip a notification's `read`
// flag; creates/deletes stay server-only.
test("owner can read own notifications — allowed", async () => {
await testEnv.withSecurityRulesDisabled(async (ctx) => {
await setDoc(doc(ctx.firestore(), `users/${UID_A}/notification_queue/notif1`), {
type: "partner_answered",
read: false,
});
});
await assertSucceeds(
getDoc(doc(alice().firestore(), `users/${UID_A}/notification_queue/notif1`))
);
});
test("no client write — denied", async () => {
test("other user cannot read — denied", async () => {
await testEnv.withSecurityRulesDisabled(async (ctx) => {
await setDoc(doc(ctx.firestore(), `users/${UID_A}/notification_queue/notif1`), {
type: "partner_answered",
read: false,
});
});
await assertFails(
getDoc(doc(bob().firestore(), `users/${UID_A}/notification_queue/notif1`))
);
});
test("owner can flip only the read flag — allowed", async () => {
await testEnv.withSecurityRulesDisabled(async (ctx) => {
await setDoc(doc(ctx.firestore(), `users/${UID_A}/notification_queue/notif1`), {
type: "partner_answered",
read: false,
});
});
await assertSucceeds(
updateDoc(doc(alice().firestore(), `users/${UID_A}/notification_queue/notif1`), {
read: true,
})
);
});
test("no client create — denied", async () => {
await assertFails(
setDoc(
doc(alice().firestore(), `users/${UID_A}/notification_queue/notif1`),
@ -245,22 +281,9 @@ describe("invites/{code}", () => {
});
}
test("inviter can create valid invite — allowed", async () => {
await assertSucceeds(
setDoc(doc(alice().firestore(), `invites/${INVITE_CODE}`), {
inviterUserId: UID_A,
code: INVITE_CODE,
status: "pending",
createdAt: Timestamp.now(),
expiresAt,
wrappedCoupleKey: "wrapped-key",
kdfSalt: "salt",
kdfParams: "argon2id",
})
);
});
test("inviter cannot set coupleId on create — denied", async () => {
// Invite creation moved server-side (createInviteCallable): 6-char codes are
// enumerable, so ALL direct client writes are denied regardless of shape.
test("client cannot create invite even with a valid shape (server-only) — denied", async () => {
await assertFails(
setDoc(doc(alice().firestore(), `invites/${INVITE_CODE}`), {
inviterUserId: UID_A,
@ -271,7 +294,6 @@ describe("invites/{code}", () => {
wrappedCoupleKey: "wrapped-key",
kdfSalt: "salt",
kdfParams: "argon2id",
coupleId: "injected",
})
);
});
@ -402,86 +424,30 @@ describe("couples/{coupleId}", () => {
await assertFails(deleteDoc(doc(alice().firestore(), `couples/${COUPLE_ID}`)));
});
describe("encryption migration", () => {
const legacyCouple = {
id: COUPLE_ID,
userIds: [UID_A, UID_B],
inviteCode: "ABC123",
createdAt: 1_000_000,
streakCount: 0,
lastAnsweredAt: null,
encryptionVersion: 0,
};
test("a member can start encryption migration — allowed", async () => {
await testEnv.withSecurityRulesDisabled(async (ctx) => {
await setDoc(doc(ctx.firestore(), `couples/${COUPLE_ID}`), legacyCouple);
});
// The v0→v1→v2 client-side migration flow is gone: couples are created at
// encryptionVersion 2 server-side, and the only key-material update a client may
// make is the recovery-wrap re-key (wrappedCoupleKey/kdfSalt/kdfParams together).
describe("recovery wrap re-key", () => {
test("a member can re-wrap the couple key — allowed", async () => {
await assertSucceeds(updateDoc(doc(alice().firestore(), `couples/${COUPLE_ID}`), {
wrappedCoupleKey: "wrapped-key",
kdfSalt: "salt",
kdfParams: "argon2id",
encryptionVersion: 1,
encryptionMigrationUsers: {},
wrappedCoupleKey: "new-wrapped-key",
kdfSalt: "new-salt",
kdfParams: "argon2id-v2",
}));
});
test("a member can mark only their own migration complete — allowed", async () => {
await testEnv.withSecurityRulesDisabled(async (ctx) => {
const versionOne = { ...COUPLE_DOC, encryptionVersion: 1 };
delete (versionOne as Record<string, unknown>).encryptionMigrationUsers;
await setDoc(doc(ctx.firestore(), `couples/${COUPLE_ID}`), versionOne);
});
await assertSucceeds(updateDoc(doc(alice().firestore(), `couples/${COUPLE_ID}`), {
encryptionVersion: 1,
encryptionMigrationUsers: { [UID_A]: true },
}));
});
test("a member cannot claim their partner migrated — denied", async () => {
await testEnv.withSecurityRulesDisabled(async (ctx) => {
await setDoc(doc(ctx.firestore(), `couples/${COUPLE_ID}`), {
...COUPLE_DOC,
encryptionVersion: 1,
encryptionMigrationUsers: {},
});
});
test("a member cannot change encryptionVersion alongside the wrap — denied", async () => {
await assertFails(updateDoc(doc(alice().firestore(), `couples/${COUPLE_ID}`), {
encryptionVersion: 1,
encryptionMigrationUsers: { [UID_B]: true },
wrappedCoupleKey: "new-wrapped-key",
kdfSalt: "new-salt",
kdfParams: "argon2id-v2",
encryptionVersion: 3,
}));
});
test("version 2 requires both partners to complete migration — denied", async () => {
await testEnv.withSecurityRulesDisabled(async (ctx) => {
await setDoc(doc(ctx.firestore(), `couples/${COUPLE_ID}`), {
...COUPLE_DOC,
encryptionVersion: 1,
encryptionMigrationUsers: {},
});
});
test("a member cannot downgrade encryptionVersion — denied", async () => {
await assertFails(updateDoc(doc(alice().firestore(), `couples/${COUPLE_ID}`), {
encryptionVersion: 2,
encryptionMigrationUsers: { [UID_A]: true },
}));
});
test("the second partner can complete migration and promote to version 2 — allowed", async () => {
await testEnv.withSecurityRulesDisabled(async (ctx) => {
await setDoc(doc(ctx.firestore(), `couples/${COUPLE_ID}`), {
...COUPLE_DOC,
encryptionVersion: 1,
encryptionMigrationUsers: { [UID_A]: true },
});
});
await assertSucceeds(updateDoc(doc(bob().firestore(), `couples/${COUPLE_ID}`), {
encryptionVersion: 2,
encryptionMigrationUsers: { [UID_A]: true, [UID_B]: true },
encryptionVersion: 0,
}));
});
});
@ -700,13 +666,19 @@ describe("couples/{coupleId}/question_threads/{threadId}", () => {
describe("answers/{userId}", () => {
const ANSWER_PATH = `${THREAD_PATH}/answers/${UID_A}`;
test("owner can write own answer — allowed", async () => {
// Thread answers are sealed partner-proof (schemaVersion 3): the content rides in
// an encryptedPayload with a commitment hash; plaintext content fields are rejected
// by the create shape's hasOnly.
test("owner can write own sealed answer — allowed", async () => {
await assertSucceeds(
setDoc(doc(alice().firestore(), ANSWER_PATH), {
userId: UID_A,
questionId: "q1",
answerType: "written",
writtenText: CIPHERTEXT,
encryptedPayload: SEALED_PAYLOAD,
commitmentHash: COMMITMENT_HASH,
schemaVersion: 3,
answerKeyReleased: false,
createdAt: serverTimestamp(),
updatedAt: serverTimestamp(),
})
@ -940,21 +912,23 @@ describe("couples/{coupleId}/date_swipes/{dateIdeaId}", () => {
beforeEach(seedCouple);
test("member can write own swipe action — allowed", async () => {
// Swipe actions are E2E ciphertext (the server can't read date preferences) and
// swipedAt is an epoch-millis number.
test("member can write own encrypted swipe action — allowed", async () => {
await assertSucceeds(
setDoc(doc(alice().firestore(), SWIPE_PATH), {
actions: {
[UID_A]: { action: "love", swipedAt: Timestamp.now() },
[UID_A]: { action: CIPHERTEXT, swipedAt: Date.now() },
},
})
);
});
test("invalid swipe action value — denied", async () => {
test("plaintext swipe action — denied", async () => {
await assertFails(
setDoc(doc(alice().firestore(), SWIPE_PATH), {
actions: {
[UID_A]: { action: "hate", swipedAt: Timestamp.now() },
[UID_A]: { action: "love", swipedAt: Date.now() },
},
})
);
@ -964,7 +938,7 @@ describe("couples/{coupleId}/date_swipes/{dateIdeaId}", () => {
await assertFails(
setDoc(doc(alice().firestore(), SWIPE_PATH), {
actions: {
[UID_B]: { action: "love", swipedAt: Timestamp.now() },
[UID_B]: { action: CIPHERTEXT, swipedAt: Date.now() },
},
})
);
@ -1065,8 +1039,9 @@ describe("couples/{coupleId}/bucket_list/{itemId}", () => {
beforeEach(seedCouple);
// Bucket-list user content (title/description) is E2E ciphertext.
const VALID_ITEM = {
title: "Skydiving",
title: CIPHERTEXT,
addedBy: UID_A,
addedAt: Timestamp.now(),
isCompleted: false,
@ -1089,6 +1064,12 @@ describe("couples/{coupleId}/bucket_list/{itemId}", () => {
);
});
test("plaintext title — denied", async () => {
await assertFails(
setDoc(doc(alice().firestore(), ITEM_PATH), { ...VALID_ITEM, title: "Skydiving" })
);
});
test("member can mark item complete (own completedBy) — allowed", async () => {
await testEnv.withSecurityRulesDisabled(async (ctx) => {
await setDoc(doc(ctx.firestore(), ITEM_PATH), VALID_ITEM);
@ -1163,14 +1144,31 @@ describe("couples/{coupleId}/daily_question/{date}", () => {
});
});
// Couple-key daily answers (schemaVersion 2): the answer doc is metadata-only
// (content lives in the read-gated `secure` subdoc); answerDate must match the
// path date so metadata can't disagree with where the doc lands.
const VALID_DAILY_ANSWER = {
userId: UID_A,
questionId: "q42",
answerType: "written",
schemaVersion: 2,
answerDate: "2026-06-19",
isRevealed: false,
createdAt: serverTimestamp(),
updatedAt: serverTimestamp(),
};
test("owner can create own daily answer — allowed", async () => {
await assertSucceeds(
setDoc(doc(alice().firestore(), ANSWER_PATH), VALID_DAILY_ANSWER)
);
});
test("answerDate must match the path date — denied", async () => {
await assertFails(
setDoc(doc(alice().firestore(), ANSWER_PATH), {
userId: UID_A,
questionId: "q42",
answerType: "written",
createdAt: serverTimestamp(),
updatedAt: serverTimestamp(),
...VALID_DAILY_ANSWER,
answerDate: "2026-06-20",
})
);
});
@ -1178,11 +1176,8 @@ describe("couples/{coupleId}/daily_question/{date}", () => {
test("owner cannot create answer for another user's path — denied", async () => {
await assertFails(
setDoc(doc(alice().firestore(), `${DQ_PATH}/answers/${UID_B}`), {
...VALID_DAILY_ANSWER,
userId: UID_B,
questionId: "q42",
answerType: "written",
createdAt: serverTimestamp(),
updatedAt: serverTimestamp(),
})
);
});

View File

@ -1,2 +1,19 @@
# Android SDK location (replace with your actual path)
sdk.dir=/home/kaspa/Android/Sdk
# ── Secrets (never committed; local.properties is gitignored) ──────────────
# Resolution order in app/build.gradle.kts: local.properties → -P/gradle.properties → env.
# RevenueCat public SDK key (required for release builds; debug falls back to a placeholder)
#RC_API_KEY=goog_xxxxxxxxxxxxxxxx
# App Check debug token (Firebase Console > App Check > Apps > Manage debug tokens).
# Debug-only; when absent (e.g. CI) the build uses an empty value and each install
# mints its own unregistered token.
#APP_CHECK_DEBUG_TOKEN=00000000-0000-0000-0000-000000000000
# Release signing (required for assembleRelease/bundleRelease)
#RELEASE_STORE_FILE=keystore/closer-release.jks
#RELEASE_STORE_PASSWORD=...
#RELEASE_KEY_ALIAS=...
#RELEASE_KEY_PASSWORD=...

View File

@ -32,4 +32,4 @@ NODE_PATH=functions/node_modules node qa/qa_push.js <uid> chat_message conversat
## Test couple (current emulators)
- coupleId `Xal3Kw3gjSdn0niERYKJ`
- QA (emulator-5554) `Y05AKO2IlTPMa0JQW1BiNIM0uzK2` · Sam (emulator-5556) `imDjjOTTQvXGGjyUhUc5JSeHWkU2`
- Admin SA JSON: `closer-app-22014-firebase-adminsdk-fbsvc-ed20bf6003.json` (gitignored; override path via `SA_JSON`).
- 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`.

View File

@ -13,9 +13,12 @@
// Env overrides: SA_JSON, COUPLE_ID.
const admin = require('firebase-admin')
const path = require('path')
const os = require('os')
// Admin SA key lives OUTSIDE the repo (never keep live credentials in an agent-accessed
// working tree). Override with SA_JSON if yours is elsewhere.
const SA = process.env.SA_JSON ||
path.join(__dirname, '..', 'closer-app-22014-firebase-adminsdk-fbsvc-ed20bf6003.json')
path.join(os.homedir(), '.config', 'closer', 'closer-admin-sa.json')
const COUPLE = process.env.COUPLE_ID || 'Xal3Kw3gjSdn0niERYKJ'
admin.initializeApp({ credential: admin.credential.cert(require(SA)) })