From 55699e17ed41225bfd9e7fab85b325853bfd9d49 Mon Sep 17 00:00:00 2001 From: null Date: Mon, 6 Jul 2026 20:33:38 -0500 Subject: [PATCH] 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 --- .github/ci/google-services.json | 30 +++ .github/workflows/android-ci.yml | 76 ++++++ .github/workflows/backend-ci.yml | 68 ++++++ .gitleaksignore | 16 ++ Future.md | 21 ++ app/build.gradle.kts | 36 ++- app/src/main/AndroidManifest.xml | 17 ++ app/src/main/java/app/closer/CloserApp.kt | 5 + app/src/main/java/app/closer/MainActivity.kt | 30 ++- .../analytics/AnalyticsConsentApplier.kt | 32 +++ .../app/closer/analytics/AnalyticsHashing.kt | 47 ++++ .../analytics/CompositeRetentionAnalytics.kt | 18 ++ .../analytics/FirebaseRetentionAnalytics.kt | 46 ++++ .../closer/analytics/RetentionAnalytics.kt | 14 +- .../app/closer/analytics/RetentionEvent.kt | 28 +++ .../analytics/FirebaseAnalyticsTracker.kt | 39 +--- .../closer/core/navigation/AppNavigation.kt | 19 +- .../app/closer/core/navigation/AppRoute.kt | 7 + .../app/closer/core/navigation/JoinLink.kt | 45 ++++ .../closer/data/local/PendingJoinCodeStore.kt | 36 +++ .../closer/data/local/SettingsDataStore.kt | 7 +- .../data/remote/FirestoreCapsuleDataSource.kt | 16 +- .../remote/FirestoreChallengeDataSource.kt | 26 ++- .../java/app/closer/di/AnalyticsModule.kt | 24 +- .../domain/repository/SettingsRepository.kt | 9 +- .../domain/usecase/GameSessionManager.kt | 34 ++- .../domain/usecase/SoloAnswerMigrator.kt | 65 ++++++ .../ui/components/UnpairedLockedCard.kt | 56 +++++ .../app/closer/ui/dates/DateMatchViewModel.kt | 15 +- .../java/app/closer/ui/home/HomeScreen.kt | 9 +- .../java/app/closer/ui/home/HomeViewModel.kt | 30 ++- .../closer/ui/pairing/AcceptInviteScreen.kt | 41 ++++ .../ui/pairing/AcceptInviteViewModel.kt | 47 +++- .../closer/ui/pairing/CreateInviteScreen.kt | 8 +- .../ui/pairing/CreateInviteViewModel.kt | 8 +- .../app/closer/ui/pairing/InviteShareText.kt | 17 ++ .../closer/ui/pairing/PairingSuccessScreen.kt | 47 +++- .../java/app/closer/ui/play/PlayHubScreen.kt | 10 + .../app/closer/ui/recap/WeeklyRecapScreen.kt | 137 +++++++++++ .../closer/ui/recap/WeeklyRecapShareCard.kt | 111 +++++++++ .../closer/ui/recap/WeeklyRecapViewModel.kt | 97 ++++++++ .../app/closer/ui/settings/PrivacyScreen.kt | 51 +++- .../closer/ui/settings/PrivacyViewModel.kt | 31 +++ app/src/main/res/values/strings.xml | 3 + .../analytics/NoPlaintextInAnalyticsTest.kt | 89 +++++++ .../closer/core/navigation/JoinLinkTest.kt | 59 +++++ .../domain/usecase/SoloAnswerMigratorTest.kt | 75 ++++++ .../closer/ui/pairing/InviteShareTextTest.kt | 36 +++ .../ui/recap/WeeklyRecapShareCardTest.kt | 48 ++++ docs/Engineering_Reference_Manual.md | 37 +++ firestore-tests/rules.test.ts | 217 +++++++++--------- local.properties.example | 17 ++ qa/README.md | 2 +- qa/qa_push.js | 5 +- 54 files changed, 1918 insertions(+), 196 deletions(-) create mode 100644 .github/ci/google-services.json create mode 100644 .github/workflows/android-ci.yml create mode 100644 .github/workflows/backend-ci.yml create mode 100644 .gitleaksignore create mode 100644 app/src/main/java/app/closer/analytics/AnalyticsConsentApplier.kt create mode 100644 app/src/main/java/app/closer/analytics/AnalyticsHashing.kt create mode 100644 app/src/main/java/app/closer/analytics/CompositeRetentionAnalytics.kt create mode 100644 app/src/main/java/app/closer/analytics/FirebaseRetentionAnalytics.kt create mode 100644 app/src/main/java/app/closer/core/navigation/JoinLink.kt create mode 100644 app/src/main/java/app/closer/data/local/PendingJoinCodeStore.kt create mode 100644 app/src/main/java/app/closer/domain/usecase/SoloAnswerMigrator.kt create mode 100644 app/src/main/java/app/closer/ui/components/UnpairedLockedCard.kt create mode 100644 app/src/main/java/app/closer/ui/pairing/InviteShareText.kt create mode 100644 app/src/main/java/app/closer/ui/recap/WeeklyRecapScreen.kt create mode 100644 app/src/main/java/app/closer/ui/recap/WeeklyRecapShareCard.kt create mode 100644 app/src/main/java/app/closer/ui/recap/WeeklyRecapViewModel.kt create mode 100644 app/src/main/java/app/closer/ui/settings/PrivacyViewModel.kt create mode 100644 app/src/test/java/app/closer/analytics/NoPlaintextInAnalyticsTest.kt create mode 100644 app/src/test/java/app/closer/core/navigation/JoinLinkTest.kt create mode 100644 app/src/test/java/app/closer/domain/usecase/SoloAnswerMigratorTest.kt create mode 100644 app/src/test/java/app/closer/ui/pairing/InviteShareTextTest.kt create mode 100644 app/src/test/java/app/closer/ui/recap/WeeklyRecapShareCardTest.kt diff --git a/.github/ci/google-services.json b/.github/ci/google-services.json new file mode 100644 index 00000000..5f44be75 --- /dev/null +++ b/.github/ci/google-services.json @@ -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" +} diff --git a/.github/workflows/android-ci.yml b/.github/workflows/android-ci.yml new file mode 100644 index 00000000..bbb96637 --- /dev/null +++ b/.github/workflows/android-ci.yml @@ -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 diff --git a/.github/workflows/backend-ci.yml b/.github/workflows/backend-ci.yml new file mode 100644 index 00000000..e11a4b7a --- /dev/null +++ b/.github/workflows/backend-ci.yml @@ -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" diff --git a/.gitleaksignore b/.gitleaksignore new file mode 100644 index 00000000..ff4eb874 --- /dev/null +++ b/.gitleaksignore @@ -0,0 +1,16 @@ +# Known historical findings, reviewed 2026-07-06 — all benign, baselined so CI stays strict +# on anything NEW. Format: :::. +# +# 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 diff --git a/Future.md b/Future.md index aab07b9a..2b22a871 100644 --- a/Future.md +++ b/Future.md @@ -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 diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 917ecbdc..e2c5586a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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 diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index e2574e4f..8a9e2bcb 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -33,6 +33,23 @@ + + + + + + + + + + + + + + + + + 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) + ) } /** diff --git a/app/src/main/java/app/closer/analytics/AnalyticsConsentApplier.kt b/app/src/main/java/app/closer/analytics/AnalyticsConsentApplier.kt new file mode 100644 index 00000000..3b2cb2d7 --- /dev/null +++ b/app/src/main/java/app/closer/analytics/AnalyticsConsentApplier.kt @@ -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) } + } + } +} diff --git a/app/src/main/java/app/closer/analytics/AnalyticsHashing.kt b/app/src/main/java/app/closer/analytics/AnalyticsHashing.kt new file mode 100644 index 00000000..d3e79393 --- /dev/null +++ b/app/src/main/java/app/closer/analytics/AnalyticsHashing.kt @@ -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" + } + } +} diff --git a/app/src/main/java/app/closer/analytics/CompositeRetentionAnalytics.kt b/app/src/main/java/app/closer/analytics/CompositeRetentionAnalytics.kt new file mode 100644 index 00000000..a83fe8fb --- /dev/null +++ b/app/src/main/java/app/closer/analytics/CompositeRetentionAnalytics.kt @@ -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 { + + override fun track(event: RetentionEvent) { + sinks.forEach { sink -> runCatching { sink.track(event) } } + } + + override fun setEnabled(enabled: Boolean) { + sinks.forEach { sink -> runCatching { sink.setEnabled(enabled) } } + } +} diff --git a/app/src/main/java/app/closer/analytics/FirebaseRetentionAnalytics.kt b/app/src/main/java/app/closer/analytics/FirebaseRetentionAnalytics.kt new file mode 100644 index 00000000..a1bf62c8 --- /dev/null +++ b/app/src/main/java/app/closer/analytics/FirebaseRetentionAnalytics.kt @@ -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 + } +} diff --git a/app/src/main/java/app/closer/analytics/RetentionAnalytics.kt b/app/src/main/java/app/closer/analytics/RetentionAnalytics.kt index 2993e15e..780b4b59 100644 --- a/app/src/main/java/app/closer/analytics/RetentionAnalytics.kt +++ b/app/src/main/java/app/closer/analytics/RetentionAnalytics.kt @@ -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" - } - } } diff --git a/app/src/main/java/app/closer/analytics/RetentionEvent.kt b/app/src/main/java/app/closer/analytics/RetentionEvent.kt index 0259bcfc..6a1fe229 100644 --- a/app/src/main/java/app/closer/analytics/RetentionEvent.kt +++ b/app/src/main/java/app/closer/analytics/RetentionEvent.kt @@ -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, + ) } diff --git a/app/src/main/java/app/closer/core/analytics/FirebaseAnalyticsTracker.kt b/app/src/main/java/app/closer/core/analytics/FirebaseAnalyticsTracker.kt index d3d7c9b1..d7eb4345 100644 --- a/app/src/main/java/app/closer/core/analytics/FirebaseAnalyticsTracker.kt +++ b/app/src/main/java/app/closer/core/analytics/FirebaseAnalyticsTracker.kt @@ -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) { diff --git a/app/src/main/java/app/closer/core/navigation/AppNavigation.kt b/app/src/main/java/app/closer/core/navigation/AppNavigation.kt index c6543b2d..1bad6f8e 100644 --- a/app/src/main/java/app/closer/core/navigation/AppNavigation.kt +++ b/app/src/main/java/app/closer/core/navigation/AppNavigation.kt @@ -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 ) diff --git a/app/src/main/java/app/closer/core/navigation/AppRoute.kt b/app/src/main/java/app/closer/core/navigation/AppRoute.kt index 2df883c6..56c72dd7 100644 --- a/app/src/main/java/app/closer/core/navigation/AppRoute.kt +++ b/app/src/main/java/app/closer/core/navigation/AppRoute.kt @@ -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()}" diff --git a/app/src/main/java/app/closer/core/navigation/JoinLink.kt b/app/src/main/java/app/closer/core/navigation/JoinLink.kt new file mode 100644 index 00000000..13ef1164 --- /dev/null +++ b/app/src/main/java/app/closer/core/navigation/JoinLink.kt @@ -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 } } + } +} diff --git a/app/src/main/java/app/closer/data/local/PendingJoinCodeStore.kt b/app/src/main/java/app/closer/data/local/PendingJoinCodeStore.kt new file mode 100644 index 00000000..3e2bf370 --- /dev/null +++ b/app/src/main/java/app/closer/data/local/PendingJoinCodeStore.kt @@ -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 +) { + 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) } + } +} diff --git a/app/src/main/java/app/closer/data/local/SettingsDataStore.kt b/app/src/main/java/app/closer/data/local/SettingsDataStore.kt index ece566fe..51b7984c 100644 --- a/app/src/main/java/app/closer/data/local/SettingsDataStore.kt +++ b/app/src/main/java/app/closer/data/local/SettingsDataStore.kt @@ -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 = 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 {} } diff --git a/app/src/main/java/app/closer/data/remote/FirestoreCapsuleDataSource.kt b/app/src/main/java/app/closer/data/remote/FirestoreCapsuleDataSource.kt index 19a7248d..94e217b4 100644 --- a/app/src/main/java/app/closer/data/remote/FirestoreCapsuleDataSource.kt +++ b/app/src/main/java/app/closer/data/remote/FirestoreCapsuleDataSource.kt @@ -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) { diff --git a/app/src/main/java/app/closer/data/remote/FirestoreChallengeDataSource.kt b/app/src/main/java/app/closer/data/remote/FirestoreChallengeDataSource.kt index 2ada4f68..0ad43d5c 100644 --- a/app/src/main/java/app/closer/data/remote/FirestoreChallengeDataSource.kt +++ b/app/src/main/java/app/closer/data/remote/FirestoreChallengeDataSource.kt @@ -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>() ) ).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) { diff --git a/app/src/main/java/app/closer/di/AnalyticsModule.kt b/app/src/main/java/app/closer/di/AnalyticsModule.kt index b582e10d..718fd22f 100644 --- a/app/src/main/java/app/closer/di/AnalyticsModule.kt +++ b/app/src/main/java/app/closer/di/AnalyticsModule.kt @@ -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 + } } diff --git a/app/src/main/java/app/closer/domain/repository/SettingsRepository.kt b/app/src/main/java/app/closer/domain/repository/SettingsRepository.kt index bd936e2c..b062ae4f 100644 --- a/app/src/main/java/app/closer/domain/repository/SettingsRepository.kt +++ b/app/src/main/java/app/closer/domain/repository/SettingsRepository.kt @@ -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) } diff --git a/app/src/main/java/app/closer/domain/usecase/GameSessionManager.kt b/app/src/main/java/app/closer/domain/usecase/GameSessionManager.kt index 2845781b..5302dae8 100644 --- a/app/src/main/java/app/closer/domain/usecase/GameSessionManager.kt +++ b/app/src/main/java/app/closer/domain/usecase/GameSessionManager.kt @@ -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 { 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) + ) + ) + } } /** diff --git a/app/src/main/java/app/closer/domain/usecase/SoloAnswerMigrator.kt b/app/src/main/java/app/closer/domain/usecase/SoloAnswerMigrator.kt new file mode 100644 index 00000000..8b97b67e --- /dev/null +++ b/app/src/main/java/app/closer/domain/usecase/SoloAnswerMigrator.kt @@ -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 + } +} diff --git a/app/src/main/java/app/closer/ui/components/UnpairedLockedCard.kt b/app/src/main/java/app/closer/ui/components/UnpairedLockedCard.kt new file mode 100644 index 00000000..b6408257 --- /dev/null +++ b/app/src/main/java/app/closer/ui/components/UnpairedLockedCard.kt @@ -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() + ) + } + } +} diff --git a/app/src/main/java/app/closer/ui/dates/DateMatchViewModel.kt b/app/src/main/java/app/closer/ui/dates/DateMatchViewModel.kt index 665d9aa2..17ebb976 100644 --- a/app/src/main/java/app/closer/ui/dates/DateMatchViewModel.kt +++ b/app/src/main/java/app/closer/ui/dates/DateMatchViewModel.kt @@ -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. diff --git a/app/src/main/java/app/closer/ui/home/HomeScreen.kt b/app/src/main/java/app/closer/ui/home/HomeScreen.kt index d6c581f8..3a2bd583 100644 --- a/app/src/main/java/app/closer/ui/home/HomeScreen.kt +++ b/app/src/main/java/app/closer/ui/home/HomeScreen.kt @@ -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 diff --git a/app/src/main/java/app/closer/ui/home/HomeViewModel.kt b/app/src/main/java/app/closer/ui/home/HomeViewModel.kt index d78e63c5..7795567d 100644 --- a/app/src/main/java/app/closer/ui/home/HomeViewModel.kt +++ b/app/src/main/java/app/closer/ui/home/HomeViewModel.kt @@ -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 ) diff --git a/app/src/main/java/app/closer/ui/pairing/AcceptInviteScreen.kt b/app/src/main/java/app/closer/ui/pairing/AcceptInviteScreen.kt index b18021ea..5f0ba3b9 100644 --- a/app/src/main/java/app/closer/ui/pairing/AcceptInviteScreen.kt +++ b/app/src/main/java/app/closer/ui/pairing/AcceptInviteScreen.kt @@ -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, diff --git a/app/src/main/java/app/closer/ui/pairing/AcceptInviteViewModel.kt b/app/src/main/java/app/closer/ui/pairing/AcceptInviteViewModel.kt index 3ea284ed..ed6ec5ff 100644 --- a/app/src/main/java/app/closer/ui/pairing/AcceptInviteViewModel.kt +++ b/app/src/main/java/app/closer/ui/pairing/AcceptInviteViewModel.kt @@ -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 = _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" } diff --git a/app/src/main/java/app/closer/ui/pairing/CreateInviteScreen.kt b/app/src/main/java/app/closer/ui/pairing/CreateInviteScreen.kt index d480e0b5..9ca8343e 100644 --- a/app/src/main/java/app/closer/ui/pairing/CreateInviteScreen.kt +++ b/app/src/main/java/app/closer/ui/pairing/CreateInviteScreen.kt @@ -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), diff --git a/app/src/main/java/app/closer/ui/pairing/CreateInviteViewModel.kt b/app/src/main/java/app/closer/ui/pairing/CreateInviteViewModel.kt index 6aeeccb8..7c06f98e 100644 --- a/app/src/main/java/app/closer/ui/pairing/CreateInviteViewModel.kt +++ b/app/src/main/java/app/closer/ui/pairing/CreateInviteViewModel.kt @@ -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) } diff --git a/app/src/main/java/app/closer/ui/pairing/InviteShareText.kt b/app/src/main/java/app/closer/ui/pairing/InviteShareText.kt new file mode 100644 index 00000000..a8a1b730 --- /dev/null +++ b/app/src/main/java/app/closer/ui/pairing/InviteShareText.kt @@ -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" + } +} diff --git a/app/src/main/java/app/closer/ui/pairing/PairingSuccessScreen.kt b/app/src/main/java/app/closer/ui/pairing/PairingSuccessScreen.kt index 45e42a1f..a6fa60da 100644 --- a/app/src/main/java/app/closer/ui/pairing/PairingSuccessScreen.kt +++ b/app/src/main/java/app/closer/ui/pairing/PairingSuccessScreen.kt @@ -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 = _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, diff --git a/app/src/main/java/app/closer/ui/play/PlayHubScreen.kt b/app/src/main/java/app/closer/ui/play/PlayHubScreen.kt index 3fc5709b..57970b1f 100644 --- a/app/src/main/java/app/closer/ui/play/PlayHubScreen.kt +++ b/app/src/main/java/app/closer/ui/play/PlayHubScreen.kt @@ -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) } diff --git a/app/src/main/java/app/closer/ui/recap/WeeklyRecapScreen.kt b/app/src/main/java/app/closer/ui/recap/WeeklyRecapScreen.kt new file mode 100644 index 00000000..1e31f396 --- /dev/null +++ b/app/src/main/java/app/closer/ui/recap/WeeklyRecapScreen.kt @@ -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) + } + } +} diff --git a/app/src/main/java/app/closer/ui/recap/WeeklyRecapShareCard.kt b/app/src/main/java/app/closer/ui/recap/WeeklyRecapShareCard.kt new file mode 100644 index 00000000..487b44db --- /dev/null +++ b/app/src/main/java/app/closer/ui/recap/WeeklyRecapShareCard.kt @@ -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")) + } +} diff --git a/app/src/main/java/app/closer/ui/recap/WeeklyRecapViewModel.kt b/app/src/main/java/app/closer/ui/recap/WeeklyRecapViewModel.kt new file mode 100644 index 00000000..876c72a2 --- /dev/null +++ b/app/src/main/java/app/closer/ui/recap/WeeklyRecapViewModel.kt @@ -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 = _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() + } +} diff --git a/app/src/main/java/app/closer/ui/settings/PrivacyScreen.kt b/app/src/main/java/app/closer/ui/settings/PrivacyScreen.kt index ab3be704..7ef03ff8 100644 --- a/app/src/main/java/app/closer/ui/settings/PrivacyScreen.kt +++ b/app/src/main/java/app/closer/ui/settings/PrivacyScreen.kt @@ -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, diff --git a/app/src/main/java/app/closer/ui/settings/PrivacyViewModel.kt b/app/src/main/java/app/closer/ui/settings/PrivacyViewModel.kt new file mode 100644 index 00000000..d66c1efc --- /dev/null +++ b/app/src/main/java/app/closer/ui/settings/PrivacyViewModel.kt @@ -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 = settingsRepository.settings + .map { it.analyticsEnabled } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), true) + + fun setAnalyticsEnabled(enabled: Boolean) { + viewModelScope.launch { settingsRepository.setAnalyticsEnabled(enabled) } + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e20dc872..5286b036 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -93,6 +93,9 @@ No data export Closer does not currently offer a data export. Your answers and history are deleted along with your account. Closer is built on one rule: answers stay private until both of you have answered. Here\'s exactly what that means. + Anonymous usage data + Share anonymous usage data + 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. Legal Legal documents Privacy Policy diff --git a/app/src/test/java/app/closer/analytics/NoPlaintextInAnalyticsTest.kt b/app/src/test/java/app/closer/analytics/NoPlaintextInAnalyticsTest.kt new file mode 100644 index 00000000..cc628068 --- /dev/null +++ b/app/src/test/java/app/closer/analytics/NoPlaintextInAnalyticsTest.kt @@ -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() + 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) + } +} diff --git a/app/src/test/java/app/closer/core/navigation/JoinLinkTest.kt b/app/src/test/java/app/closer/core/navigation/JoinLinkTest.kt new file mode 100644 index 00000000..0c4f7808 --- /dev/null +++ b/app/src/test/java/app/closer/core/navigation/JoinLinkTest.kt @@ -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)) + } +} diff --git a/app/src/test/java/app/closer/domain/usecase/SoloAnswerMigratorTest.kt b/app/src/test/java/app/closer/domain/usecase/SoloAnswerMigratorTest.kt new file mode 100644 index 00000000..ba35cdad --- /dev/null +++ b/app/src/test/java/app/closer/domain/usecase/SoloAnswerMigratorTest.kt @@ -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(relaxed = true) + private val answers = mockk(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()) } + } +} diff --git a/app/src/test/java/app/closer/ui/pairing/InviteShareTextTest.kt b/app/src/test/java/app/closer/ui/pairing/InviteShareTextTest.kt new file mode 100644 index 00000000..a7e3b0ed --- /dev/null +++ b/app/src/test/java/app/closer/ui/pairing/InviteShareTextTest.kt @@ -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)) + } + } +} diff --git a/app/src/test/java/app/closer/ui/recap/WeeklyRecapShareCardTest.kt b/app/src/test/java/app/closer/ui/recap/WeeklyRecapShareCardTest.kt new file mode 100644 index 00000000..39d8bfcc --- /dev/null +++ b/app/src/test/java/app/closer/ui/recap/WeeklyRecapShareCardTest.kt @@ -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)) + } +} diff --git a/docs/Engineering_Reference_Manual.md b/docs/Engineering_Reference_Manual.md index 080afd55..ee37c4d4 100644 --- a/docs/Engineering_Reference_Manual.md +++ b/docs/Engineering_Reference_Manual.md @@ -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: diff --git a/firestore-tests/rules.test.ts b/firestore-tests/rules.test.ts index ae133d92..11b98f69 100644 --- a/firestore-tests/rules.test.ts +++ b/firestore-tests/rules.test.ts @@ -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).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(), }) ); }); diff --git a/local.properties.example b/local.properties.example index a4919351..2907f4d4 100644 --- a/local.properties.example +++ b/local.properties.example @@ -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=... diff --git a/qa/README.md b/qa/README.md index ce591c16..cd6b3b75 100644 --- a/qa/README.md +++ b/qa/README.md @@ -32,4 +32,4 @@ NODE_PATH=functions/node_modules node qa/qa_push.js 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`. diff --git a/qa/qa_push.js b/qa/qa_push.js index b4bbe523..abf68c2b 100644 --- a/qa/qa_push.js +++ b/qa/qa_push.js @@ -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)) })