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 # 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.* > *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. **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 navController = rememberNavController()
val navBackStackEntry by navController.currentBackStackEntryAsState() val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = navBackStackEntry?.destination?.route 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 val shellTitle = currentRoute
?.takeIf { it in shellBackRoutes } ?.takeIf { it in shellBackRoutes }
?.let(AppRoute::titleFor) ?.let(AppRoute::titleFor)
// Tab-switch semantics: pop to the graph start, keep a single instance, and // Tab-switch semantics: pop to the graph start and keep a single instance, so a
// save/restore each tab's own back stack. Every navigation to a top-level // tab is never pushed on top of another tab. We deliberately DON'T save/restore
// route must go through this so a tab is never pushed on top of another tab. // 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 -> val selectTab: (String) -> Unit = { route ->
navController.navigate(route) { navController.navigate(route) {
popUpTo(navController.graph.findStartDestination().id) { popUpTo(navController.graph.findStartDestination().id) {
saveState = true saveState = false
} }
launchSingleTop = true launchSingleTop = true
restoreState = true restoreState = false
} }
} }
val navigateBackOrHome: () -> Unit = { val navigateBackOrHome: () -> Unit = {
@ -476,6 +481,9 @@ fun AppNavigation(
composable(route = AppRoute.ART_PREVIEW) { composable(route = AppRoute.ART_PREVIEW) {
app.closer.ui.debug.ArtPreviewScreen(onNavigate = navigateRoute) 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 YOUR_PROGRESS = "your_progress"
const val ACTIVITY = "activity" const val ACTIVITY = "activity"
const val ART_PREVIEW = "art_preview" const val ART_PREVIEW = "art_preview"
const val PAIRED_HOME_PREVIEW = "paired_home_preview"
const val PAIRING_SUCCESS = "pairing_success/{coupleId}" const val PAIRING_SUCCESS = "pairing_success/{coupleId}"
fun pairingSuccess(coupleId: String) = "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.Flow
import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.tasks.await
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
import kotlin.coroutines.resume import kotlin.coroutines.resume
@ -27,9 +28,11 @@ import kotlin.coroutines.resumeWithException
* "matchedBy": ["userA", "userB"] * "matchedBy": ["userA", "userB"]
* } * }
* *
* Security rules deny client create/update/delete; matches are written by a * Under E2EE the server can no longer read swipe content, so mutual-love
* Cloud Function after both partners swipe [SwipeAction.LOVE]. The client layer * detection moved client-side: when a member records the second LOVE on an idea,
* still exposes a create helper for the function trigger path. * 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 @Singleton
class FirestoreDateMatchDataSource @Inject constructor(private val db: FirebaseFirestore) { 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) db.collection(FirestoreCollections.COUPLES).document(coupleId)
.collection(FirestoreCollections.Couples.DATE_MATCHES) .collection(FirestoreCollections.Couples.DATE_MATCHES)
suspend fun createMatch(coupleId: String, dateIdeaId: String, matchedBy: List<String>): String { /**
val doc = matchesRef(coupleId).document() * Create the match marker for [dateIdeaId] iff it doesn't already exist. Keyed by
doc.set( * dateIdeaId and guarded by a transaction so both partners loving near-simultaneously
mapOf( * (and repeated swipes) never produce a duplicate. `fcmNotified` lets the notify
"dateIdeaId" to dateIdeaId, * function claim the push exactly once.
"revealedAt" to FieldValue.serverTimestamp(), */
"matchedBy" to matchedBy suspend fun createMatchIfAbsent(coupleId: String, dateIdeaId: String, matchedBy: List<String>) {
) val ref = matchesRef(coupleId).document(dateIdeaId)
).voidAwait() db.runTransaction<Unit> { tx ->
return doc.id 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? { suspend fun findMatchByDateIdeaId(coupleId: String, dateIdeaId: String): DateMatch? {
@ -76,12 +90,6 @@ class FirestoreDateMatchDataSource @Inject constructor(private val db: FirebaseF
.addOnFailureListener { cont.resumeWithException(it) } .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 ────────────────────────────────────────────────────────────── // ─── Mapper ──────────────────────────────────────────────────────────────
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")

View File

@ -1,9 +1,11 @@
package app.closer.data.remote 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.DateSwipe
import app.closer.domain.model.SwipeAction import app.closer.domain.model.SwipeAction
import com.google.crypto.tink.Aead
import com.google.firebase.firestore.DocumentSnapshot import com.google.firebase.firestore.DocumentSnapshot
import com.google.firebase.firestore.FieldValue
import com.google.firebase.firestore.FirebaseFirestore import com.google.firebase.firestore.FirebaseFirestore
import com.google.firebase.firestore.SetOptions import com.google.firebase.firestore.SetOptions
import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.channels.awaitClose
@ -21,27 +23,35 @@ import kotlin.coroutines.resumeWithException
* Path layout: * Path layout:
* couples/{coupleId}/date_swipes/{dateId} * 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": { * "actions": {
* "userA": { "action": "love", "swipedAt": 12345 }, * "userA": { "action": "enc:v1:…", "swipedAt": 12345 },
* "userB": { "action": "skip", "swipedAt": 12346 } * "userB": { "action": "enc:v1:…", "swipedAt": 12346 }
* } * }
* } * }
* *
* Security rules ensure only couple members can read/write, and each user can * Security rules ensure only couple members can read/write, each user can only
* only write their own action entry. * 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 @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) = private fun swipesRef(coupleId: String) =
db.collection(FirestoreCollections.COUPLES).document(coupleId) db.collection(FirestoreCollections.COUPLES).document(coupleId)
.collection(FirestoreCollections.Couples.DATE_SWIPES) .collection(FirestoreCollections.Couples.DATE_SWIPES)
suspend fun recordSwipe(coupleId: String, swipe: DateSwipe) { suspend fun recordSwipe(coupleId: String, swipe: DateSwipe) {
val aead = encryptionManager.requireAead(coupleId)
val path = swipesRef(coupleId).document(swipe.dateIdeaId) val path = swipesRef(coupleId).document(swipe.dateIdeaId)
val entry = mapOf( val entry = mapOf(
"action" to swipe.action.toFirestoreValue(), "action" to fieldEncryptor.encrypt(swipe.action.toFirestoreValue(), aead, coupleId),
"swipedAt" to swipe.swipedAt "swipedAt" to swipe.swipedAt
) )
path.set( path.set(
@ -50,9 +60,20 @@ class FirestoreDateSwipeDataSource @Inject constructor(private val db: FirebaseF
).voidAwait() ).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? { suspend fun getSwipe(coupleId: String, dateIdeaId: String, userId: String): DateSwipe? {
val aead = encryptionManager.aeadFor(coupleId)
val snap = swipesRef(coupleId).document(dateIdeaId).getDoc() val snap = swipesRef(coupleId).document(dateIdeaId).getDoc()
return snap.toDateSwipe(dateIdeaId, userId) return snap.toDateSwipe(dateIdeaId, userId, aead, coupleId)
} }
fun observeOwnSwipes( fun observeOwnSwipes(
@ -70,7 +91,8 @@ class FirestoreDateSwipeDataSource @Inject constructor(private val db: FirebaseF
val listener = swipesRef(coupleId) val listener = swipesRef(coupleId)
.addSnapshotListener { snap, err -> .addSnapshotListener { snap, err ->
if (err != null || snap == null) return@addSnapshotListener 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) trySend(swipes)
} }
awaitClose { listener.remove() } awaitClose { listener.remove() }
@ -80,8 +102,9 @@ class FirestoreDateSwipeDataSource @Inject constructor(private val db: FirebaseF
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
private suspend fun getAllSwipesForUser(coupleId: String, userId: String): List<DateSwipe> { private suspend fun getAllSwipesForUser(coupleId: String, userId: String): List<DateSwipe> {
val aead = encryptionManager.aeadFor(coupleId)
val snap = swipesRef(coupleId).getQuery() 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() = 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> { suspend fun getAllSwipesForDate(coupleId: String, dateIdeaId: String): List<DateSwipe> {
val aead = encryptionManager.aeadFor(coupleId)
val snap = swipesRef(coupleId).document(dateIdeaId).getDoc() val snap = swipesRef(coupleId).document(dateIdeaId).getDoc()
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
val actions = snap.get("actions") as? Map<String, Map<String, Any>> ?: emptyMap() 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( DateSwipe(
dateIdeaId = dateIdeaId, dateIdeaId = dateIdeaId,
userId = uid, 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 swipedAt = (data["swipedAt"] as? Number)?.toLong() ?: 0L
) )
} }
@ -123,13 +147,18 @@ class FirestoreDateSwipeDataSource @Inject constructor(private val db: FirebaseF
// ─── Mappers ───────────────────────────────────────────────────────────── // ─── Mappers ─────────────────────────────────────────────────────────────
@Suppress("UNCHECKED_CAST") @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 actions = get("actions") as? Map<String, Map<String, Any>> ?: return null
val data = actions[userId] ?: return null val data = actions[userId] ?: return null
return DateSwipe( return DateSwipe(
dateIdeaId = dateIdeaId, dateIdeaId = dateIdeaId,
userId = userId, 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 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 * shipped as a Cloud Function seed. Firestore is the source of truth for
* per-partner swipes and revealed matches. * per-partner swipes and revealed matches.
* *
* Mutual match detection is performed server-side by the * Swipes are E2E-encrypted (the server can't read them), so mutual-match detection
* `createDateMatchOnMutualLove` Cloud Function: the `date_matches` collection is * is done client-side: after recording a LOVE, [recordSwipe] reads everyone's
* server-write-only (Firestore rules deny client writes), so when both partners * (decrypted) swipes for that idea and, if both partners loved it, writes the match
* swipe [SwipeAction.LOVE] on an idea the function creates the match and it * marker via [FirestoreDateMatchDataSource.createMatchIfAbsent] (idempotent). A
* arrives on the client via [observeMatches]. [recordSwipe] therefore only * Cloud Function then fires the "It's a match!" notification on create. The result
* records the swipe and always resolves with a null match. * still arrives on every device via [observeMatches]. [recordSwipe] resolves with a
* null match (the UI consumes matches through [observeMatches]).
*/ */
@Singleton @Singleton
class DateMatchRepositoryImpl @Inject constructor( class DateMatchRepositoryImpl @Inject constructor(
@ -47,7 +48,19 @@ class DateMatchRepositoryImpl @Inject constructor(
swipedAt = System.currentTimeMillis() swipedAt = System.currentTimeMillis()
) )
swipeDataSource.recordSwipe(coupleId, swipe) 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 null
} }

View File

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

View File

@ -7,6 +7,8 @@ import androidx.credentials.CredentialManager
import androidx.credentials.CustomCredential import androidx.credentials.CustomCredential
import androidx.credentials.GetCredentialRequest import androidx.credentials.GetCredentialRequest
import androidx.credentials.exceptions.GetCredentialCancellationException 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.GetSignInWithGoogleOption
import com.google.android.libraries.identity.googleid.GoogleIdTokenCredential import com.google.android.libraries.identity.googleid.GoogleIdTokenCredential
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@ -208,7 +210,10 @@ fun LoginScreen(
} }
} catch (_: GetCredentialCancellationException) { } catch (_: GetCredentialCancellationException) {
// user dismissed — do nothing // 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) { } catch (e: Exception) {
Log.w("GoogleSignIn", "Google sign-in failed", e)
viewModel.reportError("Google sign-in failed. Please try again.") 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.CustomCredential
import androidx.credentials.GetCredentialRequest import androidx.credentials.GetCredentialRequest
import androidx.credentials.exceptions.GetCredentialCancellationException 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.GetSignInWithGoogleOption
import com.google.android.libraries.identity.googleid.GoogleIdTokenCredential import com.google.android.libraries.identity.googleid.GoogleIdTokenCredential
import androidx.compose.foundation.background import androidx.compose.foundation.background
@ -226,7 +228,10 @@ fun SignUpScreen(
} }
} catch (_: GetCredentialCancellationException) { } catch (_: GetCredentialCancellationException) {
// user dismissed — do nothing // 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) { } catch (e: Exception) {
Log.w("GoogleSignIn", "Google sign-up failed", e)
viewModel.reportError("Google sign-up failed. Please try again.") viewModel.reportError("Google sign-up failed. Please try again.")
} }
} }

View File

@ -679,6 +679,9 @@ private fun PrimaryHomeActionCard(
) { ) {
val colors = action.tone.actionColors() val colors = action.tone.actionColors()
val isDark = isCloserDarkTheme() 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 // 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, // 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(
text = "Got 5 min?", text = "Got 5 min?",
style = MaterialTheme.typography.labelSmall.copy(fontWeight = FontWeight.SemiBold), style = MaterialTheme.typography.labelSmall.copy(fontWeight = FontWeight.SemiBold),
@ -782,29 +797,8 @@ private fun PrimaryHomeActionCard(
) )
} }
Row( if (showTonightPartnerArt) {
horizontalArrangement = Arrangement.spacedBy(14.dp), Column(verticalArrangement = Arrangement.spacedBy(8.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(
text = titleOverride, text = titleOverride,
style = MaterialTheme.typography.headlineSmall.copy(fontWeight = FontWeight.SemiBold), style = MaterialTheme.typography.headlineSmall.copy(fontWeight = FontWeight.SemiBold),
@ -820,6 +814,46 @@ private fun PrimaryHomeActionCard(
overflow = TextOverflow.Ellipsis 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) 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 @Composable
internal fun StreakMilestoneDialog( internal fun StreakMilestoneDialog(
milestone: Int, milestone: Int,

View File

@ -474,6 +474,13 @@ fun SettingsScreen(
onClick = { onNavigate(AppRoute.ART_PREVIEW) } onClick = { onNavigate(AppRoute.ART_PREVIEW) }
) )
SettingsSectionDivider() 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( SettingsRow(
icon = Icons.Filled.Done, 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} { 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); allow read: if isCouplesMember(coupleId);
// Create/Update: each member can only write their own action entry. // Create (doc doesn't exist yet): only the caller's own entry may be present,
// The payload must contain an actions.{uid} object with a valid action. // and the action must be ciphertext.
allow create, update: if isCouplesMember(coupleId) allow create: if isCouplesMember(coupleId)
// The path to the current user's action must exist and be the only action written
&& request.resource.data.keys().hasOnly(['actions']) && request.resource.data.keys().hasOnly(['actions'])
&& request.resource.data.actions.keys().hasOnly([request.auth.uid]) && request.resource.data.actions.keys().hasOnly([request.auth.uid])
&& request.resource.data.actions[request.auth.uid].keys().hasOnly(['action', 'swipedAt']) && request.resource.data.actions[request.auth.uid].keys().hasOnly(['action', 'swipedAt'])
&& isValidSwipeAction(request.resource.data.actions[request.auth.uid].action) && isCiphertext(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 number;
&& request.resource.data.actions[request.auth.uid].swipedAt is timestamp;
// 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. // Delete: server-only (admin SDK). Admin SDK bypasses rules.
allow delete: if false; allow delete: if false;
} }
// Date matches: revealed mutual love matches. // Date matches: revealed mutual-love matches (matchId == dateIdeaId).
// Clients can read; creation of a match is performed by a Cloud Function // Server is blind to encrypted swipes, so the client writes the marker when it
// after both partners have swiped 'love'. Direct client writes are denied. // 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} { match /date_matches/{matchId} {
allow read: if isCouplesMember(coupleId); 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. // 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 return
} }
// Acknowledge immediately to avoid RevenueCat retries. // Security review Batch 2: process BEFORE acking. Previously we returned 200 up front
res.status(200).json({ received: true }) // 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 { try {
await applyEntitlementEvent(event) await applyEntitlementEvent(event)
} catch (err) { } catch (err) {
console.error('[revenueCatWebhook] entitlement sync failed:', 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 {} class ConfigError extends Error {}

View File

@ -92,6 +92,12 @@ export const createInviteCallable = functions.https.onCall(async (data: any, con
if (!clientCode) { if (!clientCode) {
throw new functions.https.HttpsError('invalid-argument', 'code is required.') 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) { if (wrappedCoupleKey == null || kdfSalt == null || kdfParams == null || encryptedRecoveryPhrase == null) {
throw new functions.https.HttpsError( throw new functions.https.HttpsError(
'invalid-argument', '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 coupleRef = db.collection('couples').doc(coupleId)
const coupleDoc = await coupleRef.get()
if (!coupleDoc.exists) { // Security review Batch 2: do the membership check, member-clearing, and couple-doc
// Couple doc gone — just clear caller's field. // delete in one transaction so two partners leaving concurrently can't clobber state.
await db.collection('users').doc(callerId).update({ coupleId: null }) // Critically, only clear a member's coupleId if it STILL points at this couple — a
return { success: true } // 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[] const userIds = (coupleSnap.data()?.userIds ?? []) as string[]
if (!userIds.includes(callerId)) { 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.') throw new functions.https.HttpsError('permission-denied', 'Not a member of this couple.')
} }
// Clear coupleId for all members atomically. // Couple doc is deleted in the transaction; sweep any subcollections left behind.
const batch = db.batch() // Idempotent if a concurrent caller already removed them.
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.
await db.recursiveDelete(coupleRef) await db.recursiveDelete(coupleRef)
console.log(`[leaveCoupleCallable] user ${callerId} left couple ${coupleId}`) console.log(`[leaveCoupleCallable] user ${callerId} left couple ${coupleId}`)

View File

@ -1,42 +1,24 @@
import * as functions from 'firebase-functions' import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin' 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 * Fires the "It's a match!" notification when a date match is created.
* same date idea.
* *
* 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 * Date swipes are E2E-encrypted, so the server can no longer detect mutual love.
* client writes (`allow create, update, delete: if false`). This trigger is * Mutual-match detection now happens client-side (whichever partner records the
* therefore the single source of truth for match creation. The client only * second LOVE writes the match marker, validated by Firestore rules). This trigger
* records swipes and observes `date_matches` for the result. * 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 * Idempotency: `fcmNotified` is claimed in a transaction so concurrent invocations
* transaction, so repeated swipes on the same idea and concurrent invocations * (or a client retry) never double-send. The match doc id is the date idea id, so
* never produce a duplicate match. * the marker itself is already de-duplicated by the client transaction + rules.
*/ */
export const createDateMatchOnMutualLove = functions.firestore export const notifyOnDateMatch = functions.firestore
.document('couples/{coupleId}/date_swipes/{dateIdeaId}') .document('couples/{coupleId}/date_matches/{dateIdeaId}')
.onWrite(async (change, context) => { .onCreate(async (snap, context) => {
const after = change.after.data() if (!snap.exists) return
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
const { coupleId, dateIdeaId } = context.params as { const { coupleId, dateIdeaId } = context.params as {
coupleId: string coupleId: string
@ -44,24 +26,9 @@ export const createDateMatchOnMutualLove = functions.firestore
} }
const db = admin.firestore() const db = admin.firestore()
const matchRef = db const matchRef = snap.ref
.collection('couples')
.doc(coupleId)
.collection('date_matches')
.doc(dateIdeaId)
await db.runTransaction(async (tx) => { // Atomically claim the FCM send so concurrent invocations don't double-send.
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.
const shouldSend = await db.runTransaction(async (tx) => { const shouldSend = await db.runTransaction(async (tx) => {
const doc = await tx.get(matchRef) const doc = await tx.get(matchRef)
if (!doc.exists || doc.data()?.fcmNotified === true) return false if (!doc.exists || doc.data()?.fcmNotified === true) return false
@ -69,11 +36,11 @@ export const createDateMatchOnMutualLove = functions.firestore
return true return true
}) })
if (shouldSend) { if (!shouldSend) return
const coupleDoc = await db.collection('couples').doc(coupleId).get()
const userIds = (coupleDoc.data()?.userIds ?? []) as string[] const coupleDoc = await db.collection('couples').doc(coupleId).get()
await Promise.all(userIds.map((uid) => notifyDateMatch(db, uid, coupleId, dateIdeaId))) const userIds = (coupleDoc.data()?.userIds ?? []) as string[]
} await Promise.all(userIds.map((uid) => notifyDateMatch(db, uid, coupleId, dateIdeaId)))
}) })
async function notifyDateMatch( async function notifyDateMatch(

View File

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

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.') 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() const questionId = await pickRandomQuestionId()
if (!questionId) { if (!questionId) {
throw new functions.https.HttpsError('internal', 'No active questions available.') 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[] 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) const partnerId = userIds.find((uid) => uid !== userId)
if (!partnerId) { if (!partnerId) {
console.warn(`[onAnswerWritten] no partner found for couple ${coupleId}`) console.warn(`[onAnswerWritten] no partner found for couple ${coupleId}`)