diff --git a/README.md b/README.md index 8dddcf07..e403b6db 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/app/src/main/java/app/closer/core/navigation/AppNavigation.kt b/app/src/main/java/app/closer/core/navigation/AppNavigation.kt index 564755a7..ae192341 100644 --- a/app/src/main/java/app/closer/core/navigation/AppNavigation.kt +++ b/app/src/main/java/app/closer/core/navigation/AppNavigation.kt @@ -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) + } } } } diff --git a/app/src/main/java/app/closer/core/navigation/AppRoute.kt b/app/src/main/java/app/closer/core/navigation/AppRoute.kt index 2f297888..1c23b1f0 100644 --- a/app/src/main/java/app/closer/core/navigation/AppRoute.kt +++ b/app/src/main/java/app/closer/core/navigation/AppRoute.kt @@ -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" diff --git a/app/src/main/java/app/closer/data/remote/FirestoreDateMatchDataSource.kt b/app/src/main/java/app/closer/data/remote/FirestoreDateMatchDataSource.kt index 883fb480..d122cce7 100644 --- a/app/src/main/java/app/closer/data/remote/FirestoreDateMatchDataSource.kt +++ b/app/src/main/java/app/closer/data/remote/FirestoreDateMatchDataSource.kt @@ -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 { - 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) { + val ref = matchesRef(coupleId).document(dateIdeaId) + db.runTransaction { 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.voidAwait() = - suspendCancellableCoroutine { cont -> - addOnSuccessListener { cont.resume(Unit) } - addOnFailureListener { cont.resumeWithException(it) } - } - // ─── Mapper ────────────────────────────────────────────────────────────── @Suppress("UNCHECKED_CAST") diff --git a/app/src/main/java/app/closer/data/remote/FirestoreDateSwipeDataSource.kt b/app/src/main/java/app/closer/data/remote/FirestoreDateSwipeDataSource.kt index 708ead25..c43ba219 100644 --- a/app/src/main/java/app/closer/data/remote/FirestoreDateSwipeDataSource.kt +++ b/app/src/main/java/app/closer/data/remote/FirestoreDateSwipeDataSource.kt @@ -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 { + 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 { + val aead = encryptionManager.aeadFor(coupleId) val snap = swipesRef(coupleId).document(dateIdeaId).getDoc() @Suppress("UNCHECKED_CAST") val actions = snap.get("actions") as? Map> ?: 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> ?: 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 ) } diff --git a/app/src/main/java/app/closer/data/repository/DateMatchRepositoryImpl.kt b/app/src/main/java/app/closer/data/repository/DateMatchRepositoryImpl.kt index 8c0ff342..5da6f170 100644 --- a/app/src/main/java/app/closer/data/repository/DateMatchRepositoryImpl.kt +++ b/app/src/main/java/app/closer/data/repository/DateMatchRepositoryImpl.kt @@ -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 } diff --git a/app/src/main/java/app/closer/data/security/PlayIntegrityChecker.kt b/app/src/main/java/app/closer/data/security/PlayIntegrityChecker.kt index f02834c8..5f7d9900 100644 --- a/app/src/main/java/app/closer/data/security/PlayIntegrityChecker.kt +++ b/app/src/main/java/app/closer/data/security/PlayIntegrityChecker.kt @@ -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) } } } diff --git a/app/src/main/java/app/closer/ui/auth/LoginScreen.kt b/app/src/main/java/app/closer/ui/auth/LoginScreen.kt index 5c6d9564..5d2a0142 100644 --- a/app/src/main/java/app/closer/ui/auth/LoginScreen.kt +++ b/app/src/main/java/app/closer/ui/auth/LoginScreen.kt @@ -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.") } } diff --git a/app/src/main/java/app/closer/ui/auth/SignUpScreen.kt b/app/src/main/java/app/closer/ui/auth/SignUpScreen.kt index 1c3f266f..cbab4170 100644 --- a/app/src/main/java/app/closer/ui/auth/SignUpScreen.kt +++ b/app/src/main/java/app/closer/ui/auth/SignUpScreen.kt @@ -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.") } } diff --git a/app/src/main/java/app/closer/ui/home/HomeScreen.kt b/app/src/main/java/app/closer/ui/home/HomeScreen.kt index 122725ab..d4df5276 100644 --- a/app/src/main/java/app/closer/ui/home/HomeScreen.kt +++ b/app/src/main/java/app/closer/ui/home/HomeScreen.kt @@ -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, diff --git a/app/src/main/java/app/closer/ui/settings/SettingsScreen.kt b/app/src/main/java/app/closer/ui/settings/SettingsScreen.kt index 30ae978d..b66d8e7e 100644 --- a/app/src/main/java/app/closer/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/app/closer/ui/settings/SettingsScreen.kt @@ -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, diff --git a/app/src/main/res/drawable-nodpi/illustration_tonight_partner_prompt.png b/app/src/main/res/drawable-nodpi/illustration_tonight_partner_prompt.png new file mode 100644 index 00000000..6626deb8 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/illustration_tonight_partner_prompt.png differ diff --git a/firestore.rules b/firestore.rules index 6812eb6c..6f017531 100644 --- a/firestore.rules +++ b/firestore.rules @@ -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. diff --git a/functions/src/billing/revenueCatWebhook.ts b/functions/src/billing/revenueCatWebhook.ts index 46c8136c..eb5c138d 100644 --- a/functions/src/billing/revenueCatWebhook.ts +++ b/functions/src/billing/revenueCatWebhook.ts @@ -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 {} diff --git a/functions/src/couples/createInviteCallable.ts b/functions/src/couples/createInviteCallable.ts index 8d2631e6..021e40ff 100644 --- a/functions/src/couples/createInviteCallable.ts +++ b/functions/src/couples/createInviteCallable.ts @@ -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', diff --git a/functions/src/couples/leaveCoupleCallable.ts b/functions/src/couples/leaveCoupleCallable.ts index 669a8628..c6746c7d 100644 --- a/functions/src/couples/leaveCoupleCallable.ts +++ b/functions/src/couples/leaveCoupleCallable.ts @@ -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}`) diff --git a/functions/src/dates/createDateMatch.ts b/functions/src/dates/createDateMatch.ts index 9d5c9733..d2ae42df 100644 --- a/functions/src/dates/createDateMatch.ts +++ b/functions/src/dates/createDateMatch.ts @@ -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 - 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( diff --git a/functions/src/index.ts b/functions/src/index.ts index 4127823a..5014f738 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -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. diff --git a/functions/src/questions/assignDailyQuestion.ts b/functions/src/questions/assignDailyQuestion.ts index c65e9f26..a22ee43a 100644 --- a/functions/src/questions/assignDailyQuestion.ts +++ b/functions/src/questions/assignDailyQuestion.ts @@ -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.') diff --git a/functions/src/questions/onAnswerWritten.ts b/functions/src/questions/onAnswerWritten.ts index 8939a779..00efcf72 100644 --- a/functions/src/questions/onAnswerWritten.ts +++ b/functions/src/questions/onAnswerWritten.ts @@ -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}`)