feat: security hardening, CI, analytics, invite deep links, solo mode, weekly recap
Roadmap phases 0-3.1 (see plan). Verified: unit tests, functions tests, firestore rules 119/119, live emulator smokes. Security + CI: - Move App Check debug token out of committed build.gradle.kts to local.properties (secret() resolver: local.properties -> -P -> env); admin service-account key relocated outside the repo. - Add GitHub Actions (android unit/scan/gitleaks, backend functions + rules emulator via emulators:exec) with a CI google-services.json stub and a reviewed .gitleaksignore baseline. - Fix firestore rules tests to the current deployed contract (was 108/118, now 119/119). Analytics + consent: - FirebaseRetentionAnalytics behind a fail-safe composite sink, single- sourced hashing (AnalyticsHashing), Settings -> Privacy opt-out toggle gating Firebase + the sink, and the ~11 missing event call sites. - NoPlaintextInAnalyticsTest guards the content-free rule at build time. Funnel: - Invite deep links (https App Link + closer:// fallback), JoinLink parser, PendingJoinCodeStore, prefilled/already-paired pairing, richer share text. - Solo pre-pairing: UnpairedLockedCard on Play, SoloAnswerMigrator + consent dialog moving pre-pairing answers into Couple Lore. Weekly recap: - WeeklyRecapScreen driven by the existing generator from local answer data, Home card rewired, content-free shareable card. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
f68cab5cf2
commit
55699e17ed
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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"
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
# Known historical findings, reviewed 2026-07-06 — all benign, baselined so CI stays strict
|
||||
# on anything NEW. Format: <commit>:<file>:<rule>:<line>.
|
||||
#
|
||||
# 1-3) scratchpad QA probe scripts embed the Firebase WEB API key (AIzaSyDAD7…). That key is
|
||||
# a project identifier, not a bearer secret (access control = Auth + rules + App Check),
|
||||
# and it ships inside every APK anyway. Owner hardening (optional): add application
|
||||
# restrictions to the key in GCP Console → Credentials.
|
||||
62696a69ea2de499b3b28204bdb4e0ffa9fdfe25:scratchpad/rules_arrays.js:gcp-api-key:8
|
||||
62696a69ea2de499b3b28204bdb4e0ffa9fdfe25:scratchpad/rules_pos.js:gcp-api-key:3
|
||||
38ff166598d6d85145bbeea53e651f7ef0cbc9f6:scratchpad/d3_negative.js:gcp-api-key:6
|
||||
#
|
||||
# 4-6) iOS crypto unit-test fixtures — fixed base64 test vectors (e.g. "test-keybox-
|
||||
# ciphertext-blob"), not credentials.
|
||||
ade4667db723986c2d0185cfa71aa7808d559a8b:iphone/CloserTests/CryptoTests/KeyboxCallableTests.swift:generic-api-key:44
|
||||
ade4667db723986c2d0185cfa71aa7808d559a8b:iphone/CloserTests/CryptoTests/KeyboxCallableTests.swift:generic-api-key:73
|
||||
60c0003114fe0677858b0b2de7331a2a3699f901:iphone/CloserTests/CryptoTests/AES_GCM_KnownVectorTests.swift:generic-api-key:37
|
||||
21
Future.md
21
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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -33,6 +33,23 @@
|
|||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- Invite deep link: verified https App Link (opens the app directly once
|
||||
assetlinks.json is hosted; pre-verification it appears in the app chooser). -->
|
||||
<intent-filter android:autoVerify="true">
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="https" android:host="closer.app" android:pathPrefix="/join/" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- Custom-scheme fallback for contexts that can't launch an App Link. -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="closer" android:host="closer.app" android:pathPrefix="/join/" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<service
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package app.closer
|
||||
|
||||
import android.app.Application
|
||||
import app.closer.analytics.AnalyticsConsentApplier
|
||||
import app.closer.core.firebase.FirebaseInitializer
|
||||
import app.closer.data.repository.ActivityProvider
|
||||
import app.closer.domain.security.DeviceIntegrityChecker
|
||||
|
|
@ -19,6 +20,7 @@ class CloserApp : Application() {
|
|||
|
||||
@Inject lateinit var firebaseInitializer: FirebaseInitializer
|
||||
@Inject lateinit var deviceIntegrityChecker: DeviceIntegrityChecker
|
||||
@Inject lateinit var analyticsConsentApplier: AnalyticsConsentApplier
|
||||
|
||||
private val appScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
|
||||
|
|
@ -30,5 +32,8 @@ class CloserApp : Application() {
|
|||
NotificationChannelSetup.createChannels(applicationContext)
|
||||
firebaseInitializer.initialize()
|
||||
appScope.launch { deviceIntegrityChecker.runCheck() }
|
||||
// Keep Firebase collection + the retention sink in step with the user's
|
||||
// "share anonymous usage data" setting (Settings → Privacy), incl. across restarts.
|
||||
appScope.launch { analyticsConsentApplier.observe() }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,6 +37,8 @@ import android.content.pm.PackageManager
|
|||
import android.os.Build
|
||||
import app.closer.BuildConfig
|
||||
import app.closer.core.navigation.AppNavigation
|
||||
import app.closer.core.navigation.AppRoute
|
||||
import app.closer.core.navigation.JoinLink
|
||||
import app.closer.core.notifications.TokenRegistrar
|
||||
import app.closer.domain.model.AuthState
|
||||
import app.closer.notifications.PartnerNotificationPayload
|
||||
|
|
@ -69,6 +71,8 @@ class MainActivity : AppCompatActivity() {
|
|||
@Inject lateinit var settingsRepository: SettingsRepository
|
||||
@Inject lateinit var authRepository: AuthRepository
|
||||
@Inject lateinit var tokenRegistrar: TokenRegistrar
|
||||
@Inject lateinit var retentionAnalytics: app.closer.analytics.RetentionAnalytics
|
||||
@Inject lateinit var pendingJoinCodeStore: app.closer.data.local.PendingJoinCodeStore
|
||||
|
||||
private val notificationPermissionLauncher =
|
||||
registerForActivityResult(ActivityResultContracts.RequestPermission()) { }
|
||||
|
|
@ -213,7 +217,22 @@ class MainActivity : AppCompatActivity() {
|
|||
// already-resolved app route as an extra. Prefer it so routing never depends on per-route
|
||||
// navDeepLink registration — game/challenge/date/capsule routes aren't registered as Uri
|
||||
// deep links, so those taps used to fall through to Home when the app was foregrounded.
|
||||
intent.getStringExtra("app_route")?.takeIf { it.isNotBlank() }?.let { return it }
|
||||
intent.getStringExtra("app_route")?.takeIf { it.isNotBlank() }?.let { route ->
|
||||
trackPushOpened(intent.getStringExtra("type") ?: "app_route")
|
||||
return route
|
||||
}
|
||||
// Invite deep link (https://closer.app/join/CODE or closer://…/join/CODE). Stash the
|
||||
// code so it survives a process death during sign-up, and route to the pairing screen
|
||||
// prefilled. pendingDeepLink then holds this until the user is past onboarding.
|
||||
JoinLink.parseCode(intent.dataString)?.let { code ->
|
||||
lifecycleScope.launch {
|
||||
runCatching { pendingJoinCodeStore.setPendingCode(code) }
|
||||
.onFailure { Log.w("MainActivity", "Failed to stash invite code", it) }
|
||||
}
|
||||
return AppRoute.acceptInvite(code)
|
||||
}
|
||||
// Any OTHER inbound Uri (our own foreground closer:// PendingIntents) is left for the
|
||||
// NavHost's registered navDeepLinks — do not resolve it from extras.
|
||||
if (intent.data != null) return null
|
||||
val type = intent.getStringExtra("type") ?: return null
|
||||
val coupleId = intent.getStringExtra("couple_id") ?: ""
|
||||
|
|
@ -228,6 +247,15 @@ class MainActivity : AppCompatActivity() {
|
|||
avatarUrl = intent.getStringExtra("sender_avatar_url")
|
||||
)
|
||||
return PartnerNotificationType.fromRemoteType(type)?.routeFor(payload, coupleId)
|
||||
?.also { trackPushOpened(type) }
|
||||
}
|
||||
|
||||
/** A notification tap resolved to a route — the push_notification_opened signal. The
|
||||
* notification `type` (an app-defined constant, never content) rides as categoryId. */
|
||||
private fun trackPushOpened(type: String) {
|
||||
retentionAnalytics.track(
|
||||
app.closer.analytics.RetentionEvent.PushNotificationOpened(categoryId = type)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
package app.closer.analytics
|
||||
|
||||
/**
|
||||
* Fans a [RetentionEvent] out to multiple sinks, isolating each: one sink throwing must
|
||||
* never stop the others or reach the caller (analytics can't break user flows).
|
||||
*/
|
||||
class CompositeRetentionAnalytics(
|
||||
private val sinks: List<RetentionAnalytics>,
|
||||
) : RetentionAnalytics {
|
||||
|
||||
override fun track(event: RetentionEvent) {
|
||||
sinks.forEach { sink -> runCatching { sink.track(event) } }
|
||||
}
|
||||
|
||||
override fun setEnabled(enabled: Boolean) {
|
||||
sinks.forEach { sink -> runCatching { sink.setEnabled(enabled) } }
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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()}"
|
||||
|
|
|
|||
|
|
@ -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 } }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
package app.closer.data.local
|
||||
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Persists ONE invite code captured from a deep link (the INVITEE side), so it survives
|
||||
* process death during a long sign-up/onboarding — the in-memory pendingDeepLink alone does
|
||||
* not. The pairing screen prefills from here, then clears it. Holds nothing but the code.
|
||||
*
|
||||
* Distinct from [PendingInviteStore], which is the INVITER's encrypted code + recovery-phrase
|
||||
* stash. This one has no phrase (the invitee has none yet).
|
||||
*/
|
||||
@Singleton
|
||||
class PendingJoinCodeStore @Inject constructor(
|
||||
private val dataStore: DataStore<Preferences>
|
||||
) {
|
||||
private val key = stringPreferencesKey("pending_join_code")
|
||||
|
||||
suspend fun setPendingCode(code: String) {
|
||||
dataStore.edit { it[key] = code }
|
||||
}
|
||||
|
||||
/** The stashed code, or null if none / already consumed. */
|
||||
suspend fun peek(): String? = dataStore.data.map { it[key] }.first()
|
||||
|
||||
suspend fun clear() {
|
||||
dataStore.edit { it.remove(key) }
|
||||
}
|
||||
}
|
||||
|
|
@ -40,6 +40,7 @@ class SettingsDataStore @Inject constructor(
|
|||
private val BIOMETRIC_LOGIN = booleanPreferencesKey("biometric_login")
|
||||
private val LAST_CELEBRATED_STREAK = intPreferencesKey("last_celebrated_streak_milestone")
|
||||
private val PREMIUM_UNLOCK_CELEBRATED = booleanPreferencesKey("premium_unlock_celebrated")
|
||||
private val ANALYTICS_ENABLED = booleanPreferencesKey("analytics_enabled")
|
||||
|
||||
override val settings: Flow<AppSettings> = dataStore.data.map { prefs ->
|
||||
AppSettings(
|
||||
|
|
@ -63,7 +64,8 @@ class SettingsDataStore @Inject constructor(
|
|||
outcomeLastPromptedDay = prefs[OUTCOME_LAST_PROMPTED_DAY] ?: "",
|
||||
biometricLoginEnabled = prefs[BIOMETRIC_LOGIN] ?: false,
|
||||
lastCelebratedStreakMilestone = prefs[LAST_CELEBRATED_STREAK] ?: 0,
|
||||
premiumUnlockCelebrated = prefs[PREMIUM_UNLOCK_CELEBRATED] ?: false
|
||||
premiumUnlockCelebrated = prefs[PREMIUM_UNLOCK_CELEBRATED] ?: false,
|
||||
analyticsEnabled = prefs[ANALYTICS_ENABLED] ?: true
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -120,4 +122,7 @@ class SettingsDataStore @Inject constructor(
|
|||
|
||||
override suspend fun setPremiumUnlockCelebrated(celebrated: Boolean) =
|
||||
dataStore.edit { it[PREMIUM_UNLOCK_CELEBRATED] = celebrated }.let {}
|
||||
|
||||
override suspend fun setAnalyticsEnabled(enabled: Boolean) =
|
||||
dataStore.edit { it[ANALYTICS_ENABLED] = enabled }.let {}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
package app.closer.data.remote
|
||||
|
||||
import app.closer.analytics.AnalyticsHashing
|
||||
import app.closer.analytics.RetentionAnalytics
|
||||
import app.closer.analytics.RetentionEvent
|
||||
import app.closer.domain.model.ChallengeProgressState
|
||||
import com.google.firebase.firestore.FieldValue
|
||||
import com.google.firebase.firestore.FirebaseFirestore
|
||||
|
|
@ -11,7 +14,10 @@ import javax.inject.Inject
|
|||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class FirestoreChallengeDataSource @Inject constructor(private val db: FirebaseFirestore) {
|
||||
class FirestoreChallengeDataSource @Inject constructor(
|
||||
private val db: FirebaseFirestore,
|
||||
private val retentionAnalytics: RetentionAnalytics
|
||||
) {
|
||||
|
||||
private fun challengeRef(coupleId: String, challengeId: String) =
|
||||
db.collection(FirestoreCollections.COUPLES)
|
||||
|
|
@ -55,16 +61,34 @@ class FirestoreChallengeDataSource @Inject constructor(private val db: FirebaseF
|
|||
"completions" to emptyMap<String, List<Int>>()
|
||||
)
|
||||
).await()
|
||||
retentionAnalytics.track(
|
||||
RetentionEvent.ChallengeStarted(
|
||||
categoryId = challengeId,
|
||||
coupleIdHash = AnalyticsHashing.hashForAnalytics(coupleId)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun markDayComplete(coupleId: String, challengeId: String, userId: String, day: Int) {
|
||||
challengeRef(coupleId, challengeId)
|
||||
.update("completions.$userId", FieldValue.arrayUnion(day))
|
||||
.await()
|
||||
retentionAnalytics.track(
|
||||
RetentionEvent.ChallengeDayCompleted(
|
||||
categoryId = challengeId,
|
||||
coupleIdHash = AnalyticsHashing.hashForAnalytics(coupleId)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun finishChallenge(coupleId: String, challengeId: String) {
|
||||
challengeRef(coupleId, challengeId).update("status", "completed").await()
|
||||
retentionAnalytics.track(
|
||||
RetentionEvent.ChallengeCompleted(
|
||||
categoryId = challengeId,
|
||||
coupleIdHash = AnalyticsHashing.hashForAnalytics(coupleId)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun abandonChallenge(coupleId: String, challengeId: String) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
package app.closer.domain.usecase
|
||||
|
||||
import app.closer.analytics.AnalyticsHashing
|
||||
import app.closer.analytics.RetentionAnalytics
|
||||
import app.closer.analytics.RetentionEvent
|
||||
import app.closer.domain.model.Couple
|
||||
import app.closer.domain.model.GameType
|
||||
import app.closer.domain.model.QuestionSession
|
||||
|
|
@ -30,7 +33,8 @@ class GameSessionManager @Inject constructor(
|
|||
private val sessionRepository: QuestionSessionRepository,
|
||||
private val authRepository: AuthRepository,
|
||||
private val coupleRepository: CoupleRepository,
|
||||
private val userRepository: UserRepository
|
||||
private val userRepository: UserRepository,
|
||||
private val retentionAnalytics: RetentionAnalytics
|
||||
) {
|
||||
val currentUserId: String?
|
||||
get() = authRepository.currentUserId
|
||||
|
|
@ -97,7 +101,17 @@ class GameSessionManager @Inject constructor(
|
|||
return sessionRepository.startSessionAtomically(session).fold(
|
||||
onSuccess = { outcome ->
|
||||
when (outcome) {
|
||||
is SessionStartOutcome.Created -> Result.success(outcome.session)
|
||||
is SessionStartOutcome.Created -> {
|
||||
// All four games start here, so this is the single game_started site.
|
||||
// gameType goes through as categoryId (the sink hashes it like any ID).
|
||||
retentionAnalytics.track(
|
||||
RetentionEvent.GameStarted(
|
||||
categoryId = gameType,
|
||||
coupleIdHash = AnalyticsHashing.hashForAnalytics(couple.id)
|
||||
)
|
||||
)
|
||||
Result.success(outcome.session)
|
||||
}
|
||||
is SessionStartOutcome.AlreadyActive -> {
|
||||
val partnerId = couple.userIds.firstOrNull { it != userId }
|
||||
val partnerName = partnerId
|
||||
|
|
@ -145,6 +159,12 @@ class GameSessionManager @Inject constructor(
|
|||
// couple out of new games. abandonSession performs the correct write. (B-ABANDON-001)
|
||||
// getOrThrow propagates a failed write (otherwise the outer runCatching falsely reports success).
|
||||
sessionRepository.abandonSession(coupleId).getOrThrow()
|
||||
retentionAnalytics.track(
|
||||
RetentionEvent.GameCompleted(
|
||||
categoryId = currentSession.gameType,
|
||||
coupleIdHash = AnalyticsHashing.hashForAnalytics(coupleId)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -167,7 +187,15 @@ class GameSessionManager @Inject constructor(
|
|||
suspend fun markUserComplete(sessionId: String, coupleId: String): Result<Unit> {
|
||||
val userId = authRepository.currentUserId
|
||||
?: return Result.failure(Exception("Not signed in"))
|
||||
return sessionRepository.markUserComplete(sessionId, coupleId, userId)
|
||||
return sessionRepository.markUserComplete(sessionId, coupleId, userId).onSuccess {
|
||||
// This user finished playing (async games complete per-user; the session itself
|
||||
// closes once both are in). gameType isn't at hand here — the count is the KPI.
|
||||
retentionAnalytics.track(
|
||||
RetentionEvent.GameCompleted(
|
||||
coupleIdHash = AnalyticsHashing.hashForAnalytics(coupleId)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -2,7 +2,12 @@ package app.closer.ui.pairing
|
|||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import app.closer.analytics.RetentionAnalytics
|
||||
import app.closer.analytics.RetentionEvent
|
||||
import app.closer.core.navigation.AppRoute
|
||||
import app.closer.data.local.PendingJoinCodeStore
|
||||
import app.closer.domain.repository.AuthRepository
|
||||
import app.closer.domain.repository.CoupleRepository
|
||||
import app.closer.domain.repository.InviteRepository
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
|
|
@ -16,20 +21,52 @@ data class AcceptInviteUiState(
|
|||
val code: String = "",
|
||||
val isLoading: Boolean = false,
|
||||
val error: String? = null,
|
||||
val navigateTo: String? = null
|
||||
val navigateTo: String? = null,
|
||||
/** True once we've confirmed this account is already in a couple — show a friendly wall. */
|
||||
val alreadyPaired: Boolean = false
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class AcceptInviteViewModel @Inject constructor(
|
||||
private val inviteRepository: InviteRepository
|
||||
private val inviteRepository: InviteRepository,
|
||||
private val authRepository: AuthRepository,
|
||||
private val coupleRepository: CoupleRepository,
|
||||
private val pendingJoinCodeStore: PendingJoinCodeStore,
|
||||
private val retentionAnalytics: RetentionAnalytics
|
||||
) : ViewModel() {
|
||||
|
||||
private val _uiState = MutableStateFlow(AcceptInviteUiState())
|
||||
val uiState: StateFlow<AcceptInviteUiState> = _uiState.asStateFlow()
|
||||
|
||||
/**
|
||||
* Seeds the screen with a code arriving from an invite deep link. Falls back to a code
|
||||
* stashed by [PendingJoinCodeStore] (survives a process death during sign-up). Also
|
||||
* detects the already-paired account so a tapped link shows a clear message instead of a
|
||||
* confusing pairing attempt. Called once from the screen with the nav-arg code.
|
||||
*/
|
||||
fun onEntered(initialCode: String) {
|
||||
viewModelScope.launch {
|
||||
// Already in a couple? A tapped invite link shouldn't try to re-pair.
|
||||
val uid = authRepository.currentUserId
|
||||
if (uid != null && coupleRepository.getCoupleForUser(uid) != null) {
|
||||
pendingJoinCodeStore.clear()
|
||||
_uiState.update { it.copy(alreadyPaired = true) }
|
||||
return@launch
|
||||
}
|
||||
|
||||
val fromLink = initialCode.ifBlank { pendingJoinCodeStore.peek().orEmpty() }
|
||||
if (fromLink.isNotBlank()) {
|
||||
pendingJoinCodeStore.clear()
|
||||
retentionAnalytics.track(RetentionEvent.InviteLinkOpened())
|
||||
_uiState.update {
|
||||
if (it.code.isBlank()) it.copy(code = normalize(fromLink)) else it
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun updateCode(raw: String) {
|
||||
val filtered = raw.uppercase().filter { it in CODE_CHARS }.take(6)
|
||||
_uiState.update { it.copy(code = filtered, error = null) }
|
||||
_uiState.update { it.copy(code = normalize(raw), error = null) }
|
||||
}
|
||||
|
||||
fun lookupCode() {
|
||||
|
|
@ -50,6 +87,8 @@ class AcceptInviteViewModel @Inject constructor(
|
|||
fun onNavigated() = _uiState.update { it.copy(navigateTo = null) }
|
||||
fun dismissError() = _uiState.update { it.copy(error = null) }
|
||||
|
||||
private fun normalize(raw: String) = raw.uppercase().filter { it in CODE_CHARS }.take(6)
|
||||
|
||||
companion object {
|
||||
private const val CODE_CHARS = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -27,8 +27,10 @@ import androidx.compose.foundation.layout.size
|
|||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
|
|
@ -85,6 +87,8 @@ data class PairingSuccessUiState(
|
|||
val myPhotoUrl: String = "",
|
||||
val partnerName: String = "",
|
||||
val partnerPhotoUrl: String = "",
|
||||
/** >0 shows the "bring your solo answers along?" consent prompt. */
|
||||
val soloAnswerCount: Int = 0,
|
||||
val navigateTo: String? = null
|
||||
)
|
||||
|
||||
|
|
@ -93,7 +97,8 @@ class PairingSuccessViewModel @Inject constructor(
|
|||
savedStateHandle: SavedStateHandle,
|
||||
private val authRepository: AuthRepository,
|
||||
private val userRepository: UserRepository,
|
||||
private val coupleRepository: CoupleRepository
|
||||
private val coupleRepository: CoupleRepository,
|
||||
private val soloAnswerMigrator: app.closer.domain.usecase.SoloAnswerMigrator
|
||||
) : ViewModel() {
|
||||
|
||||
private val coupleId: String = savedStateHandle["coupleId"] ?: ""
|
||||
|
|
@ -101,6 +106,9 @@ class PairingSuccessViewModel @Inject constructor(
|
|||
val uiState: StateFlow<PairingSuccessUiState> = _uiState.asStateFlow()
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(soloAnswerCount = runCatching { soloAnswerMigrator.pendingCount() }.getOrDefault(0)) }
|
||||
}
|
||||
viewModelScope.launch {
|
||||
val myId = authRepository.currentUserId ?: return@launch
|
||||
val me = runCatching { userRepository.getUser(myId) }.getOrNull()
|
||||
|
|
@ -129,6 +137,22 @@ class PairingSuccessViewModel @Inject constructor(
|
|||
|
||||
fun proceed() = _uiState.update { it.copy(navigateTo = AppRoute.HOME) }
|
||||
fun onNavigated() = _uiState.update { it.copy(navigateTo = null) }
|
||||
|
||||
/** "Bring them along": move the solo answers into Couple Lore, then dismiss the prompt. */
|
||||
fun keepSoloAnswers() {
|
||||
viewModelScope.launch {
|
||||
runCatching { soloAnswerMigrator.migrateToLore(coupleId) }
|
||||
_uiState.update { it.copy(soloAnswerCount = 0) }
|
||||
}
|
||||
}
|
||||
|
||||
/** "Start fresh": discard the solo answers. */
|
||||
fun discardSoloAnswers() {
|
||||
viewModelScope.launch {
|
||||
runCatching { soloAnswerMigrator.discard() }
|
||||
_uiState.update { it.copy(soloAnswerCount = 0) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Screen ────────────────────────────────────────────────────────────────────
|
||||
|
|
@ -145,6 +169,27 @@ fun PairingSuccessScreen(
|
|||
state.navigateTo?.let { onNavigate(it); viewModel.onNavigated() }
|
||||
}
|
||||
|
||||
if (state.soloAnswerCount > 0) {
|
||||
val n = state.soloAnswerCount
|
||||
AlertDialog(
|
||||
onDismissRequest = { /* require an explicit choice */ },
|
||||
title = { Text(if (n == 1) "Bring your answer along?" else "Bring your $n answers along?") },
|
||||
text = {
|
||||
Text(
|
||||
"While you waited, you answered privately. Want to add " +
|
||||
(if (n == 1) "it" else "them") +
|
||||
" to your shared history with ${state.partnerName.ifBlank { "your partner" }}?"
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = { viewModel.keepSoloAnswers() }) { Text("Bring them along") }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { viewModel.discardSoloAnswers() }) { Text("Start fresh") }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
val pulse by rememberInfiniteTransition(label = "heart").animateFloat(
|
||||
initialValue = 0.92f,
|
||||
targetValue = 1.12f,
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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"))
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,97 @@
|
|||
package app.closer.ui.recap
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import app.closer.analytics.RetentionAnalytics
|
||||
import app.closer.analytics.RetentionEvent
|
||||
import app.closer.domain.WeeklyRecapGenerator
|
||||
import app.closer.domain.model.WeeklyRecap
|
||||
import app.closer.domain.model.WeeklyRecapAnswer
|
||||
import app.closer.domain.model.WeeklyRecapInput
|
||||
import app.closer.domain.model.WeeklyRecapReveal
|
||||
import app.closer.domain.repository.AuthRepository
|
||||
import app.closer.domain.repository.LocalAnswerRepository
|
||||
import app.closer.domain.repository.QuestionRepository
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import java.time.Instant
|
||||
import java.time.LocalDate
|
||||
import java.time.ZoneId
|
||||
import javax.inject.Inject
|
||||
|
||||
data class WeeklyRecapUiState(
|
||||
val isLoading: Boolean = true,
|
||||
val recap: WeeklyRecap? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* Backs the Weekly Recap screen. Assembles a [WeeklyRecapInput] from the accurate,
|
||||
* on-device answer history (answers, reveals, categories) and runs the pure
|
||||
* [WeeklyRecapGenerator]. Fuller couple-wide metrics (games/challenges/dates/capsules) are a
|
||||
* tracked follow-on; the generator zeroes any event type it isn't given, and the screen renders
|
||||
* only the tiles it has real data for, so nothing false is asserted.
|
||||
*/
|
||||
@HiltViewModel
|
||||
class WeeklyRecapViewModel @Inject constructor(
|
||||
private val authRepository: AuthRepository,
|
||||
private val localAnswerRepository: LocalAnswerRepository,
|
||||
private val questionRepository: QuestionRepository,
|
||||
private val retentionAnalytics: RetentionAnalytics,
|
||||
) : ViewModel() {
|
||||
|
||||
private val _uiState = MutableStateFlow(WeeklyRecapUiState())
|
||||
val uiState: StateFlow<WeeklyRecapUiState> = _uiState.asStateFlow()
|
||||
|
||||
init {
|
||||
retentionAnalytics.track(RetentionEvent.WeeklyRecapOpened())
|
||||
load()
|
||||
}
|
||||
|
||||
private fun load() {
|
||||
viewModelScope.launch {
|
||||
val zone = ZoneId.systemDefault()
|
||||
val today = LocalDate.now(zone)
|
||||
val weekStart = today.minusDays(6)
|
||||
val userId = authRepository.currentUserId ?: ""
|
||||
|
||||
val answers = runCatching { localAnswerRepository.observeAnswers().first() }.getOrDefault(emptyList())
|
||||
val categoryTitles = runCatching {
|
||||
questionRepository.getCategories().associate { it.id to it.displayName }
|
||||
}.getOrDefault(emptyMap())
|
||||
|
||||
val recapAnswers = answers.map { a ->
|
||||
WeeklyRecapAnswer(
|
||||
userId = userId,
|
||||
questionId = a.questionId,
|
||||
categoryId = a.category.ifBlank { null },
|
||||
localDate = a.localDate(zone),
|
||||
isRevealed = a.isRevealed,
|
||||
hasText = !a.writtenText.isNullOrBlank()
|
||||
)
|
||||
}
|
||||
val recapReveals = answers.filter { it.isRevealed }
|
||||
.map { WeeklyRecapReveal(localDate = it.localDate(zone)) }
|
||||
|
||||
val recap = WeeklyRecapGenerator.generate(
|
||||
WeeklyRecapInput(
|
||||
weekStart = weekStart,
|
||||
weekEnd = today,
|
||||
answers = recapAnswers,
|
||||
reveals = recapReveals,
|
||||
categoryTitles = categoryTitles
|
||||
)
|
||||
)
|
||||
_uiState.update { it.copy(isLoading = false, recap = recap) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun app.closer.domain.model.LocalAnswer.localDate(zone: ZoneId): LocalDate =
|
||||
runCatching { LocalDate.parse(answerDate) }.getOrElse {
|
||||
Instant.ofEpochMilli(updatedAt).atZone(zone).toLocalDate()
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,31 @@
|
|||
package app.closer.ui.settings
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import app.closer.domain.repository.SettingsRepository
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Backs the Settings → Privacy screen's "share anonymous usage data" toggle.
|
||||
* The persisted flag is applied to Firebase + the retention sink by
|
||||
* [app.closer.analytics.AnalyticsConsentApplier].
|
||||
*/
|
||||
@HiltViewModel
|
||||
class PrivacyViewModel @Inject constructor(
|
||||
private val settingsRepository: SettingsRepository,
|
||||
) : ViewModel() {
|
||||
|
||||
val analyticsEnabled: StateFlow<Boolean> = settingsRepository.settings
|
||||
.map { it.analyticsEnabled }
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), true)
|
||||
|
||||
fun setAnalyticsEnabled(enabled: Boolean) {
|
||||
viewModelScope.launch { settingsRepository.setAnalyticsEnabled(enabled) }
|
||||
}
|
||||
}
|
||||
|
|
@ -93,6 +93,9 @@
|
|||
<string name="privacy_no_export">No data export</string>
|
||||
<string name="privacy_no_export_body">Closer does not currently offer a data export. Your answers and history are deleted along with your account.</string>
|
||||
<string name="privacy_rule_intro">Closer is built on one rule: answers stay private until both of you have answered. Here\'s exactly what that means.</string>
|
||||
<string name="privacy_usage_data_section">Anonymous usage data</string>
|
||||
<string name="privacy_usage_data_title">Share anonymous usage data</string>
|
||||
<string name="privacy_usage_data_body">Helps us see which features couples use — never what you write. Your answers, messages, and prompts are end-to-end encrypted and can\'t be in this data even if we wanted them.</string>
|
||||
<string name="privacy_legal_section">Legal</string>
|
||||
<string name="privacy_legal_documents">Legal documents</string>
|
||||
<string name="privacy_policy">Privacy Policy</string>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,89 @@
|
|||
package app.closer.analytics
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import kotlin.reflect.full.primaryConstructor
|
||||
|
||||
/**
|
||||
* Mechanically enforces the standing rule: retention analytics carry ONLY safe metadata —
|
||||
* never answer text, prompts, names, or any other content field.
|
||||
*
|
||||
* The schema check reflects over every [RetentionEvent] subclass, so adding a new event
|
||||
* with a content-shaped field (e.g. `answerText`) fails this test immediately.
|
||||
*/
|
||||
class NoPlaintextInAnalyticsTest {
|
||||
|
||||
/** The only constructor parameters an event may declare. */
|
||||
private val allowedParams = setOf("categoryId", "coupleIdHash", "timestamp")
|
||||
|
||||
@Test
|
||||
fun `every retention event declares only the safe metadata fields`() {
|
||||
val subclasses = RetentionEvent::class.sealedSubclasses
|
||||
assertTrue("expected the sealed hierarchy to be non-empty", subclasses.isNotEmpty())
|
||||
subclasses.forEach { subclass ->
|
||||
val params = subclass.primaryConstructor!!.parameters.mapNotNull { it.name }.toSet()
|
||||
val illegal = params - allowedParams
|
||||
assertTrue(
|
||||
"${subclass.simpleName} declares non-metadata constructor params: $illegal — " +
|
||||
"content must never ride on analytics events",
|
||||
illegal.isEmpty()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `every event type maps to a valid GA4 event name`() {
|
||||
RetentionEventType.entries.forEach { type ->
|
||||
val name = type.name.lowercase()
|
||||
assertTrue(
|
||||
"event name '$name' must be snake_case (GA4 requirement)",
|
||||
name.matches(Regex("^[a-z][a-z0-9_]*$"))
|
||||
)
|
||||
assertTrue(
|
||||
"event name '$name' exceeds GA4's 40-char limit",
|
||||
name.length <= 40
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `hashing is one-way, deterministic, and never echoes the input`() {
|
||||
val sensitive = "how-do-you-feel-about-intimacy-q-123"
|
||||
val hashed = AnalyticsHashing.hashForAnalytics(sensitive)
|
||||
assertNotEquals(sensitive, hashed)
|
||||
assertTrue("hash must not contain the raw value", !hashed.contains(sensitive))
|
||||
assertTrue("hash is 16 lowercase hex chars", hashed.matches(Regex("^[0-9a-f]{16}$")))
|
||||
assertEquals(hashed, AnalyticsHashing.hashForAnalytics(sensitive))
|
||||
assertEquals("empty", AnalyticsHashing.hashForAnalytics(" "))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `coarse category buckets intimate categories away from their raw names`() {
|
||||
listOf("sex", "desire", "physical_intimacy", "rebuilding_trust").forEach {
|
||||
assertEquals("intimacy", AnalyticsHashing.coarseCategory(it))
|
||||
}
|
||||
assertEquals("other", AnalyticsHashing.coarseCategory("some_new_category"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `composite isolates a throwing sink and never propagates to the caller`() {
|
||||
val received = mutableListOf<RetentionEvent>()
|
||||
val throwing = object : RetentionAnalytics {
|
||||
override fun track(event: RetentionEvent) = throw IllegalStateException("sink down")
|
||||
override fun setEnabled(enabled: Boolean) = throw IllegalStateException("sink down")
|
||||
}
|
||||
val recording = object : RetentionAnalytics {
|
||||
override fun track(event: RetentionEvent) { received.add(event) }
|
||||
override fun setEnabled(enabled: Boolean) {}
|
||||
}
|
||||
val composite = CompositeRetentionAnalytics(listOf(throwing, recording))
|
||||
|
||||
// Must not throw, and the healthy sink must still receive the event.
|
||||
composite.track(RetentionEvent.GameStarted(categoryId = "wheel"))
|
||||
composite.setEnabled(false)
|
||||
|
||||
assertEquals(1, received.size)
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
package app.closer.domain.usecase
|
||||
|
||||
import app.closer.data.remote.FirestoreAnswerDataSource
|
||||
import app.closer.domain.model.LocalAnswer
|
||||
import app.closer.domain.repository.LocalAnswerRepository
|
||||
import io.mockk.Runs
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.just
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class SoloAnswerMigratorTest {
|
||||
|
||||
private val local = mockk<LocalAnswerRepository>(relaxed = true)
|
||||
private val answers = mockk<FirestoreAnswerDataSource>(relaxed = true)
|
||||
private val migrator = SoloAnswerMigrator(local, answers)
|
||||
|
||||
private fun written(id: String, text: String) = LocalAnswer(
|
||||
questionId = id, questionText = "Q $id", category = "fun",
|
||||
answerType = "written", writtenText = text, answerDate = "2026-07-06"
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `migrates written solo answers into lore and clears them`() = runTest {
|
||||
coEvery { local.observeAnswers() } returns flowOf(listOf(written("q1", "hi"), written("q2", "yo")))
|
||||
coEvery { answers.saveLoreEntry(any(), any(), any(), any(), any(), any(), any()) } just Runs
|
||||
|
||||
val moved = migrator.migrateToLore("couple1")
|
||||
|
||||
assertEquals(2, moved)
|
||||
coVerify(exactly = 1) { answers.saveLoreEntry("couple1", "q1", "Q q1", "hi", null, null, "2026-07-06") }
|
||||
coVerify { local.deleteAnswer("q1") }
|
||||
coVerify { local.deleteAnswer("q2") }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a failed lore write keeps that answer local for a later retry`() = runTest {
|
||||
coEvery { local.observeAnswers() } returns flowOf(listOf(written("q1", "hi")))
|
||||
coEvery { answers.saveLoreEntry(any(), any(), any(), any(), any(), any(), any()) } throws
|
||||
IllegalStateException("couple key not ready")
|
||||
|
||||
val moved = migrator.migrateToLore("couple1")
|
||||
|
||||
assertEquals(0, moved)
|
||||
coVerify(exactly = 0) { local.deleteAnswer(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `answers with no usable content are skipped`() = runTest {
|
||||
val empty = LocalAnswer(
|
||||
questionId = "q9", questionText = "Q", category = "fun",
|
||||
answerType = "written", writtenText = null, answerDate = "2026-07-06"
|
||||
)
|
||||
coEvery { local.observeAnswers() } returns flowOf(listOf(empty))
|
||||
|
||||
assertEquals(0, migrator.migrateToLore("couple1"))
|
||||
coVerify(exactly = 0) { answers.saveLoreEntry(any(), any(), any(), any(), any(), any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `discard clears all solo answers without writing lore`() = runTest {
|
||||
coEvery { local.observeAnswers() } returns flowOf(listOf(written("q1", "hi"), written("q2", "yo")))
|
||||
coEvery { local.deleteAnswer(any()) } just Runs
|
||||
|
||||
migrator.discard()
|
||||
|
||||
coVerify { local.deleteAnswer("q1") }
|
||||
coVerify { local.deleteAnswer("q2") }
|
||||
coVerify(exactly = 0) { answers.saveLoreEntry(any(), any(), any(), any(), any(), any(), any()) }
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
// 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: "new-wrapped-key",
|
||||
kdfSalt: "new-salt",
|
||||
kdfParams: "argon2id-v2",
|
||||
}));
|
||||
});
|
||||
|
||||
test("a member cannot change encryptionVersion alongside the wrap — denied", async () => {
|
||||
await assertFails(updateDoc(doc(alice().firestore(), `couples/${COUPLE_ID}`), {
|
||||
wrappedCoupleKey: "new-wrapped-key",
|
||||
kdfSalt: "new-salt",
|
||||
kdfParams: "argon2id-v2",
|
||||
encryptionVersion: 3,
|
||||
}));
|
||||
});
|
||||
|
||||
test("a member cannot downgrade encryptionVersion — denied", async () => {
|
||||
await assertFails(updateDoc(doc(alice().firestore(), `couples/${COUPLE_ID}`), {
|
||||
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);
|
||||
});
|
||||
|
||||
await assertSucceeds(updateDoc(doc(alice().firestore(), `couples/${COUPLE_ID}`), {
|
||||
wrappedCoupleKey: "wrapped-key",
|
||||
kdfSalt: "salt",
|
||||
kdfParams: "argon2id",
|
||||
encryptionVersion: 1,
|
||||
encryptionMigrationUsers: {},
|
||||
}));
|
||||
});
|
||||
|
||||
test("a member can mark only their own migration complete — allowed", async () => {
|
||||
await testEnv.withSecurityRulesDisabled(async (ctx) => {
|
||||
const versionOne = { ...COUPLE_DOC, encryptionVersion: 1 };
|
||||
delete (versionOne as Record<string, unknown>).encryptionMigrationUsers;
|
||||
await setDoc(doc(ctx.firestore(), `couples/${COUPLE_ID}`), versionOne);
|
||||
});
|
||||
|
||||
await assertSucceeds(updateDoc(doc(alice().firestore(), `couples/${COUPLE_ID}`), {
|
||||
encryptionVersion: 1,
|
||||
encryptionMigrationUsers: { [UID_A]: true },
|
||||
}));
|
||||
});
|
||||
|
||||
test("a member cannot claim their partner migrated — denied", async () => {
|
||||
await testEnv.withSecurityRulesDisabled(async (ctx) => {
|
||||
await setDoc(doc(ctx.firestore(), `couples/${COUPLE_ID}`), {
|
||||
...COUPLE_DOC,
|
||||
encryptionVersion: 1,
|
||||
encryptionMigrationUsers: {},
|
||||
});
|
||||
});
|
||||
|
||||
await assertFails(updateDoc(doc(alice().firestore(), `couples/${COUPLE_ID}`), {
|
||||
encryptionVersion: 1,
|
||||
encryptionMigrationUsers: { [UID_B]: true },
|
||||
}));
|
||||
});
|
||||
|
||||
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: {},
|
||||
});
|
||||
});
|
||||
|
||||
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 },
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
|
@ -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}", () => {
|
|||
});
|
||||
});
|
||||
|
||||
test("owner can create own daily answer — allowed", async () => {
|
||||
await assertSucceeds(
|
||||
setDoc(doc(alice().firestore(), ANSWER_PATH), {
|
||||
// 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), {
|
||||
...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(),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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=...
|
||||
|
|
|
|||
|
|
@ -32,4 +32,4 @@ NODE_PATH=functions/node_modules node qa/qa_push.js <uid> chat_message conversat
|
|||
## Test couple (current emulators)
|
||||
- coupleId `Xal3Kw3gjSdn0niERYKJ`
|
||||
- QA (emulator-5554) `Y05AKO2IlTPMa0JQW1BiNIM0uzK2` · Sam (emulator-5556) `imDjjOTTQvXGGjyUhUc5JSeHWkU2`
|
||||
- Admin SA JSON: `closer-app-22014-firebase-adminsdk-fbsvc-ed20bf6003.json` (gitignored; override path via `SA_JSON`).
|
||||
- Admin SA JSON: `~/.config/closer/closer-admin-sa.json` (kept OUTSIDE the repo; override path via `SA_JSON`). For tools using Application Default Credentials: `export GOOGLE_APPLICATION_CREDENTIALS=~/.config/closer/closer-admin-sa.json`.
|
||||
|
|
|
|||
|
|
@ -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)) })
|
||||
|
|
|
|||
Loading…
Reference in New Issue