docs(readme): add privacy slogan to header

This commit is contained in:
null 2026-06-23 22:14:36 -05:00
parent e5c05abe90
commit 06e09da596
20 changed files with 416 additions and 174 deletions

View File

@ -1,5 +1,7 @@
# Closer
> *Your information is security private. We never look. We never sell it.*
>
> *Private daily questions for couples who want honest answers before shared conversations.*
**Product goal:** private, mutual-reveal relationship questions with real encryption and calmer UX.

View File

@ -91,20 +91,25 @@ fun AppNavigation(
val navController = rememberNavController()
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = navBackStackEntry?.destination?.route
val bottomRoutes = AppRoute.topLevelRoutes
// Debug paired-home preview shows the real bottom nav too, so the menu stays anchored
// exactly as it does on the live paired Home (which is the HOME tab route).
val bottomRoutes = AppRoute.topLevelRoutes + AppRoute.PAIRED_HOME_PREVIEW
val shellTitle = currentRoute
?.takeIf { it in shellBackRoutes }
?.let(AppRoute::titleFor)
// Tab-switch semantics: pop to the graph start, keep a single instance, and
// save/restore each tab's own back stack. Every navigation to a top-level
// route must go through this so a tab is never pushed on top of another tab.
// Tab-switch semantics: pop to the graph start and keep a single instance, so a
// tab is never pushed on top of another tab. We deliberately DON'T save/restore
// each tab's back stack — tapping a bottom tab always lands on that tab's root,
// never a sub-screen you previously drilled into. (Restoring the saved stack made
// re-tapping "Settings" reopen a deep page — e.g. the paired-home preview — which
// read as "Settings goes to a paired partner".)
val selectTab: (String) -> Unit = { route ->
navController.navigate(route) {
popUpTo(navController.graph.findStartDestination().id) {
saveState = true
saveState = false
}
launchSingleTop = true
restoreState = true
restoreState = false
}
}
val navigateBackOrHome: () -> Unit = {
@ -476,6 +481,9 @@ fun AppNavigation(
composable(route = AppRoute.ART_PREVIEW) {
app.closer.ui.debug.ArtPreviewScreen(onNavigate = navigateRoute)
}
composable(route = AppRoute.PAIRED_HOME_PREVIEW) {
app.closer.ui.home.PairedHomePreviewScreen(onNavigate = navigateRoute)
}
}
}
}

View File

@ -56,6 +56,7 @@ object AppRoute {
const val YOUR_PROGRESS = "your_progress"
const val ACTIVITY = "activity"
const val ART_PREVIEW = "art_preview"
const val PAIRED_HOME_PREVIEW = "paired_home_preview"
const val PAIRING_SUCCESS = "pairing_success/{coupleId}"
fun pairingSuccess(coupleId: String) = "pairing_success/$coupleId"

View File

@ -9,6 +9,7 @@ import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.tasks.await
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.coroutines.resume
@ -27,9 +28,11 @@ import kotlin.coroutines.resumeWithException
* "matchedBy": ["userA", "userB"]
* }
*
* Security rules deny client create/update/delete; matches are written by a
* Cloud Function after both partners swipe [SwipeAction.LOVE]. The client layer
* still exposes a create helper for the function trigger path.
* Under E2EE the server can no longer read swipe content, so mutual-love
* detection moved client-side: when a member records the second LOVE on an idea,
* the client writes the match marker here (idempotent, keyed by dateIdeaId). A
* Cloud Function then fires the "It's a match!" notification on create. Security
* rules allow couple members to create (validated shape) but never update/delete.
*/
@Singleton
class FirestoreDateMatchDataSource @Inject constructor(private val db: FirebaseFirestore) {
@ -37,16 +40,27 @@ class FirestoreDateMatchDataSource @Inject constructor(private val db: FirebaseF
db.collection(FirestoreCollections.COUPLES).document(coupleId)
.collection(FirestoreCollections.Couples.DATE_MATCHES)
suspend fun createMatch(coupleId: String, dateIdeaId: String, matchedBy: List<String>): String {
val doc = matchesRef(coupleId).document()
doc.set(
mapOf(
"dateIdeaId" to dateIdeaId,
"revealedAt" to FieldValue.serverTimestamp(),
"matchedBy" to matchedBy
)
).voidAwait()
return doc.id
/**
* Create the match marker for [dateIdeaId] iff it doesn't already exist. Keyed by
* dateIdeaId and guarded by a transaction so both partners loving near-simultaneously
* (and repeated swipes) never produce a duplicate. `fcmNotified` lets the notify
* function claim the push exactly once.
*/
suspend fun createMatchIfAbsent(coupleId: String, dateIdeaId: String, matchedBy: List<String>) {
val ref = matchesRef(coupleId).document(dateIdeaId)
db.runTransaction<Unit> { tx ->
if (!tx.get(ref).exists()) {
tx.set(
ref,
mapOf(
"dateIdeaId" to dateIdeaId,
"revealedAt" to FieldValue.serverTimestamp(),
"matchedBy" to matchedBy,
"fcmNotified" to false
)
)
}
}.await()
}
suspend fun findMatchByDateIdeaId(coupleId: String, dateIdeaId: String): DateMatch? {
@ -76,12 +90,6 @@ class FirestoreDateMatchDataSource @Inject constructor(private val db: FirebaseF
.addOnFailureListener { cont.resumeWithException(it) }
}
private suspend fun com.google.android.gms.tasks.Task<Void>.voidAwait() =
suspendCancellableCoroutine<Unit> { cont ->
addOnSuccessListener { cont.resume(Unit) }
addOnFailureListener { cont.resumeWithException(it) }
}
// ─── Mapper ──────────────────────────────────────────────────────────────
@Suppress("UNCHECKED_CAST")

View File

@ -1,9 +1,11 @@
package app.closer.data.remote
import app.closer.crypto.CoupleEncryptionManager
import app.closer.crypto.FieldEncryptor
import app.closer.domain.model.DateSwipe
import app.closer.domain.model.SwipeAction
import com.google.crypto.tink.Aead
import com.google.firebase.firestore.DocumentSnapshot
import com.google.firebase.firestore.FieldValue
import com.google.firebase.firestore.FirebaseFirestore
import com.google.firebase.firestore.SetOptions
import kotlinx.coroutines.channels.awaitClose
@ -21,27 +23,35 @@ import kotlin.coroutines.resumeWithException
* Path layout:
* couples/{coupleId}/date_swipes/{dateId}
*
* Each document stores a map keyed by userId:
* Each document stores a map keyed by userId. The `action` is E2E-encrypted with
* the couple key (like every other game) so the server can never read a partner's
* date preferences; only `swipedAt` stays plaintext.
* {
* "actions": {
* "userA": { "action": "love", "swipedAt": 12345 },
* "userB": { "action": "skip", "swipedAt": 12346 }
* "userA": { "action": "enc:v1:…", "swipedAt": 12345 },
* "userB": { "action": "enc:v1:…", "swipedAt": 12346 }
* }
* }
*
* Security rules ensure only couple members can read/write, and each user can
* only write their own action entry.
* Security rules ensure only couple members can read/write, each user can only
* write their own action entry, and the action must be ciphertext. Match detection
* is therefore done client-side (the server is blind to swipe content).
*/
@Singleton
class FirestoreDateSwipeDataSource @Inject constructor(private val db: FirebaseFirestore) {
class FirestoreDateSwipeDataSource @Inject constructor(
private val db: FirebaseFirestore,
private val encryptionManager: CoupleEncryptionManager,
private val fieldEncryptor: FieldEncryptor
) {
private fun swipesRef(coupleId: String) =
db.collection(FirestoreCollections.COUPLES).document(coupleId)
.collection(FirestoreCollections.Couples.DATE_SWIPES)
suspend fun recordSwipe(coupleId: String, swipe: DateSwipe) {
val aead = encryptionManager.requireAead(coupleId)
val path = swipesRef(coupleId).document(swipe.dateIdeaId)
val entry = mapOf(
"action" to swipe.action.toFirestoreValue(),
"action" to fieldEncryptor.encrypt(swipe.action.toFirestoreValue(), aead, coupleId),
"swipedAt" to swipe.swipedAt
)
path.set(
@ -50,9 +60,20 @@ class FirestoreDateSwipeDataSource @Inject constructor(private val db: FirebaseF
).voidAwait()
}
/** Decrypt a stored action, tolerating legacy plaintext written before E2EE. */
private fun decryptAction(raw: String?, aead: Aead?, coupleId: String): String {
if (raw.isNullOrEmpty()) return ""
return if (fieldEncryptor.isEncrypted(raw)) {
fieldEncryptor.decrypt(raw, aead, coupleId) ?: ""
} else {
raw // legacy plaintext from before this migration
}
}
suspend fun getSwipe(coupleId: String, dateIdeaId: String, userId: String): DateSwipe? {
val aead = encryptionManager.aeadFor(coupleId)
val snap = swipesRef(coupleId).document(dateIdeaId).getDoc()
return snap.toDateSwipe(dateIdeaId, userId)
return snap.toDateSwipe(dateIdeaId, userId, aead, coupleId)
}
fun observeOwnSwipes(
@ -70,7 +91,8 @@ class FirestoreDateSwipeDataSource @Inject constructor(private val db: FirebaseF
val listener = swipesRef(coupleId)
.addSnapshotListener { snap, err ->
if (err != null || snap == null) return@addSnapshotListener
val swipes = snap.documents.mapNotNull { it.toDateSwipe(it.id, userId) }
val aead = encryptionManager.aeadFor(coupleId)
val swipes = snap.documents.mapNotNull { it.toDateSwipe(it.id, userId, aead, coupleId) }
trySend(swipes)
}
awaitClose { listener.remove() }
@ -80,8 +102,9 @@ class FirestoreDateSwipeDataSource @Inject constructor(private val db: FirebaseF
@Suppress("UNCHECKED_CAST")
private suspend fun getAllSwipesForUser(coupleId: String, userId: String): List<DateSwipe> {
val aead = encryptionManager.aeadFor(coupleId)
val snap = swipesRef(coupleId).getQuery()
return snap.documents.mapNotNull { it.toDateSwipe(it.id, userId) }
return snap.documents.mapNotNull { it.toDateSwipe(it.id, userId, aead, coupleId) }
}
private suspend fun com.google.firebase.firestore.CollectionReference.getQuery() =
@ -92,6 +115,7 @@ class FirestoreDateSwipeDataSource @Inject constructor(private val db: FirebaseF
}
suspend fun getAllSwipesForDate(coupleId: String, dateIdeaId: String): List<DateSwipe> {
val aead = encryptionManager.aeadFor(coupleId)
val snap = swipesRef(coupleId).document(dateIdeaId).getDoc()
@Suppress("UNCHECKED_CAST")
val actions = snap.get("actions") as? Map<String, Map<String, Any>> ?: emptyMap()
@ -99,7 +123,7 @@ class FirestoreDateSwipeDataSource @Inject constructor(private val db: FirebaseF
DateSwipe(
dateIdeaId = dateIdeaId,
userId = uid,
action = SwipeAction.fromFirestoreValue(data["action"] as? String ?: ""),
action = SwipeAction.fromFirestoreValue(decryptAction(data["action"] as? String, aead, coupleId)),
swipedAt = (data["swipedAt"] as? Number)?.toLong() ?: 0L
)
}
@ -123,13 +147,18 @@ class FirestoreDateSwipeDataSource @Inject constructor(private val db: FirebaseF
// ─── Mappers ─────────────────────────────────────────────────────────────
@Suppress("UNCHECKED_CAST")
private fun DocumentSnapshot.toDateSwipe(dateIdeaId: String, userId: String): DateSwipe? {
private fun DocumentSnapshot.toDateSwipe(
dateIdeaId: String,
userId: String,
aead: Aead?,
coupleId: String
): DateSwipe? {
val actions = get("actions") as? Map<String, Map<String, Any>> ?: return null
val data = actions[userId] ?: return null
return DateSwipe(
dateIdeaId = dateIdeaId,
userId = userId,
action = SwipeAction.fromFirestoreValue(data["action"] as? String ?: ""),
action = SwipeAction.fromFirestoreValue(decryptAction(data["action"] as? String, aead, coupleId)),
swipedAt = (data["swipedAt"] as? Number)?.toLong() ?: 0L
)
}

View File

@ -19,12 +19,13 @@ import javax.inject.Singleton
* shipped as a Cloud Function seed. Firestore is the source of truth for
* per-partner swipes and revealed matches.
*
* Mutual match detection is performed server-side by the
* `createDateMatchOnMutualLove` Cloud Function: the `date_matches` collection is
* server-write-only (Firestore rules deny client writes), so when both partners
* swipe [SwipeAction.LOVE] on an idea the function creates the match and it
* arrives on the client via [observeMatches]. [recordSwipe] therefore only
* records the swipe and always resolves with a null match.
* Swipes are E2E-encrypted (the server can't read them), so mutual-match detection
* is done client-side: after recording a LOVE, [recordSwipe] reads everyone's
* (decrypted) swipes for that idea and, if both partners loved it, writes the match
* marker via [FirestoreDateMatchDataSource.createMatchIfAbsent] (idempotent). A
* Cloud Function then fires the "It's a match!" notification on create. The result
* still arrives on every device via [observeMatches]. [recordSwipe] resolves with a
* null match (the UI consumes matches through [observeMatches]).
*/
@Singleton
class DateMatchRepositoryImpl @Inject constructor(
@ -47,7 +48,19 @@ class DateMatchRepositoryImpl @Inject constructor(
swipedAt = System.currentTimeMillis()
)
swipeDataSource.recordSwipe(coupleId, swipe)
// Match creation is server-side (see class docs); surfaced via observeMatches.
// Server is blind to encrypted swipes, so detect mutual love here: if both
// partners have now loved this idea, write the (idempotent) match marker.
// Surfaced to the UI via observeMatches; FCM fired by the notify function.
if (action == SwipeAction.LOVE) {
val lovers = swipeDataSource.getAllSwipesForDate(coupleId, dateIdeaId)
.filter { it.action == SwipeAction.LOVE }
.map { it.userId }
.distinct()
if (lovers.size >= 2) {
runCatching { matchDataSource.createMatchIfAbsent(coupleId, dateIdeaId, lovers.sorted()) }
}
}
null
}

View File

@ -62,8 +62,15 @@ class PlayIntegrityChecker @Inject constructor(
return
}
val passed = verifyWithServer(token)
_state.value = if (passed) DeviceIntegrityResult.PASSED else DeviceIntegrityResult.COMPROMISED
// Security review Batch 3: don't fail-open to PASSED when verification can't be
// completed. A null verdict (server/network error, or no verdict returned) maps to
// UNAVAILABLE — neutral, not a false "passed". Firebase App Check remains the real
// server-side gatekeeper; this is only an in-app signal.
_state.value = when (verifyWithServer(token)) {
true -> DeviceIntegrityResult.PASSED
false -> DeviceIntegrityResult.COMPROMISED
null -> DeviceIntegrityResult.UNAVAILABLE
}
}
private suspend fun requestToken(): String = suspendCancellableCoroutine { cont ->
@ -77,20 +84,20 @@ class PlayIntegrityChecker @Inject constructor(
.addOnFailureListener { cont.resumeWithException(it) }
}
private suspend fun verifyWithServer(token: String): Boolean =
/** Returns the server verdict, or null when verification couldn't be completed. */
private suspend fun verifyWithServer(token: String): Boolean? =
suspendCancellableCoroutine { cont ->
functions
.getHttpsCallable("checkDeviceIntegrity")
.call(mapOf("token" to token))
.addOnSuccessListener { result ->
@Suppress("UNCHECKED_CAST")
val passed = (result.getData() as? Map<*, *>)?.get("passed") as? Boolean ?: true
cont.resume(passed)
val passed = (result.getData() as? Map<*, *>)?.get("passed") as? Boolean
cont.resume(passed) // null if the server returned no explicit verdict
}
.addOnFailureListener {
// Fail-open: if server verification is unavailable, don't penalise the user.
// Firebase App Check remains the server-side gatekeeper.
cont.resume(true)
// Couldn't verify (server/network). Don't assert PASSED — surface as
// UNAVAILABLE. Firebase App Check remains the server-side gatekeeper.
cont.resume(null)
}
}
}

View File

@ -7,6 +7,8 @@ import androidx.credentials.CredentialManager
import androidx.credentials.CustomCredential
import androidx.credentials.GetCredentialRequest
import androidx.credentials.exceptions.GetCredentialCancellationException
import androidx.credentials.exceptions.NoCredentialException
import android.util.Log
import com.google.android.libraries.identity.googleid.GetSignInWithGoogleOption
import com.google.android.libraries.identity.googleid.GoogleIdTokenCredential
import androidx.compose.foundation.layout.Column
@ -208,7 +210,10 @@ fun LoginScreen(
}
} catch (_: GetCredentialCancellationException) {
// user dismissed — do nothing
} catch (_: NoCredentialException) {
viewModel.reportError("No Google account found on this device. Add one in Settings, then try again.")
} catch (e: Exception) {
Log.w("GoogleSignIn", "Google sign-in failed", e)
viewModel.reportError("Google sign-in failed. Please try again.")
}
}

View File

@ -5,6 +5,8 @@ import androidx.credentials.CredentialManager
import androidx.credentials.CustomCredential
import androidx.credentials.GetCredentialRequest
import androidx.credentials.exceptions.GetCredentialCancellationException
import androidx.credentials.exceptions.NoCredentialException
import android.util.Log
import com.google.android.libraries.identity.googleid.GetSignInWithGoogleOption
import com.google.android.libraries.identity.googleid.GoogleIdTokenCredential
import androidx.compose.foundation.background
@ -226,7 +228,10 @@ fun SignUpScreen(
}
} catch (_: GetCredentialCancellationException) {
// user dismissed — do nothing
} catch (_: NoCredentialException) {
viewModel.reportError("No Google account found on this device. Add one in Settings, then try again.")
} catch (e: Exception) {
Log.w("GoogleSignIn", "Google sign-up failed", e)
viewModel.reportError("Google sign-up failed. Please try again.")
}
}

View File

@ -679,6 +679,9 @@ private fun PrimaryHomeActionCard(
) {
val colors = action.tone.actionColors()
val isDark = isCloserDarkTheme()
val showTonightPartnerArt = action.target == HomeActionTarget.DailyQuestion &&
(dailyQuestionState == DailyQuestionState.UNANSWERED ||
dailyQuestionState == DailyQuestionState.PARTNER_ANSWERED_USER_PENDING)
// For daily-question actions, route the CTA through the explicit state handlers
// so the same button label maps to the correct next step (answer, remind,
@ -774,7 +777,19 @@ private fun PrimaryHomeActionCard(
}
}
if (action.target == HomeActionTarget.DailyQuestion) {
if (showTonightPartnerArt) {
Image(
painter = painterResource(R.drawable.illustration_tonight_partner_prompt),
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier
.fillMaxWidth()
.height(148.dp)
.clip(RoundedCornerShape(22.dp))
)
}
if (action.target == HomeActionTarget.DailyQuestion && !showTonightPartnerArt) {
Text(
text = "Got 5 min?",
style = MaterialTheme.typography.labelSmall.copy(fontWeight = FontWeight.SemiBold),
@ -782,29 +797,8 @@ private fun PrimaryHomeActionCard(
)
}
Row(
horizontalArrangement = Arrangement.spacedBy(14.dp),
verticalAlignment = Alignment.Top
) {
Surface(
shape = RoundedCornerShape(CloserRadii.Tile),
color = colors.accent.copy(alpha = 0.16f),
modifier = Modifier.size(52.dp)
) {
Box(contentAlignment = Alignment.Center) {
Icon(
imageVector = Icons.Filled.Favorite,
contentDescription = null,
tint = colors.deep,
modifier = Modifier.size(26.dp)
)
}
}
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
if (showTonightPartnerArt) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
text = titleOverride,
style = MaterialTheme.typography.headlineSmall.copy(fontWeight = FontWeight.SemiBold),
@ -820,6 +814,46 @@ private fun PrimaryHomeActionCard(
overflow = TextOverflow.Ellipsis
)
}
} else {
Row(
horizontalArrangement = Arrangement.spacedBy(14.dp),
verticalAlignment = Alignment.Top
) {
Surface(
shape = RoundedCornerShape(CloserRadii.Tile),
color = colors.accent.copy(alpha = 0.16f),
modifier = Modifier.size(52.dp)
) {
Box(contentAlignment = Alignment.Center) {
Icon(
imageVector = Icons.Filled.Favorite,
contentDescription = null,
tint = colors.deep,
modifier = Modifier.size(26.dp)
)
}
}
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
text = titleOverride,
style = MaterialTheme.typography.headlineSmall.copy(fontWeight = FontWeight.SemiBold),
color = MaterialTheme.colorScheme.onSurface,
maxLines = 3,
overflow = TextOverflow.Ellipsis
)
Text(
text = bodyOverride,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 4,
overflow = TextOverflow.Ellipsis
)
}
}
}
HomePulseStrip(stats = stats, streakCount = streakCount)
@ -1410,6 +1444,100 @@ fun HomeScreenPreview() {
)
}
/**
* Debug-only: renders the paired Home from mock state so the paired experience can be viewed
* without actually pairing two devices. No data sources, no Firestore pure UI preview.
* Reachable from Settings "Paired home (debug)" in debug builds only.
*/
@Composable
fun PairedHomePreviewScreen(onNavigate: (String) -> Unit = {}) {
val demoState = HomeUiState(
isLoading = false,
isPaired = true,
coupleId = "demo",
partnerName = "Sofia",
streakCount = 12,
unreadActivityCount = 2,
dailyQuestion = Question(
id = "demo",
text = "What's something I did recently that made you feel loved?",
category = "emotional_intimacy",
depthLevel = 2
),
dailyQuestionState = DailyQuestionState.PARTNER_ANSWERED_USER_PENDING,
hasPartnerAnsweredToday = true,
partnerAnsweredQuestionId = "demo",
answerStats = HomeAnswerStats(total = 24, revealed = 18, private = 6),
primaryAction = HomeAction(
eyebrow = "Tonight",
title = "Sofia answered. Your turn.",
body = "She shared what's on her heart tonight — open the question and answer back.",
cta = "Answer now",
target = HomeActionTarget.DailyQuestion,
tone = HomeActionTone.Daily
),
secondaryActions = listOf(
HomeAction(
eyebrow = "Keep playing",
title = "Question packs",
body = "Fresh prompts for the two of you.",
cta = "Browse packs",
target = HomeActionTarget.QuestionPacks,
tone = HomeActionTone.Pack
),
HomeAction(
eyebrow = "Look back",
title = "Your answers",
body = "Revisit the moments you've shared.",
cta = "Open history",
target = HomeActionTarget.AnswerHistory,
tone = HomeActionTone.Reflection
)
),
pendingActions = listOf(
PendingActionCard(
title = "Sofia is waiting to play",
subtitle = "A game is ready for you both",
priority = 1,
target = HomeActionTarget.Game
)
),
categories = listOf(
HomeCategorySummary(
category = QuestionCategory(
id = "communication", displayName = "Communication",
description = "", access = "mixed", iconName = "chat"
),
questionCount = 250
),
HomeCategorySummary(
category = QuestionCategory(
id = "trust", displayName = "Trust",
description = "", access = "mixed", iconName = "heart"
),
questionCount = 250
),
HomeCategorySummary(
category = QuestionCategory(
id = "intimacy", displayName = "Intimacy",
description = "", access = "mixed", iconName = "heart"
),
questionCount = 180
)
)
)
Box(modifier = Modifier.fillMaxSize()) {
HomeContent(
state = demoState,
snackbarHostState = remember { SnackbarHostState() },
onNavigate = onNavigate,
onDailyQuestion = {}, onPacks = {}, onCategory = {}, onHistory = {},
onSettings = {}, onInvite = {}, onAcceptInvite = {}, onReminder = {},
onReveal = {}, onFollowUp = {}, onRefresh = {}, onPartner = {}
)
}
}
@Composable
internal fun StreakMilestoneDialog(
milestone: Int,

View File

@ -474,6 +474,13 @@ fun SettingsScreen(
onClick = { onNavigate(AppRoute.ART_PREVIEW) }
)
SettingsSectionDivider()
SettingsRow(
icon = Icons.Filled.Favorite,
label = "Paired home (debug)",
subtitle = "Preview the paired Home without pairing",
onClick = { onNavigate(AppRoute.PAIRED_HOME_PREVIEW) }
)
SettingsSectionDivider()
}
SettingsRow(
icon = Icons.Filled.Done,

Binary file not shown.

After

Width:  |  Height:  |  Size: 640 KiB

View File

@ -395,32 +395,48 @@ service cloud.firestore {
}
}
// Date swipes: per-couple, per-date partner swipe state.
// Date swipes: per-couple, per-date partner swipe state. The action is E2E
// ciphertext so the server can't read date preferences; only swipedAt is plaintext.
match /date_swipes/{dateIdeaId} {
// Read: both couple members can read the shared swipe document.
// Read: both couple members can read the shared swipe document (ciphertext).
allow read: if isCouplesMember(coupleId);
// Create/Update: each member can only write their own action entry.
// The payload must contain an actions.{uid} object with a valid action.
allow create, update: if isCouplesMember(coupleId)
// The path to the current user's action must exist and be the only action written
// Create (doc doesn't exist yet): only the caller's own entry may be present,
// and the action must be ciphertext.
allow create: if isCouplesMember(coupleId)
&& request.resource.data.keys().hasOnly(['actions'])
&& request.resource.data.actions.keys().hasOnly([request.auth.uid])
&& request.resource.data.actions[request.auth.uid].keys().hasOnly(['action', 'swipedAt'])
&& isValidSwipeAction(request.resource.data.actions[request.auth.uid].action)
&& request.resource.data.actions[request.auth.uid].action != null
&& request.resource.data.actions[request.auth.uid].swipedAt is timestamp;
&& isCiphertext(request.resource.data.actions[request.auth.uid].action)
&& request.resource.data.actions[request.auth.uid].swipedAt is number;
// Update (partner may already have an entry): a merge write exposes the whole
// post-write doc, so diff to ensure ONLY the caller's own entry changed.
allow update: if isCouplesMember(coupleId)
&& request.resource.data.keys().hasOnly(['actions'])
&& request.resource.data.actions[request.auth.uid].keys().hasOnly(['action', 'swipedAt'])
&& isCiphertext(request.resource.data.actions[request.auth.uid].action)
&& request.resource.data.actions[request.auth.uid].swipedAt is number
&& resource.data.actions.diff(request.resource.data.actions).affectedKeys().hasOnly([request.auth.uid]);
// Delete: server-only (admin SDK). Admin SDK bypasses rules.
allow delete: if false;
}
// Date matches: revealed mutual love matches.
// Clients can read; creation of a match is performed by a Cloud Function
// after both partners have swiped 'love'. Direct client writes are denied.
// Date matches: revealed mutual-love matches (matchId == dateIdeaId).
// Server is blind to encrypted swipes, so the client writes the marker when it
// detects mutual love; a Cloud Function fires the notification on create. The
// creator must be one of the two matched members. fcmNotified flips server-side.
match /date_matches/{matchId} {
allow read: if isCouplesMember(coupleId);
allow create, update, delete: if false;
allow create: if isCouplesMember(coupleId)
&& request.resource.data.keys().hasOnly(['dateIdeaId', 'revealedAt', 'matchedBy', 'fcmNotified'])
&& request.resource.data.dateIdeaId is string
&& request.resource.data.matchedBy is list
&& request.resource.data.matchedBy.size() == 2
&& request.auth.uid in request.resource.data.matchedBy
&& request.resource.data.fcmNotified == false;
allow update, delete: if false;
}
// Date plan preferences: per-partner preferences for building date plans.

View File

@ -42,14 +42,19 @@ export const revenueCatWebhook = functions.https.onRequest(async (req, res) => {
return
}
// Acknowledge immediately to avoid RevenueCat retries.
res.status(200).json({ received: true })
// Security review Batch 2: process BEFORE acking. Previously we returned 200 up front
// and only logged failures, so a failed entitlement sync was silently dropped (no retry).
// RevenueCat retries on non-2xx, and applyEntitlementEvent is idempotent (it sets state),
// so returning 500 on failure recovers the event safely.
try {
await applyEntitlementEvent(event)
} catch (err) {
console.error('[revenueCatWebhook] entitlement sync failed:', err)
res.status(500).json({ error: 'processing_failed' })
return
}
res.status(200).json({ received: true })
})
class ConfigError extends Error {}

View File

@ -92,6 +92,12 @@ export const createInviteCallable = functions.https.onCall(async (data: any, con
if (!clientCode) {
throw new functions.https.HttpsError('invalid-argument', 'code is required.')
}
// Security review Batch 2: validate the code is exactly the 6-char Crockford-style
// alphabet the client generates (CODE_CHARS, no I/O/0/1). Rejects malformed/oversized
// codes and anything that could be abused as the document id.
if (!/^[A-HJ-NP-Z2-9]{6}$/.test(clientCode)) {
throw new functions.https.HttpsError('invalid-argument', 'code must be 6 valid characters.')
}
if (wrappedCoupleKey == null || kdfSalt == null || kdfParams == null || encryptedRecoveryPhrase == null) {
throw new functions.https.HttpsError(
'invalid-argument',

View File

@ -34,27 +34,46 @@ export const leaveCoupleCallable = functions.https.onCall(async (_data, context)
}
const coupleRef = db.collection('couples').doc(coupleId)
const coupleDoc = await coupleRef.get()
if (!coupleDoc.exists) {
// Couple doc gone — just clear caller's field.
await db.collection('users').doc(callerId).update({ coupleId: null })
return { success: true }
}
// Security review Batch 2: do the membership check, member-clearing, and couple-doc
// delete in one transaction so two partners leaving concurrently can't clobber state.
// Critically, only clear a member's coupleId if it STILL points at this couple — a
// stale concurrent call must never wipe a coupleId set by a fresh re-pair.
const result = await db.runTransaction(async (tx) => {
const coupleSnap = await tx.get(coupleRef)
if (!coupleSnap.exists) {
const callerRef = db.collection('users').doc(callerId)
const callerSnap = await tx.get(callerRef)
if (callerSnap.data()?.coupleId === coupleId) {
tx.update(callerRef, { coupleId: null })
}
return { membership: true }
}
const userIds = (coupleDoc.data()?.userIds ?? []) as string[]
if (!userIds.includes(callerId)) {
const userIds = (coupleSnap.data()?.userIds ?? []) as string[]
if (!userIds.includes(callerId)) {
return { membership: false }
}
// Reads must precede writes in a transaction: snapshot every member first.
const memberSnaps = await Promise.all(
userIds.map((uid) => tx.get(db.collection('users').doc(uid)))
)
memberSnaps.forEach((snap, i) => {
if (snap.data()?.coupleId === coupleId) {
tx.update(db.collection('users').doc(userIds[i]), { coupleId: null })
}
})
tx.delete(coupleRef)
return { membership: true }
})
if (!result.membership) {
throw new functions.https.HttpsError('permission-denied', 'Not a member of this couple.')
}
// Clear coupleId for all members atomically.
const batch = db.batch()
for (const uid of userIds) {
batch.update(db.collection('users').doc(uid), { coupleId: null })
}
await batch.commit()
// Recursively delete the couple document and every subcollection beneath it.
// Couple doc is deleted in the transaction; sweep any subcollections left behind.
// Idempotent if a concurrent caller already removed them.
await db.recursiveDelete(coupleRef)
console.log(`[leaveCoupleCallable] user ${callerId} left couple ${coupleId}`)

View File

@ -1,42 +1,24 @@
import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin'
const LOVE = 'love'
interface SwipeEntry {
action?: string
swipedAt?: number
}
/**
* Creates a revealed date match when both partners have swiped LOVE on the
* same date idea.
* Fires the "It's a match!" notification when a date match is created.
*
* Trigger: couples/{coupleId}/date_swipes/{dateIdeaId} (onWrite)
* Trigger: couples/{coupleId}/date_matches/{dateIdeaId} (onCreate)
*
* The `date_matches` collection is server-write-only Firestore rules deny all
* client writes (`allow create, update, delete: if false`). This trigger is
* therefore the single source of truth for match creation. The client only
* records swipes and observes `date_matches` for the result.
* Date swipes are E2E-encrypted, so the server can no longer detect mutual love.
* Mutual-match detection now happens client-side (whichever partner records the
* second LOVE writes the match marker, validated by Firestore rules). This trigger
* only sends the push to both partners it never reads swipe content.
*
* Idempotency: the match document id is the date idea id and creation runs in a
* transaction, so repeated swipes on the same idea and concurrent invocations
* never produce a duplicate match.
* Idempotency: `fcmNotified` is claimed in a transaction so concurrent invocations
* (or a client retry) never double-send. The match doc id is the date idea id, so
* the marker itself is already de-duplicated by the client transaction + rules.
*/
export const createDateMatchOnMutualLove = functions.firestore
.document('couples/{coupleId}/date_swipes/{dateIdeaId}')
.onWrite(async (change, context) => {
const after = change.after.data()
if (!after) return // swipe document was deleted
const actions = (after.actions ?? {}) as Record<string, SwipeEntry>
const lovedBy = Object.entries(actions)
.filter(([, entry]) => entry?.action === LOVE)
.map(([uid]) => uid)
.sort()
// A match needs both partners to have loved the same idea.
if (lovedBy.length < 2) return
export const notifyOnDateMatch = functions.firestore
.document('couples/{coupleId}/date_matches/{dateIdeaId}')
.onCreate(async (snap, context) => {
if (!snap.exists) return
const { coupleId, dateIdeaId } = context.params as {
coupleId: string
@ -44,24 +26,9 @@ export const createDateMatchOnMutualLove = functions.firestore
}
const db = admin.firestore()
const matchRef = db
.collection('couples')
.doc(coupleId)
.collection('date_matches')
.doc(dateIdeaId)
const matchRef = snap.ref
await db.runTransaction(async (tx) => {
const existing = await tx.get(matchRef)
if (existing.exists) return
tx.set(matchRef, {
dateIdeaId,
matchedBy: lovedBy,
revealedAt: admin.firestore.FieldValue.serverTimestamp(),
fcmNotified: false,
})
})
// Atomically claim FCM send so concurrent trigger invocations don't double-send.
// Atomically claim the FCM send so concurrent invocations don't double-send.
const shouldSend = await db.runTransaction(async (tx) => {
const doc = await tx.get(matchRef)
if (!doc.exists || doc.data()?.fcmNotified === true) return false
@ -69,11 +36,11 @@ export const createDateMatchOnMutualLove = functions.firestore
return true
})
if (shouldSend) {
const coupleDoc = await db.collection('couples').doc(coupleId).get()
const userIds = (coupleDoc.data()?.userIds ?? []) as string[]
await Promise.all(userIds.map((uid) => notifyDateMatch(db, uid, coupleId, dateIdeaId)))
}
if (!shouldSend) return
const coupleDoc = await db.collection('couples').doc(coupleId).get()
const userIds = (coupleDoc.data()?.userIds ?? []) as string[]
await Promise.all(userIds.map((uid) => notifyDateMatch(db, uid, coupleId, dateIdeaId)))
})
async function notifyDateMatch(

View File

@ -1,4 +1,3 @@
import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin'
// Initialize the Admin SDK once for every function in this codebase.
@ -22,7 +21,7 @@ export {
export { sendDailyQuestionProactiveReminder } from './notifications/dailyQuestionReminder'
export { sendReengagementReminder } from './notifications/reengagement'
export { checkDeviceIntegrity } from './security/checkDeviceIntegrity'
export { createDateMatchOnMutualLove } from './dates/createDateMatch'
export { notifyOnDateMatch } from './dates/createDateMatch'
export {
assignDailyQuestion,
assignDailyQuestionCallable,
@ -38,10 +37,7 @@ export { scheduledOutcomesReminder } from './couples/scheduledOutcomesReminder'
export { onUserDelete } from './users/onUserDelete'
export { onGameSessionUpdate } from './games/onGameSessionUpdate'
/**
* Basic health check callable.
* Useful for verifying function deployment and firebase-tools wiring.
*/
export const health = functions.https.onRequest((req, res) => {
res.status(200).json({ status: 'ok' })
})
// NOTE (security review Batch 2): the unauthenticated public `health` HTTP endpoint
// was removed to shrink attack surface. Deployment can be verified via
// `firebase functions:list`. If an uptime probe is ever needed, re-add it behind
// auth / a shared secret rather than as an open endpoint.

View File

@ -98,7 +98,18 @@ export const assignDailyQuestionCallable = functions.https.onCall(async (data: a
throw new functions.https.HttpsError('permission-denied', 'Caller is not a couple member.')
}
const date = data?.date && typeof data.date === 'string' ? data.date : cstDateString()
// Security review Batch 2: constrain the client-supplied date. Only today's CST date
// may be assigned on demand — this blocks creating arbitrary past/future daily_question
// docs and, combined with create()'s ALREADY_EXISTS guard, caps it to one per day
// (effective rate limit; repeat calls return already-exists).
const today = cstDateString()
const date = data?.date && typeof data.date === 'string' ? data.date : today
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
throw new functions.https.HttpsError('invalid-argument', 'date must be YYYY-MM-DD.')
}
if (date !== today) {
throw new functions.https.HttpsError('invalid-argument', 'Daily question can only be assigned for today.')
}
const questionId = await pickRandomQuestionId()
if (!questionId) {
throw new functions.https.HttpsError('internal', 'No active questions available.')

View File

@ -28,6 +28,15 @@ export const onAnswerWritten = functions.firestore
}
const userIds = (coupleDoc.data()?.userIds ?? []) as string[]
// Security review Batch 2: re-verify the writer actually belongs to this couple
// before sending a cross-user notification. Firestore rules already enforce this,
// but defense-in-depth ensures a stray/forged answer doc can't trigger a partner ping.
if (!userIds.includes(userId)) {
console.warn(`[onAnswerWritten] writer ${userId} is not a member of couple ${coupleId}`)
return
}
const partnerId = userIds.find((uid) => uid !== userId)
if (!partnerId) {
console.warn(`[onAnswerWritten] no partner found for couple ${coupleId}`)