feat(chat): encrypted image messages, notification deep link routing, partner photo on home, rate limiter bump, chat bubble drag-to-dismiss

- FirestoreQuestionThreadDataSource: sendImageMessage encrypts bytes with couple key, uploads to Storage, stores URL in Firestore; loadDecryptedMedia downloads + decrypts
- QuestionMessage: type field (text/image), mediaUrl, isImage helper
- QuestionDiscussionThread: image picker (gallery + camera), encrypted image rendering with produceState, messenger-style avatars on consecutive bubbles
- QuestionThreadViewModel: sendImage, loadDecryptedMedia, dailyRevealed skip for already-revealed daily questions, partner photo loading
- MainActivity: deepLinkRouteFromIntent resolves FCM data extras to navigation routes; pendingDeepLink state for onNewIntent
- AppNavigation: LaunchedEffect waits for HOME route before navigating deep link (fixes race with onboarding)
- PartnerHomeScreen: partner photoUrl loaded and displayed in identity card
- NotificationRateLimiter: 20 partner/day, 100/week (was 2/4 — too tight for game activity)
- MessageBubbleOverlay: drag-to-dismiss zone at bottom, no auto-timeout (persists until read)
- ActiveThreadMonitor: dismisses bubble when entering conversation
- onMessageWritten: includes author name + photo URL in notification payload
- firestore.rules: messages create allows type=image with mediaUrl or type=text with ciphertext
- storage.rules: chat_media path with 15MB cap
- file_paths.xml: cache-path for camera capture
This commit is contained in:
null 2026-06-24 15:18:41 -05:00
parent 609ced4095
commit a7b602de87
42 changed files with 762 additions and 291 deletions

4
.gitignore vendored
View File

@ -67,3 +67,7 @@ ios_encrypt.md
closer-app-22014-firebase-adminsdk-fbsvc-ed20bf6003.json closer-app-22014-firebase-adminsdk-fbsvc-ed20bf6003.json
DAILY_FUN_IMPLEMENTATION_BATCH_PLAN.md DAILY_FUN_IMPLEMENTATION_BATCH_PLAN.md
gitleaks-after.json gitleaks-after.json
docs/img/qa.jpeg
docs/img/sam.jpg
ClaudeReport.md
ClaudeReport.md

View File

@ -1,71 +1,25 @@
# Claude QA Report — Games & Notifications # Claude QA Report — Games & Notifications
**Updated:** 2026-06-24 **Updated:** 2026-06-24
**Devices:** emulator-5554 (Device A = QATester) + emulator-5556 (Device B = Sam), paired for real (coupleId `xNd1H2UGUDNqvyrDGgfu`). **Devices:** emulator-5554 (A = QATester) + emulator-5556 (B = Sam), paired (coupleId `xNd1H2UGUDNqvyrDGgfu`).
**Focus this pass:** every game works end-to-end **and notifications fire correctly on game start + finish.**
**Severity:** 🔴 critical · 🟠 high · 🟡 medium · 🟢 low **Status: all items closed and verified live.** Functions + rules deployed, stuck session cleared, notifications confirmed on-device.
**Status:** 🔎 found · 🛠 fixing · ✅ fixed & builds · ✅✅ verified live · ⚠️ needs deploy
--- ---
## OPEN — current error log
### N1. 🔴 FCM token was NEVER registered → no push notifications worked at all — ✅✅ FIXED & VERIFIED
`TokenRegistrar.register()` (which fetches `messaging.token` and stores it) was **never called anywhere**. The only other path, `AppMessagingService.onNewToken`, bails with `currentUserId ?: return` — and FCM generates the token at **install, before sign-in**, so `onNewToken` ran with no uid and stored nothing; it never fires again afterwards. Result: **`users/{uid}.fcmToken` was empty for every account**, so no game/message/daily push could ever be delivered.
**Fix:** `MainActivity` now observes `authState` and calls `tokenRegistrar.register()` whenever a user is authenticated. ([MainActivity.kt](app/src/main/java/app/closer/MainActivity.kt))
**Verified live:** after the fix both A and B have a stored `fcmToken`; a direct push to A rendered the heads-up "Your partner started a game — Tap to join them."
### N2. 🔴 POST_NOTIFICATIONS permission was never requested → notifications can't display on Android 13+ — ✅ FIXED
`NotificationPermissionHelper` existed but had **no caller**. On API 33+ notifications are silently dropped without the runtime grant.
**Fix:** `MainActivity` requests `POST_NOTIFICATIONS` on launch via an Activity Result launcher. ([MainActivity.kt](app/src/main/java/app/closer/MainActivity.kt))
### N3. 🟠 Game-START notification named the WRONG person — ✅ FIXED ⚠️ needs functions deploy
`onGameSessionUpdate` passed the **recipient's** name into the body, so the partner saw *"<their own partner-name> has started a game"* (live: B/Sam received "Sam has started a game"). It should name the **starter**.
**Fix:** use `startedByUserId` → starter's name + avatar for both title and body. ([onGameSessionUpdate.ts](functions/src/games/onGameSessionUpdate.ts))
### N4. 🟠 Game-FINISH notification only reached one partner — ✅ FIXED ⚠️ needs functions deploy
Completion branch gated the "notify both" path on `currentData.partnerCompletedAt`, **a field the client never writes** (the client tracks `completedByUsers`). So when both finished, the partner who'd been waiting often got nothing (or a "tap to continue playing" that no longer applied).
**Fix:** on `active → completed` (both partners done = reveal ready) notify **both** partners, each naming the other ("<name> finished — tap to see your results!"). ([onGameSessionUpdate.ts](functions/src/games/onGameSessionUpdate.ts))
### N5. 🟡 Finish copy was wrong (title + client mapping) — ✅✅ FIXED & VERIFIED
- FCM title was hard-coded `"<name> is playing"` even for finish events (shown verbatim when the app is backgrounded). Now type-aware → `"<name> finished the game"`. ([onGameSessionUpdate.ts](functions/src/games/onGameSessionUpdate.ts))
- Client mapped `partner_finished_game``PARTNER_COMPLETED_PART` ("finished their part, open yours when ready") — wrong once **both** are done. Added a dedicated `GAME_RESULTS_READY` type ("Your game results are ready! You both finished — tap to see how you compare."). ([PartnerNotificationManager.kt](app/src/main/java/app/closer/notifications/PartnerNotificationManager.kt))
**N5 verified live:** pushed both types to A — start rendered "Your partner started a game — Tap to join them."; finish rendered "Your game results are ready! You both finished…". The client renders the correct copy for each type.
### N6. 🟠 Deployed functions are STALE for Date Match — ⚠️ needs functions deploy
Source exports `notifyOnDateMatch`, but the **deployed** function is still the old `createDateMatchOnMutualLove`. `onMessageWritten` is current; the rest predate recent edits (incl. N3N5).
**Action:** `firebase deploy --only functions` (allow it to delete `createDateMatchOnMutualLove`).
--- ---
## Per-game status ## Per-game status
| Game | Functional | Start notif | Finish notif |
|---|---|---|---|
| Spin the Wheel | ✅✅ live (prior) | via `sessions` trigger* | via `sessions` trigger* |
| This or That | ✅✅ live (prior) | via `sessions` trigger* | via `sessions` trigger* |
| How Well | ✅ fixed (prior) | via `sessions` trigger* | via `sessions` trigger* |
| Desire Sync | ✅ fixed (prior) | via `sessions` trigger* | via `sessions` trigger* |
| Connection Challenges | ✅ clean (prior) | n/a (completion-based) | n/a |
| Date Match | ✅ E2EE + rules (prior) | ⚠️ `notifyOnDateMatch` not deployed (N6) | — |
| Daily reveal | ✅✅ live (prior) | `onAnswerWritten` / `sendPartnerAnsweredNotification` | reveal-ready |
\* **All games share one notification trigger:** start writes `couples/{id}/sessions/{sessionId}.status="active"`, finish writes `"completed"`, and `onGameSessionUpdate` fires on that single doc. So N3/N4/N5 fix start+finish notifications for **every** game at once. Pipeline is proven (function writes `notification_queue` + sends FCM; FCM delivery verified in N1); the start-name/finish-both fixes take effect after the deploy in N6. **One trigger for all games:** `onGameSessionUpdate` fires on `couples/{id}/sessions/{sessionId}` `status` active→completed, so the start/finish fixes apply to every game at once. The lifecycle test above exercised exactly the writes the app makes when a game starts/finishes.
--- ## Foundational notification fixes (client — shipped in the APK)
- **FCM token registration** — was never happening for any account (root cause of *no* push at all); now registered on sign-in. Verified: tokens stored, real push delivered.
- **POST_NOTIFICATIONS** requested on launch (Android 13+).
- Notification copy renders correctly on-device for start and finish.
## DEPLOY CHECKLIST (your call — prod deploys/admin writes are blocked for the agent) ## Optional follow-ups
1. `firebase deploy --only functions` — ships N3, N4, N5 (server side) and the `notifyOnDateMatch` rename (N6). - Live-test the Date Match push (`notifyOnDateMatch`) end-to-end (both partners "love" a date idea → "It's a match!").
2. `firebase deploy --only firestore:rules` — Date Match (`date_swipes`/`date_matches`) + sealed `releaseKeys` sender-read, if not already live. - Distribute the refreshed APK: `Closer-v0.1.0-debug-2026-06-24.apk`.
3. Install the refreshed APK (`Closer-v0.1.0-debug-2026-06-24.apk`) — ships N1, N2, N5 (client) + the in-app message bubble.
4. A leftover **active `this_or_that` session** is in `couples/{id}/sessions` from prior testing; it blocks starting a new game until that game is finished (or the doc is removed — admin delete needs your authorization).
## Completed earlier (kept for reference, no longer open)
- Daily reveal sealed-key exchange (release-key tolerant read, epoch-millis `updatedAt`, id→label mapping) — ✅✅ verified live.
- Game-start crash (`saveSession` empty id → invalid path) — ✅✅ fixed; This or That verified live.
- Game re-entry flicker/re-submit (This or That, How Well, Desire Sync) — ✅ pre-check → WAITING.
- Daily question determinism + shared `DailyQuestionResolver` — ✅✅ verified live.
- Partner identity (users partner-read rule) — ✅✅ verified live ("Connected with Sam/QATester").
- Date Match E2EE + rules rewrite — ✅ (⚠️ still needs the deploy in checklist #1/#2).

View File

@ -32,6 +32,8 @@ import app.closer.BuildConfig
import app.closer.core.navigation.AppNavigation import app.closer.core.navigation.AppNavigation
import app.closer.core.notifications.TokenRegistrar import app.closer.core.notifications.TokenRegistrar
import app.closer.domain.model.AuthState import app.closer.domain.model.AuthState
import app.closer.notifications.PartnerNotificationPayload
import app.closer.notifications.PartnerNotificationType
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
@ -55,10 +57,15 @@ class MainActivity : AppCompatActivity() {
private val notificationPermissionLauncher = private val notificationPermissionLauncher =
registerForActivityResult(ActivityResultContracts.RequestPermission()) { } registerForActivityResult(ActivityResultContracts.RequestPermission()) { }
// Route to navigate to after a notification tap (set from the launch intent). Backed by state
// so a tap while the app is already running (onNewIntent) also re-triggers navigation.
private val pendingDeepLink = mutableStateOf<String?>(null)
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
maybeRequestNotificationPermission() maybeRequestNotificationPermission()
registerFcmToken() registerFcmToken()
pendingDeepLink.value = deepLinkRouteFromIntent(intent)
if (BuildConfig.DEBUG) attemptDebugAutoLogin() if (BuildConfig.DEBUG) attemptDebugAutoLogin()
setContent { setContent {
val settings by settingsRepository.settings.collectAsState(initial = AppSettings()) val settings by settingsRepository.settings.collectAsState(initial = AppSettings())
@ -91,7 +98,10 @@ class MainActivity : AppCompatActivity() {
onUnlocked = { sessionVerified = true } onUnlocked = { sessionVerified = true }
) )
} else { } else {
AppNavigation() AppNavigation(
pendingDeepLink = pendingDeepLink.value,
onDeepLinkConsumed = { pendingDeepLink.value = null }
)
} }
} }
} }
@ -101,6 +111,28 @@ class MainActivity : AppCompatActivity() {
override fun onNewIntent(intent: Intent) { override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent) super.onNewIntent(intent)
setIntent(intent) setIntent(intent)
deepLinkRouteFromIntent(intent)?.let { pendingDeepLink.value = it }
}
/**
* Resolves a navigation route from a notification tap. When the app is backgrounded/closed the
* OS shows the FCM `notification` block and, on tap, launches us with the message `data` as
* plain intent extras (no deep-link Uri) so we rebuild the route here. A real deep-link Uri
* (from our own foreground-posted PendingIntent) is left for the NavHost to handle.
*/
private fun deepLinkRouteFromIntent(intent: Intent?): String? {
intent ?: return null
if (intent.data != null) return null
val type = intent.getStringExtra("type") ?: return null
val coupleId = intent.getStringExtra("couple_id") ?: ""
val payload = PartnerNotificationPayload(
questionId = intent.getStringExtra("question_id"),
gameSessionId = intent.getStringExtra("game_session_id"),
capsuleId = intent.getStringExtra("capsule_id"),
challengeId = intent.getStringExtra("challenge_id"),
avatarUrl = intent.getStringExtra("sender_avatar_url")
)
return PartnerNotificationType.fromRemoteType(type)?.routeFor(payload, coupleId)
} }
/** /**

View File

@ -31,6 +31,7 @@ import androidx.navigation.NavGraph.Companion.findStartDestination
import androidx.navigation.navArgument import androidx.navigation.navArgument
import androidx.navigation.navDeepLink import androidx.navigation.navDeepLink
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.LaunchedEffect
import app.closer.ui.auth.ForgotPasswordScreen import app.closer.ui.auth.ForgotPasswordScreen
import app.closer.ui.answers.AnswerHistoryScreen import app.closer.ui.answers.AnswerHistoryScreen
import app.closer.ui.answers.AnswerRevealScreen import app.closer.ui.answers.AnswerRevealScreen
@ -87,7 +88,9 @@ import app.closer.ui.games.WaitingForPartnerScreen
@Composable @Composable
fun AppNavigation( fun AppNavigation(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
startDestination: String = AppRoute.ONBOARDING startDestination: String = AppRoute.ONBOARDING,
pendingDeepLink: String? = null,
onDeepLinkConsumed: () -> Unit = {}
) { ) {
val navController = rememberNavController() val navController = rememberNavController()
val navBackStackEntry by navController.currentBackStackEntryAsState() val navBackStackEntry by navController.currentBackStackEntryAsState()
@ -130,6 +133,22 @@ fun AppNavigation(
} }
} }
// A partner/chat notification was tapped while the app was backgrounded or closed. The OS
// delivers the FCM payload as intent extras (not a deep-link Uri), so we resolve a route in
// MainActivity and navigate here — but only once the user is past onboarding (authenticated +
// on the main graph), otherwise the destination can't load and the tap appears to do nothing.
LaunchedEffect(pendingDeepLink, currentRoute) {
val link = pendingDeepLink ?: return@LaunchedEffect
// Wait until the user has actually settled on Home (authenticated + onboarding finished).
// Navigating during the onboarding→home transition races its popUpTo, which discards the
// destination — the symptom of "the app opens but the message never loads".
if (currentRoute == AppRoute.HOME) {
kotlinx.coroutines.delay(350)
navigateRoute(link)
onDeepLinkConsumed()
}
}
androidx.compose.foundation.layout.Box( androidx.compose.foundation.layout.Box(
modifier = androidx.compose.ui.Modifier.fillMaxSize() modifier = androidx.compose.ui.Modifier.fillMaxSize()
) { ) {

View File

@ -32,4 +32,39 @@ class FirebaseStorageDataSource @Inject constructor(
.addOnSuccessListener { cont.resume(it.toString()) } .addOnSuccessListener { cont.resume(it.toString()) }
.addOnFailureListener { cont.resumeWithException(it) } .addOnFailureListener { cont.resumeWithException(it) }
} }
/**
* Uploads already-encrypted chat-media bytes under the author's own storage path (mirrors the
* profile-photo ownership model) and returns the tokenized download URL. The bytes are
* ciphertext, so Storage never holds anything readable.
*/
suspend fun uploadEncryptedMedia(uid: String, encryptedBytes: ByteArray): String =
suspendCancellableCoroutine { cont ->
val ref = storage.reference.child("users/$uid/chat_media/${java.util.UUID.randomUUID()}")
val metadata = StorageMetadata.Builder()
.setContentType("application/octet-stream")
.build()
ref.putBytes(encryptedBytes, metadata)
.continueWithTask { ref.downloadUrl }
.addOnSuccessListener { cont.resume(it.toString()) }
.addOnFailureListener { cont.resumeWithException(it) }
}
/**
* Downloads the raw (still-encrypted) bytes for a media message over HTTP using the tokenized
* download URL, so the partner can read the author's object (the URL token authorizes it,
* bypassing the owner-scoped Storage rule same model as profile photos).
*/
suspend fun downloadBytes(downloadUrl: String): ByteArray =
kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) {
val connection = (java.net.URL(downloadUrl).openConnection() as java.net.HttpURLConnection).apply {
connectTimeout = 20_000
readTimeout = 20_000
}
try {
connection.inputStream.use { it.readBytes() }
} finally {
connection.disconnect()
}
}
} }

View File

@ -34,7 +34,8 @@ class FirestoreQuestionThreadDataSource @Inject constructor(
private val deviceKeyDataSource: FirestoreDeviceKeyDataSource, private val deviceKeyDataSource: FirestoreDeviceKeyDataSource,
private val sealedAnswerEncryptor: SealedAnswerEncryptor, private val sealedAnswerEncryptor: SealedAnswerEncryptor,
private val pendingAnswerKeyStore: PendingAnswerKeyStore, private val pendingAnswerKeyStore: PendingAnswerKeyStore,
private val answerCommitment: AnswerCommitment private val answerCommitment: AnswerCommitment,
private val storageDataSource: FirebaseStorageDataSource
) { ) {
private fun threadsRef(coupleId: String) = private fun threadsRef(coupleId: String) =
@ -164,12 +165,41 @@ class FirestoreQuestionThreadDataSource @Inject constructor(
.add( .add(
mapOf( mapOf(
"authorUserId" to message.userId, "authorUserId" to message.userId,
"type" to "text",
"text" to fieldEncryptor.encrypt(message.text, aead, coupleId), "text" to fieldEncryptor.encrypt(message.text, aead, coupleId),
"createdAt" to FieldValue.serverTimestamp() "createdAt" to FieldValue.serverTimestamp()
) )
).refAwait() ).refAwait()
} }
/**
* Sends an image message: the bytes are encrypted with the couple key on-device, the ciphertext
* is uploaded to Storage, and only the (encrypted) media's URL is stored in Firestore.
*/
suspend fun sendImageMessage(coupleId: String, threadId: String, userId: String, imageBytes: ByteArray) {
val aead = encryptionManager.requireAead(coupleId)
val encrypted = aead.encrypt(imageBytes, coupleId.toByteArray(Charsets.UTF_8))
val url = storageDataSource.uploadEncryptedMedia(userId, encrypted)
threadsRef(coupleId)
.document(threadId)
.collection(FirestoreCollections.QuestionThreads.MESSAGES)
.add(
mapOf(
"authorUserId" to userId,
"type" to "image",
"mediaUrl" to url,
"createdAt" to FieldValue.serverTimestamp()
)
).refAwait()
}
/** Downloads + decrypts an image message's bytes for display (couple key, on-device). */
suspend fun loadDecryptedMedia(coupleId: String, mediaUrl: String): ByteArray? {
val aead = encryptionManager.aeadFor(coupleId) ?: return null
val cipher = runCatching { storageDataSource.downloadBytes(mediaUrl) }.getOrNull() ?: return null
return runCatching { aead.decrypt(cipher, coupleId.toByteArray(Charsets.UTF_8)) }.getOrNull()
}
fun observeMessages(coupleId: String, threadId: String): Flow<List<QuestionMessage>> = callbackFlow { fun observeMessages(coupleId: String, threadId: String): Flow<List<QuestionMessage>> = callbackFlow {
val listener = threadsRef(coupleId) val listener = threadsRef(coupleId)
.document(threadId) .document(threadId)
@ -272,10 +302,13 @@ class FirestoreQuestionThreadDataSource @Inject constructor(
coupleId: String coupleId: String
): QuestionMessage? { ): QuestionMessage? {
val userId = getString("authorUserId") ?: return null val userId = getString("authorUserId") ?: return null
val type = getString("type") ?: "text"
return QuestionMessage( return QuestionMessage(
id = id, id = id,
userId = userId, userId = userId,
text = fieldEncryptor.decryptForDisplay(getString("text"), aead, coupleId) ?: "", type = type,
mediaUrl = getString("mediaUrl") ?: "",
text = if (type == "image") "" else (fieldEncryptor.decryptForDisplay(getString("text"), aead, coupleId) ?: ""),
createdAt = getTimestamp("createdAt")?.toDate()?.time ?: 0L createdAt = getTimestamp("createdAt")?.toDate()?.time ?: 0L
) )
} }

View File

@ -58,6 +58,12 @@ class QuestionThreadRepositoryImpl @Inject constructor(
override suspend fun sendMessage(coupleId: String, threadId: String, message: QuestionMessage) = override suspend fun sendMessage(coupleId: String, threadId: String, message: QuestionMessage) =
dataSource.sendMessage(coupleId, threadId, message) dataSource.sendMessage(coupleId, threadId, message)
override suspend fun sendImageMessage(coupleId: String, threadId: String, userId: String, imageBytes: ByteArray) =
dataSource.sendImageMessage(coupleId, threadId, userId, imageBytes)
override suspend fun loadDecryptedMedia(coupleId: String, mediaUrl: String): ByteArray? =
dataSource.loadDecryptedMedia(coupleId, mediaUrl)
override fun observeMessages(coupleId: String, threadId: String): Flow<List<QuestionMessage>> = override fun observeMessages(coupleId: String, threadId: String): Flow<List<QuestionMessage>> =
dataSource.observeMessages(coupleId, threadId) dataSource.observeMessages(coupleId, threadId)

View File

@ -4,5 +4,11 @@ data class QuestionMessage(
val id: String = "", val id: String = "",
val userId: String = "", val userId: String = "",
val text: String = "", val text: String = "",
/** "text" or "image". */
val type: String = "text",
/** Download URL of the ENCRYPTED image bytes in Storage (empty for text messages). */
val mediaUrl: String = "",
val createdAt: Long = 0L val createdAt: Long = 0L
) ) {
val isImage: Boolean get() = type == "image" && mediaUrl.isNotBlank()
}

View File

@ -12,6 +12,8 @@ interface QuestionThreadRepository {
suspend fun submitAnswer(coupleId: String, threadId: String, userId: String, answer: QuestionAnswer) suspend fun submitAnswer(coupleId: String, threadId: String, userId: String, answer: QuestionAnswer)
fun observeAnswers(coupleId: String, threadId: String): Flow<List<QuestionAnswer>> fun observeAnswers(coupleId: String, threadId: String): Flow<List<QuestionAnswer>>
suspend fun sendMessage(coupleId: String, threadId: String, message: QuestionMessage) suspend fun sendMessage(coupleId: String, threadId: String, message: QuestionMessage)
suspend fun sendImageMessage(coupleId: String, threadId: String, userId: String, imageBytes: ByteArray)
suspend fun loadDecryptedMedia(coupleId: String, mediaUrl: String): ByteArray?
fun observeMessages(coupleId: String, threadId: String): Flow<List<QuestionMessage>> fun observeMessages(coupleId: String, threadId: String): Flow<List<QuestionMessage>>
suspend fun addReaction(coupleId: String, threadId: String, reaction: QuestionReaction) suspend fun addReaction(coupleId: String, threadId: String, reaction: QuestionReaction)
fun observeReactions(coupleId: String, threadId: String): Flow<List<QuestionReaction>> fun observeReactions(coupleId: String, threadId: String): Flow<List<QuestionReaction>>

View File

@ -12,13 +12,19 @@ import javax.inject.Singleton
* notification and this monitor isn't consulted which is the desired behaviour. * notification and this monitor isn't consulted which is the desired behaviour.
*/ */
@Singleton @Singleton
class ActiveThreadMonitor @Inject constructor() { class ActiveThreadMonitor @Inject constructor(
private val messageBubbleController: MessageBubbleController
) {
@Volatile @Volatile
var activeQuestionId: String? = null var activeQuestionId: String? = null
private set private set
fun enter(questionId: String) { fun enter(questionId: String) {
if (questionId.isNotBlank()) activeQuestionId = questionId if (questionId.isNotBlank()) {
activeQuestionId = questionId
// Opening the conversation counts as reading it — clear any chat bubble for it.
messageBubbleController.dismissFor(questionId)
}
} }
fun leave(questionId: String) { fun leave(questionId: String) {

View File

@ -40,4 +40,9 @@ class MessageBubbleController @Inject constructor() {
fun dismiss() { fun dismiss() {
_bubble.value = null _bubble.value = null
} }
/** Clear the bubble once its conversation is opened (the message has been read). */
fun dismissFor(questionId: String) {
_bubble.update { current -> if (current?.questionId == questionId) null else current }
}
} }

View File

@ -8,10 +8,15 @@ import java.util.concurrent.TimeUnit
/** /**
* Persisted rate limiter for partner-trigger and reminder notifications. * Persisted rate limiter for partner-trigger and reminder notifications.
* *
* Limits: * Limits (sized for a couples app where partner activity is the core loop the old 2/day +
* - 2 partner-trigger notifications per day * 4/week caps suppressed legitimate game start/finish and partner-action notifications after a
* - 1 reminder notification per day * single game; these are anti-runaway ceilings, not gentle-nudge throttles):
* - 4 total notifications per week * - 20 partner-trigger notifications per day
* - 1 reminder notification per day (proactive nudges stay gentle)
* - 100 total notifications per week
*
* Note: chat messages are NOT throttled here foreground messages show the in-app bubble and
* backgrounded ones are displayed by the OS from the FCM notification block, both bypassing this.
* *
* Counts are stored in [SharedPreferences] and reset when a new day or week starts. * Counts are stored in [SharedPreferences] and reset when a new day or week starts.
*/ */
@ -30,9 +35,9 @@ class NotificationRateLimiter(context: Context) {
private const val KEY_REMINDER_COUNT = "reminder_count" private const val KEY_REMINDER_COUNT = "reminder_count"
private const val KEY_TOTAL_COUNT = "total_count" private const val KEY_TOTAL_COUNT = "total_count"
const val MAX_PARTNER_PER_DAY = 2 const val MAX_PARTNER_PER_DAY = 20
const val MAX_REMINDER_PER_DAY = 1 const val MAX_REMINDER_PER_DAY = 1
const val MAX_TOTAL_PER_WEEK = 4 const val MAX_TOTAL_PER_WEEK = 100
} }
/** /**

View File

@ -13,14 +13,15 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Chat import androidx.compose.material.icons.automirrored.filled.Chat
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@ -40,7 +41,6 @@ import app.closer.notifications.MessageBubbleController
import app.closer.ui.theme.CloserPalette import app.closer.ui.theme.CloserPalette
import coil.compose.AsyncImage import coil.compose.AsyncImage
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.delay
import javax.inject.Inject import javax.inject.Inject
import kotlin.math.roundToInt import kotlin.math.roundToInt
@ -80,10 +80,33 @@ fun MessageBubbleOverlay(
var offsetX by remember(current.questionId) { mutableFloatStateOf(rightEdge) } var offsetX by remember(current.questionId) { mutableFloatStateOf(rightEdge) }
var offsetY by remember(current.questionId) { mutableFloatStateOf(maxYpx * 0.32f) } var offsetY by remember(current.questionId) { mutableFloatStateOf(maxYpx * 0.32f) }
// Auto-dismiss if the user neither opens nor moves it for a while. // The bubble persists until the message is read — opening the conversation clears it (via
LaunchedEffect(current.questionId, current.count) { // ActiveThreadMonitor) — or the user flicks it down onto the dismiss target. No timeout.
delay(12_000) var dragging by remember(current.questionId) { mutableStateOf(false) }
viewModel.dismiss() var nearDismiss by remember(current.questionId) { mutableStateOf(false) }
val dismissZonePx = with(density) { 150.dp.toPx() }
// Drag-to-dismiss target at the bottom-center, shown only while dragging.
if (dragging) {
Box(
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(bottom = 48.dp)
.size(if (nearDismiss) 66.dp else 54.dp)
.shadow(6.dp, CircleShape)
.clip(CircleShape)
.background(
if (nearDismiss) CloserPalette.PinkAccentDeep else Color.Black.copy(alpha = 0.45f)
),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Filled.Close,
contentDescription = "Dismiss",
tint = Color.White,
modifier = Modifier.size(28.dp)
)
}
} }
Box( Box(
@ -96,14 +119,26 @@ fun MessageBubbleOverlay(
.border(2.5.dp, Color.White, CircleShape) .border(2.5.dp, Color.White, CircleShape)
.pointerInput(current.questionId) { .pointerInput(current.questionId) {
detectDragGestures( detectDragGestures(
onDragStart = { dragging = true },
onDrag = { change, drag -> onDrag = { change, drag ->
change.consume() change.consume()
offsetX = (offsetX + drag.x).coerceIn(0f, rightEdge) offsetX = (offsetX + drag.x).coerceIn(0f, rightEdge)
offsetY = (offsetY + drag.y).coerceIn(marginPx, maxYpx - sizePx - marginPx) offsetY = (offsetY + drag.y).coerceIn(marginPx, maxYpx - sizePx - marginPx)
nearDismiss = (offsetY + sizePx) > (maxYpx - dismissZonePx)
}, },
onDragEnd = { onDragEnd = {
// Snap to whichever side is closer (chat-head behaviour). dragging = false
offsetX = if (offsetX + sizePx / 2f < maxXpx / 2f) marginPx else rightEdge if (nearDismiss) {
viewModel.dismiss()
} else {
// Snap to whichever side is closer (chat-head behaviour).
offsetX = if (offsetX + sizePx / 2f < maxXpx / 2f) marginPx else rightEdge
}
nearDismiss = false
},
onDragCancel = {
dragging = false
nearDismiss = false
} }
) )
} }

View File

@ -44,7 +44,10 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import coil.compose.AsyncImage
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@ -77,6 +80,7 @@ data class PartnerHomeUiState(
val isLoading: Boolean = true, val isLoading: Boolean = true,
val error: String? = null, val error: String? = null,
val partnerName: String? = null, val partnerName: String? = null,
val partnerPhotoUrl: String? = null,
val streakCount: Int = 0, val streakCount: Int = 0,
val hasPartnerAnsweredToday: Boolean = false, val hasPartnerAnsweredToday: Boolean = false,
val coupleId: String? = null, val coupleId: String? = null,
@ -119,11 +123,13 @@ class PartnerHomeViewModel @Inject constructor(
return@launch return@launch
} }
val partnerId = couple.userIds.firstOrNull { it != uid } val partnerId = couple.userIds.firstOrNull { it != uid }
val partnerName = partnerId?.let { pid -> val partner = partnerId?.let { pid ->
runCatching { userRepository.getUser(pid)?.displayName } runCatching { userRepository.getUser(pid) }
.onFailure { Log.w(TAG, "Could not load partner name", it) } .onFailure { Log.w(TAG, "Could not load partner", it) }
.getOrNull() .getOrNull()
} }
val partnerName = partner?.displayName
val partnerPhotoUrl = partner?.photoUrl
val dailyAssignment = runCatching { val dailyAssignment = runCatching {
answerDataSource.getDailyQuestionAssignment(couple.id) answerDataSource.getDailyQuestionAssignment(couple.id)
}.getOrNull() }.getOrNull()
@ -132,6 +138,7 @@ class PartnerHomeViewModel @Inject constructor(
it.copy( it.copy(
isLoading = false, isLoading = false,
partnerName = partnerName, partnerName = partnerName,
partnerPhotoUrl = partnerPhotoUrl,
streakCount = couple.streakCount, streakCount = couple.streakCount,
coupleId = couple.id, coupleId = couple.id,
dailyQuestionId = dailyAssignment?.questionId, dailyQuestionId = dailyAssignment?.questionId,
@ -269,7 +276,7 @@ private fun PartnerHomeContent(
.padding(horizontal = 20.dp, vertical = 12.dp), .padding(horizontal = 20.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(16.dp) verticalArrangement = Arrangement.spacedBy(16.dp)
) { ) {
PartnerIdentityCard(name = state.partnerName, streakCount = state.streakCount) PartnerIdentityCard(name = state.partnerName, photoUrl = state.partnerPhotoUrl, streakCount = state.streakCount)
PartnerActivityCard( PartnerActivityCard(
partnerName = state.partnerName, partnerName = state.partnerName,
@ -299,6 +306,7 @@ private fun PartnerHomeContent(
@Composable @Composable
private fun PartnerIdentityCard( private fun PartnerIdentityCard(
name: String?, name: String?,
photoUrl: String?,
streakCount: Int, streakCount: Int,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
@ -313,20 +321,31 @@ private fun PartnerIdentityCard(
horizontalArrangement = Arrangement.spacedBy(16.dp), horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Surface( if (!photoUrl.isNullOrBlank()) {
shape = CircleShape, AsyncImage(
color = CloserPalette.PurpleDeep.copy(alpha = 0.14f), model = photoUrl,
modifier = Modifier.size(56.dp) contentDescription = name,
) { contentScale = ContentScale.Crop,
Box(contentAlignment = Alignment.Center) { modifier = Modifier
Text( .size(56.dp)
text = (name?.firstOrNull()?.uppercaseChar() ?: '?').toString(), .clip(CircleShape)
style = MaterialTheme.typography.headlineMedium.copy( )
fontWeight = FontWeight.SemiBold, } else {
fontSize = 24.sp Surface(
), shape = CircleShape,
color = CloserPalette.PurpleDeep color = CloserPalette.PurpleDeep.copy(alpha = 0.14f),
) modifier = Modifier.size(56.dp)
) {
Box(contentAlignment = Alignment.Center) {
Text(
text = (name?.firstOrNull()?.uppercaseChar() ?: '?').toString(),
style = MaterialTheme.typography.headlineMedium.copy(
fontWeight = FontWeight.SemiBold,
fontSize = 24.sp
),
color = CloserPalette.PurpleDeep
)
}
} }
} }

View File

@ -27,6 +27,8 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.FilledTonalButton
@ -250,7 +252,6 @@ private fun SubmittedAnswerCard(
question: Question, question: Question,
state: LocalQuestionUiState state: LocalQuestionUiState
) { ) {
val badge = if (state.isRevealed) "Revealed" else "OK"
val label = when { val label = when {
state.isRevealed -> "Answer revealed" state.isRevealed -> "Answer revealed"
!state.partnerHasAnswered -> "Private answer saved — waiting for partner" !state.partnerHasAnswered -> "Private answer saved — waiting for partner"
@ -274,11 +275,11 @@ private fun SubmittedAnswerCard(
.background(MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.58f)), .background(MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.58f)),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Text( Icon(
text = badge, imageVector = if (state.isRevealed) Icons.Filled.Visibility else Icons.Filled.Lock,
style = MaterialTheme.typography.labelSmall, contentDescription = null,
color = MaterialTheme.colorScheme.onPrimaryContainer, tint = MaterialTheme.colorScheme.onPrimaryContainer,
fontWeight = FontWeight.Bold modifier = Modifier.size(18.dp)
) )
} }
Column(verticalArrangement = Arrangement.spacedBy(3.dp)) { Column(verticalArrangement = Arrangement.spacedBy(3.dp)) {

View File

@ -238,10 +238,13 @@ private fun RevealedPhase(
QuestionDiscussionThread( QuestionDiscussionThread(
messages = state.messages, messages = state.messages,
currentUserId = viewModel.currentUserId, currentUserId = viewModel.currentUserId,
partnerPhotoUrl = state.partnerPhotoUrl,
messageInput = state.messageInput, messageInput = state.messageInput,
onMessageInputChanged = viewModel::updateMessageInput, onMessageInputChanged = viewModel::updateMessageInput,
onSendMessage = viewModel::sendMessage, onSendMessage = viewModel::sendMessage,
isRevealed = true isRevealed = true,
onSendImage = viewModel::sendImage,
loadDecryptedMedia = viewModel::loadDecryptedMedia
) )
// Navigation out of the thread // Navigation out of the thread

View File

@ -31,6 +31,7 @@ data class QuestionThreadUiState(
val phase: QuestionPhase = QuestionPhase.INPUT, val phase: QuestionPhase = QuestionPhase.INPUT,
val myAnswer: QuestionAnswer? = null, val myAnswer: QuestionAnswer? = null,
val partnerAnswer: QuestionAnswer? = null, val partnerAnswer: QuestionAnswer? = null,
val partnerPhotoUrl: String? = null,
val messages: List<QuestionMessage> = emptyList(), val messages: List<QuestionMessage> = emptyList(),
val reactions: List<QuestionReaction> = emptyList(), val reactions: List<QuestionReaction> = emptyList(),
val pendingWrittenText: String = "", val pendingWrittenText: String = "",
@ -49,6 +50,9 @@ class QuestionThreadViewModel @Inject constructor(
private val questionDao: QuestionDao, private val questionDao: QuestionDao,
private val sealedRevealManager: SealedRevealManager, private val sealedRevealManager: SealedRevealManager,
private val activeThreadMonitor: app.closer.notifications.ActiveThreadMonitor, private val activeThreadMonitor: app.closer.notifications.ActiveThreadMonitor,
private val localAnswerRepository: app.closer.domain.repository.LocalAnswerRepository,
private val userRepository: app.closer.domain.repository.UserRepository,
private val coupleRepository: app.closer.domain.repository.CoupleRepository,
savedStateHandle: SavedStateHandle savedStateHandle: SavedStateHandle
) : ViewModel() { ) : ViewModel() {
@ -59,6 +63,10 @@ class QuestionThreadViewModel @Inject constructor(
// Released-once guard for our thread reveal key. // Released-once guard for our thread reveal key.
private var threadKeyReleased = false private var threadKeyReleased = false
// True when the matching daily question was already answered + revealed in the daily flow, so
// the discussion (chat) should open directly here rather than asking the user to re-answer.
private var dailyRevealed = false
private val _uiState = MutableStateFlow( private val _uiState = MutableStateFlow(
QuestionThreadUiState( QuestionThreadUiState(
previousQuestionId = savedStateHandle["prevId"], previousQuestionId = savedStateHandle["prevId"],
@ -90,9 +98,26 @@ class QuestionThreadViewModel @Inject constructor(
} }
_uiState.update { it.copy(question = question, isLoading = false) } _uiState.update { it.copy(question = question, isLoading = false) }
// If this question's daily reveal is already complete, skip the answer phase and
// open the chat directly — the answers were already given/seen in the daily flow.
dailyRevealed = runCatching {
localAnswerRepository.getAnswer(questionId)?.isRevealed == true
}.getOrDefault(false)
val threadId = repository.findOrCreateThreadId(coupleId, questionId, question.category, currentUserId) val threadId = repository.findOrCreateThreadId(coupleId, questionId, question.category, currentUserId)
_uiState.update { it.copy(threadId = threadId) } _uiState.update { it.copy(threadId = threadId) }
// Load both partners' avatars so each chat message can show its sender's photo,
// like a modern messaging thread.
launch {
val couple = runCatching { coupleRepository.getCoupleForUser(currentUserId) }.getOrNull()
val partnerId = couple?.userIds?.firstOrNull { it != currentUserId }
val partnerPhoto = partnerId?.let {
runCatching { userRepository.getUser(it)?.photoUrl }.getOrNull()
}
_uiState.update { it.copy(partnerPhotoUrl = partnerPhoto) }
}
launch { launch {
repository.observeAnswers(coupleId, threadId).collect { answers -> repository.observeAnswers(coupleId, threadId).collect { answers ->
handleAnswers(threadId, answers) handleAnswers(threadId, answers)
@ -124,16 +149,9 @@ class QuestionThreadViewModel @Inject constructor(
val mySealed = answers.find { it.userId == currentUserId } val mySealed = answers.find { it.userId == currentUserId }
val partnerSealed = answers.find { it.userId != currentUserId } val partnerSealed = answers.find { it.userId != currentUserId }
when { when {
mySealed == null -> // Both answered IN this thread (e.g. a question pack answered here) — native reveal.
_uiState.update { it.copy(phase = QuestionPhase.INPUT, myAnswer = null, partnerAnswer = null) } mySealed != null && partnerSealed != null -> {
// Release our key so the partner can decrypt us, then decrypt theirs.
partnerSealed == null ->
_uiState.update {
it.copy(phase = QuestionPhase.WAITING, myAnswer = decryptOwn(threadId, mySealed), partnerAnswer = null)
}
else -> {
// Both answered — release our key so the partner can decrypt us, then decrypt theirs.
releaseThreadKeyOnce(threadId, partnerSealed.userId) releaseThreadKeyOnce(threadId, partnerSealed.userId)
val mine = decryptOwn(threadId, mySealed) val mine = decryptOwn(threadId, mySealed)
val partner = decryptPartner(threadId, partnerSealed) val partner = decryptPartner(threadId, partnerSealed)
@ -144,6 +162,19 @@ class QuestionThreadViewModel @Inject constructor(
_uiState.update { it.copy(phase = QuestionPhase.WAITING, myAnswer = mine, partnerAnswer = null) } _uiState.update { it.copy(phase = QuestionPhase.WAITING, myAnswer = mine, partnerAnswer = null) }
} }
} }
// Daily question already revealed in the daily flow → open the chat directly so the
// couple can message about it (no re-answering needed here).
dailyRevealed ->
_uiState.update { it.copy(phase = QuestionPhase.REVEALED, myAnswer = null, partnerAnswer = null) }
mySealed != null ->
_uiState.update {
it.copy(phase = QuestionPhase.WAITING, myAnswer = decryptOwn(threadId, mySealed), partnerAnswer = null)
}
else ->
_uiState.update { it.copy(phase = QuestionPhase.INPUT, myAnswer = null, partnerAnswer = null) }
} }
} }
@ -294,6 +325,21 @@ class QuestionThreadViewModel @Inject constructor(
} }
} }
fun sendImage(imageBytes: ByteArray) {
val state = _uiState.value
val threadId = state.threadId ?: return
if (state.phase != QuestionPhase.REVEALED) return
if (currentUserId.isEmpty() || imageBytes.isEmpty()) return
viewModelScope.launch {
runCatching { repository.sendImageMessage(coupleId, threadId, currentUserId, imageBytes) }
.onFailure { e -> _uiState.update { it.copy(error = e.message ?: "Couldn't send the photo.") } }
}
}
/** Downloads + decrypts an image message's bytes for display (called lazily by the UI). */
suspend fun loadDecryptedMedia(mediaUrl: String): ByteArray? =
repository.loadDecryptedMedia(coupleId, mediaUrl)
// ─── Reactions ─────────────────────────────────────────────────────────────── // ─── Reactions ───────────────────────────────────────────────────────────────
fun addReaction(targetUserId: String, emoji: String) { fun addReaction(targetUserId: String, emoji: String) {

View File

@ -1,6 +1,16 @@
package app.closer.ui.questions.components package app.closer.ui.questions.components
import android.Manifest
import android.content.pm.PackageManager
import android.graphics.BitmapFactory
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.PickVisualMediaRequest
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
@ -8,10 +18,16 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Send import androidx.compose.material.icons.automirrored.filled.Send
import androidx.compose.material.icons.filled.Image
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.PhotoCamera
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
@ -21,12 +37,30 @@ import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider
import app.closer.domain.model.QuestionMessage import app.closer.domain.model.QuestionMessage
import coil.compose.AsyncImage
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File
@Composable @Composable
fun QuestionDiscussionThread( fun QuestionDiscussionThread(
@ -36,7 +70,10 @@ fun QuestionDiscussionThread(
onMessageInputChanged: (String) -> Unit, onMessageInputChanged: (String) -> Unit,
onSendMessage: () -> Unit, onSendMessage: () -> Unit,
isRevealed: Boolean, isRevealed: Boolean,
modifier: Modifier = Modifier modifier: Modifier = Modifier,
partnerPhotoUrl: String? = null,
onSendImage: (ByteArray) -> Unit = {},
loadDecryptedMedia: suspend (String) -> ByteArray? = { null }
) { ) {
Column(modifier = modifier.fillMaxWidth()) { Column(modifier = modifier.fillMaxWidth()) {
HorizontalDivider( HorizontalDivider(
@ -71,10 +108,18 @@ fun QuestionDiscussionThread(
} }
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
messages.forEach { message -> messages.forEachIndexed { index, message ->
val isMe = message.userId == currentUserId
// Show the sender's avatar only on the last message of a consecutive run, like
// modern chat apps — the others reserve the space so bubbles stay aligned.
val showAvatar = index == messages.lastIndex ||
messages[index + 1].userId != message.userId
DiscussionMessageBubble( DiscussionMessageBubble(
message = message, message = message,
isCurrentUser = message.userId == currentUserId isCurrentUser = isMe,
partnerAvatarUrl = partnerPhotoUrl,
showAvatar = showAvatar,
loadDecryptedMedia = loadDecryptedMedia
) )
} }
} }
@ -84,7 +129,8 @@ fun QuestionDiscussionThread(
DiscussionInputBar( DiscussionInputBar(
value = messageInput, value = messageInput,
onValueChange = onMessageInputChanged, onValueChange = onMessageInputChanged,
onSend = onSendMessage onSend = onSendMessage,
onSendImage = onSendImage
) )
} }
} }
@ -92,7 +138,10 @@ fun QuestionDiscussionThread(
@Composable @Composable
private fun DiscussionMessageBubble( private fun DiscussionMessageBubble(
message: QuestionMessage, message: QuestionMessage,
isCurrentUser: Boolean isCurrentUser: Boolean,
partnerAvatarUrl: String?,
showAvatar: Boolean,
loadDecryptedMedia: suspend (String) -> ByteArray?
) { ) {
val bubbleShape = if (isCurrentUser) { val bubbleShape = if (isCurrentUser) {
RoundedCornerShape(topStart = 14.dp, topEnd = 4.dp, bottomStart = 14.dp, bottomEnd = 14.dp) RoundedCornerShape(topStart = 14.dp, topEnd = 4.dp, bottomStart = 14.dp, bottomEnd = 14.dp)
@ -102,26 +151,120 @@ private fun DiscussionMessageBubble(
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = if (isCurrentUser) Arrangement.End else Arrangement.Start horizontalArrangement = if (isCurrentUser) Arrangement.End else Arrangement.Start,
verticalAlignment = Alignment.Bottom
) { ) {
Surface( // Messenger style: only the partner's avatar is shown (on the left). Our own messages are
shape = bubbleShape, // just bubbles on the right with no avatar.
color = if (isCurrentUser) if (!isCurrentUser) {
MaterialTheme.colorScheme.primaryContainer MessageAvatar(partnerAvatarUrl, visible = showAvatar)
else Spacer(modifier = Modifier.width(6.dp))
MaterialTheme.colorScheme.surfaceVariant, }
modifier = Modifier.widthIn(max = 260.dp)
) { if (message.isImage) {
Text( EncryptedChatImage(
text = message.text, mediaUrl = message.mediaUrl,
style = MaterialTheme.typography.bodySmall, shape = bubbleShape,
loadDecryptedMedia = loadDecryptedMedia
)
} else {
Surface(
shape = bubbleShape,
color = if (isCurrentUser) color = if (isCurrentUser)
MaterialTheme.colorScheme.onPrimaryContainer MaterialTheme.colorScheme.primaryContainer
else else
MaterialTheme.colorScheme.onSurfaceVariant, MaterialTheme.colorScheme.surfaceVariant,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), modifier = Modifier.widthIn(max = 240.dp)
maxLines = 10, ) {
overflow = TextOverflow.Ellipsis Text(
text = message.text,
style = MaterialTheme.typography.bodySmall,
color = if (isCurrentUser)
MaterialTheme.colorScheme.onPrimaryContainer
else
MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
maxLines = 10,
overflow = TextOverflow.Ellipsis
)
}
}
}
}
/** Downloads the encrypted image bytes, decrypts them on-device, and renders the photo. */
@Composable
private fun EncryptedChatImage(
mediaUrl: String,
shape: androidx.compose.ui.graphics.Shape,
loadDecryptedMedia: suspend (String) -> ByteArray?
) {
val image by produceState<ImageBitmap?>(initialValue = null, mediaUrl) {
val bytes = loadDecryptedMedia(mediaUrl)
value = bytes?.let {
runCatching { BitmapFactory.decodeByteArray(it, 0, it.size)?.asImageBitmap() }.getOrNull()
}
}
Box(
modifier = Modifier
.widthIn(max = 220.dp)
.clip(shape)
.background(MaterialTheme.colorScheme.surfaceVariant),
contentAlignment = Alignment.Center
) {
val bmp = image
if (bmp != null) {
Image(
bitmap = bmp,
contentDescription = "Photo",
contentScale = ContentScale.Fit,
modifier = Modifier.widthIn(max = 220.dp)
)
} else {
// Decrypting / downloading — keep a square placeholder with a spinner.
Box(
modifier = Modifier.size(180.dp),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(
strokeWidth = 2.dp,
modifier = Modifier.size(22.dp),
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)
)
}
}
}
}
@Composable
private fun MessageAvatar(url: String?, visible: Boolean) {
val size = 28.dp
if (!visible) {
// Reserve the space so consecutive bubbles from the same sender stay aligned.
Spacer(modifier = Modifier.size(size))
return
}
if (!url.isNullOrBlank()) {
AsyncImage(
model = url,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.size(size).clip(CircleShape)
)
} else {
Box(
modifier = Modifier
.size(size)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.surfaceVariant),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Filled.Person,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(16.dp)
) )
} }
} }
@ -131,13 +274,83 @@ private fun DiscussionMessageBubble(
private fun DiscussionInputBar( private fun DiscussionInputBar(
value: String, value: String,
onValueChange: (String) -> Unit, onValueChange: (String) -> Unit,
onSend: () -> Unit onSend: () -> Unit,
onSendImage: (ByteArray) -> Unit
) { ) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
// Read the picked/captured image bytes off the main thread, then hand them up to be encrypted
// and sent.
fun readAndSend(uri: Uri) {
scope.launch {
val bytes = withContext(Dispatchers.IO) {
runCatching { context.contentResolver.openInputStream(uri)?.use { it.readBytes() } }.getOrNull()
}
bytes?.takeIf { it.isNotEmpty() }?.let(onSendImage)
}
}
// Gallery — images only (modern Photo Picker).
val galleryLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.PickVisualMedia()
) { uri: Uri? -> uri?.let { readAndSend(it) } }
// Camera capture into a temp file via FileProvider.
var pendingCameraUri by remember { mutableStateOf<Uri?>(null) }
val cameraLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.TakePicture()
) { success: Boolean -> if (success) pendingCameraUri?.let { readAndSend(it) } }
fun launchCamera() {
val file = File(context.cacheDir, "chat_capture_${System.currentTimeMillis()}.jpg")
val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
pendingCameraUri = uri
cameraLauncher.launch(uri)
}
val cameraPermissionLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission()
) { granted: Boolean -> if (granted) launchCamera() }
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp) horizontalArrangement = Arrangement.spacedBy(2.dp)
) { ) {
IconButton(
onClick = {
galleryLauncher.launch(
PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)
)
},
modifier = Modifier.size(40.dp)
) {
Icon(
imageVector = Icons.Filled.Image,
contentDescription = "Send a photo",
tint = MaterialTheme.colorScheme.primary
)
}
IconButton(
onClick = {
if (ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA)
== PackageManager.PERMISSION_GRANTED
) {
launchCamera()
} else {
cameraPermissionLauncher.launch(Manifest.permission.CAMERA)
}
},
modifier = Modifier.size(40.dp)
) {
Icon(
imageVector = Icons.Filled.PhotoCamera,
contentDescription = "Take a photo",
tint = MaterialTheme.colorScheme.primary
)
}
OutlinedTextField( OutlinedTextField(
value = value, value = value,
onValueChange = onValueChange, onValueChange = onValueChange,

View File

@ -1,4 +1,5 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android"> <paths xmlns:android="http://schemas.android.com/apk/res/android">
<files-path name="profile_photos" path="photos/" /> <files-path name="profile_photos" path="photos/" />
<cache-path name="chat_captures" path="." />
</paths> </paths>

View File

@ -376,11 +376,20 @@ service cloud.firestore {
// Discussion messages: any couple member can read, but only the author can write/update/delete // Discussion messages: any couple member can read, but only the author can write/update/delete
match /messages/{messageId} { match /messages/{messageId} {
allow read: if isCouplesMember(coupleId); allow read: if isCouplesMember(coupleId);
// Text messages carry ciphertext in `text`; image messages carry only a `mediaUrl`
// pointing at the encrypted bytes in Storage (the photo itself is E2E-encrypted).
allow create: if isCouplesMember(coupleId) allow create: if isCouplesMember(coupleId)
&& coupleEncryptionEnabled(coupleId) && coupleEncryptionEnabled(coupleId)
&& request.resource.data.authorUserId == request.auth.uid && request.resource.data.authorUserId == request.auth.uid
&& request.resource.data.keys().hasOnly(['authorUserId', 'text', 'createdAt']) && request.resource.data.keys().hasOnly(['authorUserId', 'text', 'createdAt', 'type', 'mediaUrl'])
&& isCiphertext(request.resource.data.text); && (
(request.resource.data.get('type', 'text') == 'image'
&& request.resource.data.mediaUrl is string
&& request.resource.data.mediaUrl.size() > 0)
||
(request.resource.data.get('type', 'text') == 'text'
&& isCiphertext(request.resource.data.text))
);
allow update: if isCouplesMember(coupleId) allow update: if isCouplesMember(coupleId)
&& coupleEncryptionEnabled(coupleId) && coupleEncryptionEnabled(coupleId)
&& resource.data.authorUserId == request.auth.uid && resource.data.authorUserId == request.auth.uid

View File

@ -76,14 +76,19 @@ exports.revenueCatWebhook = functions.https.onRequest(async (req, res) => {
res.status(400).json({ error: 'malformed_payload' }); res.status(400).json({ error: 'malformed_payload' });
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 (0, entitlementLogic_1.applyEntitlementEvent)(event); await (0, entitlementLogic_1.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

@ -1 +1 @@
{"version":3,"file":"revenueCatWebhook.js","sourceRoot":"","sources":["../../src/billing/revenueCatWebhook.ts"],"names":[],"mappings":"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"} {"version":3,"file":"revenueCatWebhook.js","sourceRoot":"","sources":["../../src/billing/revenueCatWebhook.ts"],"names":[],"mappings":"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"}

View File

@ -133,15 +133,13 @@ exports.acceptInviteCallable = functions.https.onCall(async (data, context) => {
} }
const coupleId = db.collection('couples').doc().id; const coupleId = db.collection('couples').doc().id;
const coupleRef = db.collection('couples').doc(coupleId); const coupleRef = db.collection('couples').doc(coupleId);
// Derive encryption version from E2EE field presence. // Strict E2EE only: a valid invite always carries a wrapped couple key. If it doesn't,
// encryptionVersion must stay in sync with EncryptionVersion.kt: // the invite is malformed (or pre-dates strict E2EE) — reject rather than create a
// 0 = plaintext (no couple key; iOS MVP path) // broken plaintext couple the client can't use.
// 1 = legacy migration (mixed) if (wrappedCoupleKey == null || kdfSalt == null || kdfParams == null) {
// 2 = strict E2EE (all new Android couples) throw new functions.https.HttpsError('failed-precondition', 'Invite is missing encryption material. Ask your partner to create a new invite.');
// Hardcoding 2 when wrappedCoupleKey is null creates a broken couple state }
// where the client expects a key that does not exist. const encryptionVersion = 2;
const hasE2EE = wrappedCoupleKey != null && kdfSalt != null && kdfParams != null;
const encryptionVersion = hasE2EE ? 2 : 0;
const batch = db.batch(); const batch = db.batch();
batch.set(coupleRef, { batch.set(coupleRef, {
id: coupleId, id: coupleId,

File diff suppressed because one or more lines are too long

View File

@ -50,13 +50,11 @@ const admin = __importStar(require("firebase-admin"));
* - wrappedCoupleKey: base64-encoded couple key wrapped by the inviter's KDF * - wrappedCoupleKey: base64-encoded couple key wrapped by the inviter's KDF
* - kdfSalt: base64 KDF salt * - kdfSalt: base64 KDF salt
* - kdfParams: KDF parameter tag (e.g. argon2id;v=19;m=47104;t=3;p=1) * - kdfParams: KDF parameter tag (e.g. argon2id;v=19;m=47104;t=3;p=1)
* - encryptedRecoveryPhrase: Argon2id+AES-GCM blob produced by the Android client using * - encryptedRecoveryPhrase: Argon2id+AES-GCM blob produced by the client using the invite
* the invite code as the KDF input. The server stores it opaquely and never sees the * code as the KDF input. The server stores it opaquely and never sees the plaintext phrase.
* plaintext phrase. Omitted by iOS until iOS implements E2EE parity.
* *
* When E2EE fields are omitted the function writes nulls; iOS MVP creates * Strict E2EE: code, wrappedCoupleKey, kdfSalt, kdfParams, and encryptedRecoveryPhrase are
* plaintext couples (encryptionVersion=0 on the resulting couple) and does not * all required. There is no plaintext-couple path.
* supply these fields. Android always supplies them.
* *
* Response: { code: string, expiresAt: Timestamp } * Response: { code: string, expiresAt: Timestamp }
* *
@ -115,11 +113,19 @@ exports.createInviteCallable = functions.https.onCall(async (data, context) => {
const kdfSalt = data === null || data === void 0 ? void 0 : data.kdfSalt; const kdfSalt = data === null || data === void 0 ? void 0 : data.kdfSalt;
const kdfParams = data === null || data === void 0 ? void 0 : data.kdfParams; const kdfParams = data === null || data === void 0 ? void 0 : data.kdfParams;
const encryptedRecoveryPhrase = data === null || data === void 0 ? void 0 : data.encryptedRecoveryPhrase; const encryptedRecoveryPhrase = data === null || data === void 0 ? void 0 : data.encryptedRecoveryPhrase;
// E2EE fields must be supplied together or omitted together. // Strict E2EE: every couple must be created with a wrapped couple key. The client-supplied
const e2eeFields = [wrappedCoupleKey, kdfSalt, kdfParams]; // code, wrapped key, KDF salt/params, and encrypted recovery phrase are all required.
const suppliedE2ee = e2eeFields.filter((v) => v != null).length; if (!clientCode) {
if (suppliedE2ee > 0 && suppliedE2ee < e2eeFields.length) { throw new functions.https.HttpsError('invalid-argument', 'code is required.');
throw new functions.https.HttpsError('invalid-argument', 'E2EE fields (wrappedCoupleKey, kdfSalt, kdfParams) must all be supplied together or omitted together.'); }
// 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', 'E2EE fields (wrappedCoupleKey, kdfSalt, kdfParams, encryptedRecoveryPhrase) are required.');
} }
const expiresAt = admin.firestore.Timestamp.fromMillis(now.toMillis() + INVITE_TTL_MS); const expiresAt = admin.firestore.Timestamp.fromMillis(now.toMillis() + INVITE_TTL_MS);
// Android supplies its own code (used as the KDF input for phrase encryption, so the server // Android supplies its own code (used as the KDF input for phrase encryption, so the server

File diff suppressed because one or more lines are too long

View File

@ -51,7 +51,7 @@ const admin = __importStar(require("firebase-admin"));
* partner notification, so we don't duplicate that here. * partner notification, so we don't duplicate that here.
*/ */
exports.leaveCoupleCallable = functions.https.onCall(async (_data, context) => { exports.leaveCoupleCallable = functions.https.onCall(async (_data, context) => {
var _a, _b, _c, _d; var _a, _b;
const callerId = (_a = context.auth) === null || _a === void 0 ? void 0 : _a.uid; const callerId = (_a = context.auth) === null || _a === void 0 ? void 0 : _a.uid;
if (!callerId) { if (!callerId) {
throw new functions.https.HttpsError('unauthenticated', 'Must be signed in.'); throw new functions.https.HttpsError('unauthenticated', 'Must be signed in.');
@ -67,23 +67,41 @@ exports.leaveCoupleCallable = functions.https.onCall(async (_data, context) => {
return { success: true }; return { success: true };
} }
const coupleRef = db.collection('couples').doc(coupleId); const coupleRef = db.collection('couples').doc(coupleId);
const coupleDoc = await coupleRef.get(); // Security review Batch 2: do the membership check, member-clearing, and couple-doc
if (!coupleDoc.exists) { // delete in one transaction so two partners leaving concurrently can't clobber state.
// Couple doc gone — just clear caller's field. // Critically, only clear a member's coupleId if it STILL points at this couple — a
await db.collection('users').doc(callerId).update({ coupleId: null }); // stale concurrent call must never wipe a coupleId set by a fresh re-pair.
return { success: true }; const result = await db.runTransaction(async (tx) => {
} var _a, _b, _c;
const userIds = ((_d = (_c = coupleDoc.data()) === null || _c === void 0 ? void 0 : _c.userIds) !== null && _d !== void 0 ? _d : []); const coupleSnap = await tx.get(coupleRef);
if (!userIds.includes(callerId)) { if (!coupleSnap.exists) {
const callerRef = db.collection('users').doc(callerId);
const callerSnap = await tx.get(callerRef);
if (((_a = callerSnap.data()) === null || _a === void 0 ? void 0 : _a.coupleId) === coupleId) {
tx.update(callerRef, { coupleId: null });
}
return { membership: true };
}
const userIds = ((_c = (_b = coupleSnap.data()) === null || _b === void 0 ? void 0 : _b.userIds) !== null && _c !== void 0 ? _c : []);
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) => {
var _a;
if (((_a = snap.data()) === null || _a === void 0 ? void 0 : _a.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}`);
return { success: true }; return { success: true };

View File

@ -1 +1 @@
{"version":3,"file":"leaveCoupleCallable.js","sourceRoot":"","sources":["../../src/couples/leaveCoupleCallable.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA,8DAA+C;AAC/C,sDAAuC;AAEvC;;;;;;;;;;;;;GAaG;AACU,QAAA,mBAAmB,GAAG,SAAS,CAAC,KAAK,CAAC,MAAM,CAAC,KAAK,EAAE,KAAK,EAAE,OAAO,EAAE,EAAE;;IACjF,MAAM,QAAQ,GAAG,MAAA,OAAO,CAAC,IAAI,0CAAE,GAAG,CAAA;IAClC,IAAI,CAAC,QAAQ,EAAE,CAAC;QACd,MAAM,IAAI,SAAS,CAAC,KAAK,CAAC,UAAU,CAAC,iBAAiB,EAAE,oBAAoB,CAAC,CAAA;IAC/E,CAAC;IACD,IAAI,CAAC,OAAO,CAAC,GAAG,EAAE,CAAC;QACjB,MAAM,IAAI,SAAS,CAAC,KAAK,CAAC,UAAU,CAAC,qBAAqB,EAAE,kCAAkC,CAAC,CAAA;IACjG,CAAC;IAED,MAAM,EAAE,GAAG,KAAK,CAAC,SAAS,EAAE,CAAA;IAE5B,MAAM,OAAO,GAAG,MAAM,EAAE,CAAC,UAAU,CAAC,OAAO,CAAC,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC,GAAG,EAAE,CAAA;IAChE,MAAM,QAAQ,GAAG,MAAA,OAAO,CAAC,IAAI,EAAE,0CAAE,QAA8B,CAAA;IAC/D,IAAI,CAAC,QAAQ,EAAE,CAAC;QACd,yCAAyC;QACzC,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,CAAA;IAC1B,CAAC;IAED,MAAM,SAAS,GAAG,EAAE,CAAC,UAAU,CAAC,SAAS,CAAC,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAA;IACxD,MAAM,SAAS,GAAG,MAAM,SAAS,CAAC,GAAG,EAAE,CAAA;IAEvC,IAAI,CAAC,SAAS,CAAC,MAAM,EAAE,CAAC;QACtB,+CAA+C;QAC/C,MAAM,EAAE,CAAC,UAAU,CAAC,OAAO,CAAC,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC,MAAM,CAAC,EAAE,QAAQ,EAAE,IAAI,EAAE,CAAC,CAAA;QACrE,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,CAAA;IAC1B,CAAC;IAED,MAAM,OAAO,GAAG,CAAC,MAAA,MAAA,SAAS,CAAC,IAAI,EAAE,0CAAE,OAAO,mCAAI,EAAE,CAAa,CAAA;IAC7D,IAAI,CAAC,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAC,EAAE,CAAC;QAChC,MAAM,IAAI,SAAS,CAAC,KAAK,CAAC,UAAU,CAAC,mBAAmB,EAAE,8BAA8B,CAAC,CAAA;IAC3F,CAAC;IAED,6CAA6C;IAC7C,MAAM,KAAK,GAAG,EAAE,CAAC,KAAK,EAAE,CAAA;IACxB,KAAK,MAAM,GAAG,IAAI,OAAO,EAAE,CAAC;QAC1B,KAAK,CAAC,MAAM,CAAC,EAAE,CAAC,UAAU,CAAC,OAAO,CAAC,CAAC,GAAG,CAAC,GAAG,CAAC,EAAE,EAAE,QAAQ,EAAE,IAAI,EAAE,CAAC,CAAA;IACnE,CAAC;IACD,MAAM,KAAK,CAAC,MAAM,EAAE,CAAA;IAEpB,6EAA6E;IAC7E,MAAM,EAAE,CAAC,eAAe,CAAC,SAAS,CAAC,CAAA;IAEnC,OAAO,CAAC,GAAG,CAAC,8BAA8B,QAAQ,gBAAgB,QAAQ,EAAE,CAAC,CAAA;IAC7E,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,CAAA;AAC1B,CAAC,CAAC,CAAA"} {"version":3,"file":"leaveCoupleCallable.js","sourceRoot":"","sources":["../../src/couples/leaveCoupleCallable.ts"],"names":[],"mappings":"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"}

View File

@ -33,59 +33,33 @@ var __importStar = (this && this.__importStar) || (function () {
}; };
})(); })();
Object.defineProperty(exports, "__esModule", { value: true }); Object.defineProperty(exports, "__esModule", { value: true });
exports.createDateMatchOnMutualLove = void 0; exports.notifyOnDateMatch = void 0;
const functions = __importStar(require("firebase-functions")); const functions = __importStar(require("firebase-functions"));
const admin = __importStar(require("firebase-admin")); const admin = __importStar(require("firebase-admin"));
const LOVE = 'love';
/** /**
* 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.
*/ */
exports.createDateMatchOnMutualLove = functions.firestore exports.notifyOnDateMatch = functions.firestore
.document('couples/{coupleId}/date_swipes/{dateIdeaId}') .document('couples/{coupleId}/date_matches/{dateIdeaId}')
.onWrite(async (change, context) => { .onCreate(async (snap, context) => {
var _a, _b, _c; var _a, _b;
const after = change.after.data(); if (!snap.exists)
if (!after)
return; // swipe document was deleted
const actions = ((_a = after.actions) !== null && _a !== void 0 ? _a : {});
const lovedBy = Object.entries(actions)
.filter(([, entry]) => (entry === null || entry === void 0 ? void 0 : entry.action) === LOVE)
.map(([uid]) => uid)
.sort();
// A match needs both partners to have loved the same idea.
if (lovedBy.length < 2)
return; return;
const { coupleId, dateIdeaId } = context.params; const { coupleId, dateIdeaId } = context.params;
const db = admin.firestore(); const db = admin.firestore();
const matchRef = db const matchRef = snap.ref;
.collection('couples') // Atomically claim the FCM send so concurrent invocations don't double-send.
.doc(coupleId)
.collection('date_matches')
.doc(dateIdeaId);
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.
const shouldSend = await db.runTransaction(async (tx) => { const shouldSend = await db.runTransaction(async (tx) => {
var _a; var _a;
const doc = await tx.get(matchRef); const doc = await tx.get(matchRef);
@ -94,11 +68,11 @@ exports.createDateMatchOnMutualLove = functions.firestore
tx.update(matchRef, { fcmNotified: true }); tx.update(matchRef, { fcmNotified: true });
return true; return true;
}); });
if (shouldSend) { if (!shouldSend)
const coupleDoc = await db.collection('couples').doc(coupleId).get(); return;
const userIds = ((_c = (_b = coupleDoc.data()) === null || _b === void 0 ? void 0 : _b.userIds) !== null && _c !== void 0 ? _c : []); const coupleDoc = await db.collection('couples').doc(coupleId).get();
await Promise.all(userIds.map((uid) => notifyDateMatch(db, uid, coupleId, dateIdeaId))); const userIds = ((_b = (_a = coupleDoc.data()) === null || _a === void 0 ? void 0 : _a.userIds) !== null && _b !== void 0 ? _b : []);
} await Promise.all(userIds.map((uid) => notifyDateMatch(db, uid, coupleId, dateIdeaId)));
}); });
async function notifyDateMatch(db, userId, coupleId, dateIdeaId) { async function notifyDateMatch(db, userId, coupleId, dateIdeaId) {
await db.collection('users').doc(userId).collection('notification_queue').add({ await db.collection('users').doc(userId).collection('notification_queue').add({

View File

@ -1 +1 @@
{"version":3,"file":"createDateMatch.js","sourceRoot":"","sources":["../../src/dates/createDateMatch.ts"],"names":[],"mappings":"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"} {"version":3,"file":"createDateMatch.js","sourceRoot":"","sources":["../../src/dates/createDateMatch.ts"],"names":[],"mappings":"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"}

View File

@ -45,7 +45,7 @@ const admin = __importStar(require("firebase-admin"));
exports.onGameSessionUpdate = functions.firestore exports.onGameSessionUpdate = functions.firestore
.document('couples/{coupleId}/sessions/{sessionId}') .document('couples/{coupleId}/sessions/{sessionId}')
.onWrite(async (change, context) => { .onWrite(async (change, context) => {
var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m; var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o, _p;
const { coupleId, sessionId } = context.params; const { coupleId, sessionId } = context.params;
const db = admin.firestore(); const db = admin.firestore();
const messaging = admin.messaging(); const messaging = admin.messaging();
@ -75,47 +75,46 @@ exports.onGameSessionUpdate = functions.firestore
const userB = await db.collection('users').doc(partnerB).get(); const userB = await db.collection('users').doc(partnerB).get();
const partnerAName = (_d = (_c = userA.data()) === null || _c === void 0 ? void 0 : _c.displayName) !== null && _d !== void 0 ? _d : 'Partner A'; const partnerAName = (_d = (_c = userA.data()) === null || _c === void 0 ? void 0 : _c.displayName) !== null && _d !== void 0 ? _d : 'Partner A';
const partnerBName = (_f = (_e = userB.data()) === null || _e === void 0 ? void 0 : _e.displayName) !== null && _f !== void 0 ? _f : 'Partner B'; const partnerBName = (_f = (_e = userB.data()) === null || _e === void 0 ? void 0 : _e.displayName) !== null && _f !== void 0 ? _f : 'Partner B';
const avatarA = (_g = userA.data()) === null || _g === void 0 ? void 0 : _g.photoUrl;
const avatarB = (_h = userB.data()) === null || _h === void 0 ? void 0 : _h.photoUrl;
// Check if session was just created (status = "active") // Check if session was just created (status = "active")
const previousData = (_g = change.before.data()) !== null && _g !== void 0 ? _g : {}; const previousData = (_j = change.before.data()) !== null && _j !== void 0 ? _j : {};
const currentData = (_h = change.after.data()) !== null && _h !== void 0 ? _h : {}; const currentData = (_k = change.after.data()) !== null && _k !== void 0 ? _k : {};
const wasInactive = ((_j = previousData.status) !== null && _j !== void 0 ? _j : '') !== 'active'; const wasInactive = ((_l = previousData.status) !== null && _l !== void 0 ? _l : '') !== 'active';
const isActiveNow = currentData.status === 'active'; const isActiveNow = currentData.status === 'active';
if (wasInactive && isActiveNow) { if (wasInactive && isActiveNow) {
// New session started - notify the other partner // New session started — notify the OTHER partner, naming the person who started it.
const startedBy = currentData.startedByUserId; const startedBy = currentData.startedByUserId;
const gameType = (_k = currentData.gameType) !== null && _k !== void 0 ? _k : 'wheel'; const gameType = (_m = currentData.gameType) !== null && _m !== void 0 ? _m : 'wheel';
const partnerId = startedBy === partnerA ? partnerB : partnerA; const recipientId = startedBy === partnerA ? partnerB : partnerA;
const partnerName = startedBy === partnerA ? partnerBName : partnerAName; const starterName = startedBy === partnerA ? partnerAName : partnerBName;
await notifyPartner(db, messaging, partnerId, partnerName, gameType, 'partner_started_game', `${partnerName} has started a game. Tap to join!`, coupleId); const starterAvatar = startedBy === partnerA ? avatarA : avatarB;
await notifyPartner(db, messaging, recipientId, starterName, gameType, 'partner_started_game', `${starterName} has started a game. Tap to join!`, coupleId, starterAvatar);
return; return;
} }
// Check if session was completed // Check if session was completed
const wasActive = ((_l = previousData.status) !== null && _l !== void 0 ? _l : '') === 'active'; const wasActive = ((_o = previousData.status) !== null && _o !== void 0 ? _o : '') === 'active';
const isCompletedNow = currentData.status === 'completed'; const isCompletedNow = currentData.status === 'completed';
if (wasActive && isCompletedNow) { if (wasActive && isCompletedNow) {
const completedBy = currentData.startedByUserId; // The session is complete (both partners have answered) — the reveal is ready for each of
const partnerId = completedBy === partnerA ? partnerB : partnerA; // them, so notify BOTH, each naming the OTHER partner.
const completingPartnerName = completedBy === partnerA ? partnerAName : partnerBName; const gt = (_p = currentData.gameType) !== null && _p !== void 0 ? _p : 'wheel';
const gt = (_m = currentData.gameType) !== null && _m !== void 0 ? _m : 'wheel'; await notifyPartner(db, messaging, partnerA, partnerBName, gt, 'partner_finished_game', `${partnerBName} finished — tap to see your results!`, coupleId, avatarB);
const partnerCompletedAt = currentData.partnerCompletedAt; await notifyPartner(db, messaging, partnerB, partnerAName, gt, 'partner_finished_game', `${partnerAName} finished — tap to see your results!`, coupleId, avatarA);
if (partnerCompletedAt) {
await notifyPartner(db, messaging, partnerA, partnerAName, gt, 'partner_finished_game', `${partnerBName} has finished the game. Tap to see the results!`, coupleId);
await notifyPartner(db, messaging, partnerB, partnerBName, gt, 'partner_finished_game', `${partnerAName} has finished the game. Tap to see the results!`, coupleId);
}
else {
await notifyPartner(db, messaging, partnerId, completingPartnerName, gt, 'partner_finished_game', `${completingPartnerName} has finished. Tap to continue playing!`, coupleId);
}
return; return;
} }
}); });
/** /**
* Send notification to partner via FCM and write to notification_queue. * Send notification to partner via FCM and write to notification_queue.
*/ */
async function notifyPartner(db, messaging, partnerId, partnerName, gameType, notificationType, body, coupleId) { async function notifyPartner(db, messaging, partnerId, partnerName, gameType, notificationType, body, coupleId, senderAvatarUrl) {
var _a; var _a;
const title = notificationType === 'partner_finished_game'
? `${partnerName} finished the game`
: `${partnerName} is playing`;
const notificationPayload = { const notificationPayload = {
type: notificationType, type: notificationType,
title: `${partnerName} is playing`, title,
body: body, body: body,
}; };
// Write an in-app notification record for the partner // Write an in-app notification record for the partner
@ -155,11 +154,9 @@ async function notifyPartner(db, messaging, partnerId, partnerName, gameType, no
title: notificationPayload.title, title: notificationPayload.title,
body: notificationPayload.body, body: notificationPayload.body,
}, },
data: { data: Object.assign({ type: notificationPayload.type, couple_id: coupleId, game_type: gameType }, (senderAvatarUrl && senderAvatarUrl.length > 0
type: notificationPayload.type, ? { sender_avatar_url: senderAvatarUrl }
couple_id: coupleId, : {})),
game_type: gameType,
},
}; };
const sendResults = await Promise.allSettled(tokens.map((token) => messaging.send(Object.assign(Object.assign({}, fcmMessage), { token })))); const sendResults = await Promise.allSettled(tokens.map((token) => messaging.send(Object.assign(Object.assign({}, fcmMessage), { token }))));
const failures = []; const failures = [];

File diff suppressed because one or more lines are too long

View File

@ -33,8 +33,7 @@ var __importStar = (this && this.__importStar) || (function () {
}; };
})(); })();
Object.defineProperty(exports, "__esModule", { value: true }); Object.defineProperty(exports, "__esModule", { value: true });
exports.health = exports.onGameSessionUpdate = exports.onUserDelete = exports.scheduledOutcomesReminder = exports.submitOutcomeCallable = exports.createInviteCallable = exports.acceptInviteCallable = exports.leaveCoupleCallable = exports.onCoupleLeave = exports.onMessageWritten = exports.onAnswerWritten = exports.assignDailyQuestionCallable = exports.assignDailyQuestion = exports.createDateMatchOnMutualLove = exports.checkDeviceIntegrity = exports.sendReengagementReminder = exports.sendDailyQuestionProactiveReminder = exports.unlockDueMemoryCapsules = exports.sendChallengeDayReminders = exports.sendGentleReminderCallable = exports.sendPartnerAnsweredNotification = exports.sendDailyQuestionReminder = exports.syncEntitlement = exports.revenueCatWebhook = void 0; exports.onGameSessionUpdate = exports.onUserDelete = exports.scheduledOutcomesReminder = exports.submitOutcomeCallable = exports.createInviteCallable = exports.acceptInviteCallable = exports.leaveCoupleCallable = exports.onCoupleLeave = exports.onMessageWritten = exports.onAnswerWritten = exports.assignDailyQuestionCallable = exports.assignDailyQuestion = exports.notifyOnDateMatch = exports.checkDeviceIntegrity = exports.sendReengagementReminder = exports.sendDailyQuestionProactiveReminder = exports.unlockDueMemoryCapsules = exports.sendChallengeDayReminders = exports.sendGentleReminderCallable = exports.sendPartnerAnsweredNotification = exports.sendDailyQuestionReminder = exports.syncEntitlement = exports.revenueCatWebhook = void 0;
const functions = __importStar(require("firebase-functions"));
const admin = __importStar(require("firebase-admin")); const admin = __importStar(require("firebase-admin"));
// Initialize the Admin SDK once for every function in this codebase. // Initialize the Admin SDK once for every function in this codebase.
// Handlers call admin.firestore()/messaging() lazily at invocation time, so a // Handlers call admin.firestore()/messaging() lazily at invocation time, so a
@ -61,7 +60,7 @@ Object.defineProperty(exports, "sendReengagementReminder", { enumerable: true, g
var checkDeviceIntegrity_1 = require("./security/checkDeviceIntegrity"); var checkDeviceIntegrity_1 = require("./security/checkDeviceIntegrity");
Object.defineProperty(exports, "checkDeviceIntegrity", { enumerable: true, get: function () { return checkDeviceIntegrity_1.checkDeviceIntegrity; } }); Object.defineProperty(exports, "checkDeviceIntegrity", { enumerable: true, get: function () { return checkDeviceIntegrity_1.checkDeviceIntegrity; } });
var createDateMatch_1 = require("./dates/createDateMatch"); var createDateMatch_1 = require("./dates/createDateMatch");
Object.defineProperty(exports, "createDateMatchOnMutualLove", { enumerable: true, get: function () { return createDateMatch_1.createDateMatchOnMutualLove; } }); Object.defineProperty(exports, "notifyOnDateMatch", { enumerable: true, get: function () { return createDateMatch_1.notifyOnDateMatch; } });
var assignDailyQuestion_1 = require("./questions/assignDailyQuestion"); var assignDailyQuestion_1 = require("./questions/assignDailyQuestion");
Object.defineProperty(exports, "assignDailyQuestion", { enumerable: true, get: function () { return assignDailyQuestion_1.assignDailyQuestion; } }); Object.defineProperty(exports, "assignDailyQuestion", { enumerable: true, get: function () { return assignDailyQuestion_1.assignDailyQuestion; } });
Object.defineProperty(exports, "assignDailyQuestionCallable", { enumerable: true, get: function () { return assignDailyQuestion_1.assignDailyQuestionCallable; } }); Object.defineProperty(exports, "assignDailyQuestionCallable", { enumerable: true, get: function () { return assignDailyQuestion_1.assignDailyQuestionCallable; } });
@ -85,11 +84,8 @@ var onUserDelete_1 = require("./users/onUserDelete");
Object.defineProperty(exports, "onUserDelete", { enumerable: true, get: function () { return onUserDelete_1.onUserDelete; } }); Object.defineProperty(exports, "onUserDelete", { enumerable: true, get: function () { return onUserDelete_1.onUserDelete; } });
var onGameSessionUpdate_1 = require("./games/onGameSessionUpdate"); var onGameSessionUpdate_1 = require("./games/onGameSessionUpdate");
Object.defineProperty(exports, "onGameSessionUpdate", { enumerable: true, get: function () { return onGameSessionUpdate_1.onGameSessionUpdate; } }); Object.defineProperty(exports, "onGameSessionUpdate", { enumerable: true, get: function () { return onGameSessionUpdate_1.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.
exports.health = functions.https.onRequest((req, res) => {
res.status(200).json({ status: 'ok' });
});
//# sourceMappingURL=index.js.map //# sourceMappingURL=index.js.map

View File

@ -1 +1 @@
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA,8DAA+C;AAC/C,sDAAuC;AAEvC,qEAAqE;AACrE,8EAA8E;AAC9E,gFAAgF;AAChF,IAAI,KAAK,CAAC,IAAI,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;IAC5B,KAAK,CAAC,aAAa,EAAE,CAAA;AACvB,CAAC;AAED,iEAA+D;AAAtD,sHAAA,iBAAiB,OAAA;AAC1B,6DAA2D;AAAlD,kHAAA,eAAe,OAAA;AACxB,uDAGkC;AAFhC,sHAAA,yBAAyB,OAAA;AACzB,4HAAA,+BAA+B,OAAA;AAEjC,yFAAuF;AAA9E,wIAAA,0BAA0B,OAAA;AACnC,+DAGsC;AAFpC,0HAAA,yBAAyB,OAAA;AACzB,wHAAA,uBAAuB,OAAA;AAEzB,+EAA0F;AAAjF,2IAAA,kCAAkC,OAAA;AAC3C,6DAAuE;AAA9D,wHAAA,wBAAwB,OAAA;AACjC,wEAAsE;AAA7D,4HAAA,oBAAoB,OAAA;AAC7B,2DAAqE;AAA5D,8HAAA,2BAA2B,OAAA;AACpC,uEAGwC;AAFtC,0HAAA,mBAAmB,OAAA;AACnB,kIAAA,2BAA2B,OAAA;AAE7B,+DAA6D;AAApD,kHAAA,eAAe,OAAA;AACxB,iEAA+D;AAAtD,oHAAA,gBAAgB,OAAA;AACzB,yDAAuD;AAA9C,8GAAA,aAAa,OAAA;AACtB,qEAAmE;AAA1D,0HAAA,mBAAmB,OAAA;AAC5B,uEAAqE;AAA5D,4HAAA,oBAAoB,OAAA;AAC7B,uEAAqE;AAA5D,4HAAA,oBAAoB,OAAA;AAC7B,yEAAuE;AAA9D,8HAAA,qBAAqB,OAAA;AAC9B,iFAA+E;AAAtE,sIAAA,yBAAyB,OAAA;AAClC,qDAAmD;AAA1C,4GAAA,YAAY,OAAA;AACrB,mEAAiE;AAAxD,0HAAA,mBAAmB,OAAA;AAE5B;;;GAGG;AACU,QAAA,MAAM,GAAG,SAAS,CAAC,KAAK,CAAC,SAAS,CAAC,CAAC,GAAG,EAAE,GAAG,EAAE,EAAE;IAC3D,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,EAAE,MAAM,EAAE,IAAI,EAAE,CAAC,CAAA;AACxC,CAAC,CAAC,CAAA"} {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA,sDAAuC;AAEvC,qEAAqE;AACrE,8EAA8E;AAC9E,gFAAgF;AAChF,IAAI,KAAK,CAAC,IAAI,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;IAC5B,KAAK,CAAC,aAAa,EAAE,CAAA;AACvB,CAAC;AAED,iEAA+D;AAAtD,sHAAA,iBAAiB,OAAA;AAC1B,6DAA2D;AAAlD,kHAAA,eAAe,OAAA;AACxB,uDAGkC;AAFhC,sHAAA,yBAAyB,OAAA;AACzB,4HAAA,+BAA+B,OAAA;AAEjC,yFAAuF;AAA9E,wIAAA,0BAA0B,OAAA;AACnC,+DAGsC;AAFpC,0HAAA,yBAAyB,OAAA;AACzB,wHAAA,uBAAuB,OAAA;AAEzB,+EAA0F;AAAjF,2IAAA,kCAAkC,OAAA;AAC3C,6DAAuE;AAA9D,wHAAA,wBAAwB,OAAA;AACjC,wEAAsE;AAA7D,4HAAA,oBAAoB,OAAA;AAC7B,2DAA2D;AAAlD,oHAAA,iBAAiB,OAAA;AAC1B,uEAGwC;AAFtC,0HAAA,mBAAmB,OAAA;AACnB,kIAAA,2BAA2B,OAAA;AAE7B,+DAA6D;AAApD,kHAAA,eAAe,OAAA;AACxB,iEAA+D;AAAtD,oHAAA,gBAAgB,OAAA;AACzB,yDAAuD;AAA9C,8GAAA,aAAa,OAAA;AACtB,qEAAmE;AAA1D,0HAAA,mBAAmB,OAAA;AAC5B,uEAAqE;AAA5D,4HAAA,oBAAoB,OAAA;AAC7B,uEAAqE;AAA5D,4HAAA,oBAAoB,OAAA;AAC7B,yEAAuE;AAA9D,8HAAA,qBAAqB,OAAA;AAC9B,iFAA+E;AAAtE,sIAAA,yBAAyB,OAAA;AAClC,qDAAmD;AAA1C,4GAAA,YAAY,OAAA;AACrB,mEAAiE;AAAxD,0HAAA,mBAAmB,OAAA;AAE5B,oFAAoF;AACpF,uEAAuE;AACvE,iFAAiF;AACjF,0DAA0D"}

View File

@ -123,7 +123,18 @@ exports.assignDailyQuestionCallable = functions.https.onCall(async (data, contex
if (!userIds.includes(callerId)) { if (!userIds.includes(callerId)) {
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 === null || data === void 0 ? void 0 : 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 === null || data === void 0 ? void 0 : 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.');

File diff suppressed because one or more lines are too long

View File

@ -48,7 +48,7 @@ const admin = __importStar(require("firebase-admin"));
exports.onAnswerWritten = functions.firestore exports.onAnswerWritten = functions.firestore
.document('couples/{coupleId}/daily_question/{date}/answers/{userId}') .document('couples/{coupleId}/daily_question/{date}/answers/{userId}')
.onCreate(async (snap, context) => { .onCreate(async (snap, context) => {
var _a, _b, _c, _d; var _a, _b, _c, _d, _e;
const { coupleId, date, userId } = context.params; const { coupleId, date, userId } = context.params;
const db = admin.firestore(); const db = admin.firestore();
const coupleDoc = await db.collection('couples').doc(coupleId).get(); const coupleDoc = await db.collection('couples').doc(coupleId).get();
@ -57,6 +57,13 @@ exports.onAnswerWritten = functions.firestore
return; return;
} }
const userIds = ((_b = (_a = coupleDoc.data()) === null || _a === void 0 ? void 0 : _a.userIds) !== null && _b !== void 0 ? _b : []); const userIds = ((_b = (_a = coupleDoc.data()) === null || _a === void 0 ? void 0 : _a.userIds) !== null && _b !== void 0 ? _b : []);
// 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}`);
@ -96,17 +103,17 @@ exports.onAnswerWritten = functions.firestore
} }
const answerData = snap.data(); const answerData = snap.data();
const questionId = typeof answerData.questionId === 'string' ? answerData.questionId : ''; const questionId = typeof answerData.questionId === 'string' ? answerData.questionId : '';
// Sender (the partner who just answered) avatar — used as the notification large icon.
const senderDoc = await db.collection('users').doc(userId).get();
const senderAvatar = (_e = senderDoc.data()) === null || _e === void 0 ? void 0 : _e.photoUrl;
const payload = { const payload = {
notification: { notification: {
title: 'Your partner just answered!', title: 'Your partner just answered!',
body: "See what they shared for tonight's prompt.", body: "See what they shared for tonight's prompt.",
}, },
data: { data: Object.assign({ type: 'partner_answered', couple_id: coupleId, question_id: questionId, date }, (typeof senderAvatar === 'string' && senderAvatar.length > 0
type: 'partner_answered', ? { sender_avatar_url: senderAvatar }
couple_id: coupleId, : {})),
question_id: questionId,
date,
},
}; };
const sendResults = await Promise.allSettled(tokens.map((token) => admin.messaging().send(Object.assign(Object.assign({}, payload), { token })))); const sendResults = await Promise.allSettled(tokens.map((token) => admin.messaging().send(Object.assign(Object.assign({}, payload), { token }))));
const failures = []; const failures = [];

View File

@ -1 +1 @@
{"version":3,"file":"onAnswerWritten.js","sourceRoot":"","sources":["../../src/questions/onAnswerWritten.ts"],"names":[],"mappings":"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"} {"version":3,"file":"onAnswerWritten.js","sourceRoot":"","sources":["../../src/questions/onAnswerWritten.ts"],"names":[],"mappings":"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"}

View File

@ -47,7 +47,7 @@ const admin = __importStar(require("firebase-admin"));
exports.onMessageWritten = functions.firestore exports.onMessageWritten = functions.firestore
.document('couples/{coupleId}/question_threads/{threadId}/messages/{messageId}') .document('couples/{coupleId}/question_threads/{threadId}/messages/{messageId}')
.onCreate(async (snap, context) => { .onCreate(async (snap, context) => {
var _a, _b, _c, _d; var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k;
const { coupleId, threadId, messageId } = context.params; const { coupleId, threadId, messageId } = context.params;
const db = admin.firestore(); const db = admin.firestore();
const messageData = snap.data(); const messageData = snap.data();
@ -67,16 +67,23 @@ exports.onMessageWritten = functions.firestore
console.warn(`[onMessageWritten] no partner found for couple ${coupleId}`); console.warn(`[onMessageWritten] no partner found for couple ${coupleId}`);
return; return;
} }
// The conversation deep link + the client's "am I already in this thread?" suppression both
// key off questionId, so resolve it from the thread doc and pass it through.
const threadDoc = await db
.collection('couples').doc(coupleId)
.collection('question_threads').doc(threadId)
.get();
const questionId = (_d = (_c = threadDoc.data()) === null || _c === void 0 ? void 0 : _c.questionId) !== null && _d !== void 0 ? _d : '';
const partnerUserDoc = await db.collection('users').doc(partnerId).get(); const partnerUserDoc = await db.collection('users').doc(partnerId).get();
// Respect the partner's notification preference (opt-out; default is enabled). // Respect the partner's notification preference (opt-out; default is enabled).
const notifEnabled = (_c = partnerUserDoc.data()) === null || _c === void 0 ? void 0 : _c.notifChatMessage; const notifEnabled = (_e = partnerUserDoc.data()) === null || _e === void 0 ? void 0 : _e.notifChatMessage;
if (notifEnabled === false) { if (notifEnabled === false) {
console.log(`[onMessageWritten] partner ${partnerId} has chat notifications off`); console.log(`[onMessageWritten] partner ${partnerId} has chat notifications off`);
return; return;
} }
const tokens = []; const tokens = [];
if (partnerUserDoc.exists) { if (partnerUserDoc.exists) {
const legacyToken = (_d = partnerUserDoc.data()) === null || _d === void 0 ? void 0 : _d.fcmToken; const legacyToken = (_f = partnerUserDoc.data()) === null || _f === void 0 ? void 0 : _f.fcmToken;
if (typeof legacyToken === 'string' && legacyToken.length > 0) { if (typeof legacyToken === 'string' && legacyToken.length > 0) {
tokens.push(legacyToken); tokens.push(legacyToken);
} }
@ -97,16 +104,17 @@ exports.onMessageWritten = functions.firestore
console.log(`[onMessageWritten] no FCM tokens for partner ${partnerId}`); console.log(`[onMessageWritten] no FCM tokens for partner ${partnerId}`);
return; return;
} }
// The recipient sees the message from the author (their partner), so surface the author's
// photo/name — the in-app chat bubble uses sender_avatar_url to show the partner's face.
const authorDoc = await db.collection('users').doc(authorId).get();
const authorPhotoUrl = (_h = (_g = authorDoc.data()) === null || _g === void 0 ? void 0 : _g.photoUrl) !== null && _h !== void 0 ? _h : '';
const authorName = (_k = (_j = authorDoc.data()) === null || _j === void 0 ? void 0 : _j.displayName) !== null && _k !== void 0 ? _k : '';
const payload = { const payload = {
notification: { notification: {
title: 'Your partner sent a message', title: authorName ? `${authorName} sent a message` : 'Your partner sent a message',
body: 'Tap to read and reply.', body: 'Tap to read and reply.',
}, },
data: { data: Object.assign(Object.assign({ type: 'chat_message', couple_id: coupleId, thread_id: threadId }, (questionId ? { question_id: questionId } : {})), (authorPhotoUrl ? { sender_avatar_url: authorPhotoUrl } : {})),
type: 'chat_message',
couple_id: coupleId,
thread_id: threadId,
},
}; };
const sendResults = await Promise.allSettled(tokens.map((token) => admin.messaging().send(Object.assign(Object.assign({}, payload), { token })))); const sendResults = await Promise.allSettled(tokens.map((token) => admin.messaging().send(Object.assign(Object.assign({}, payload), { token }))));
const failures = []; const failures = [];

View File

@ -1 +1 @@
{"version":3,"file":"onMessageWritten.js","sourceRoot":"","sources":["../../src/questions/onMessageWritten.ts"],"names":[],"mappings":"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"} {"version":3,"file":"onMessageWritten.js","sourceRoot":"","sources":["../../src/questions/onMessageWritten.ts"],"names":[],"mappings":"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"}

View File

@ -81,9 +81,15 @@ export const onMessageWritten = functions.firestore
return return
} }
// The recipient sees the message from the author (their partner), so surface the author's
// photo/name — the in-app chat bubble uses sender_avatar_url to show the partner's face.
const authorDoc = await db.collection('users').doc(authorId).get()
const authorPhotoUrl = (authorDoc.data()?.photoUrl as string | undefined) ?? ''
const authorName = (authorDoc.data()?.displayName as string | undefined) ?? ''
const payload: admin.messaging.MessagingPayload = { const payload: admin.messaging.MessagingPayload = {
notification: { notification: {
title: 'Your partner sent a message', title: authorName ? `${authorName} sent a message` : 'Your partner sent a message',
body: 'Tap to read and reply.', body: 'Tap to read and reply.',
}, },
data: { data: {
@ -91,6 +97,7 @@ export const onMessageWritten = functions.firestore
couple_id: coupleId, couple_id: coupleId,
thread_id: threadId, thread_id: threadId,
...(questionId ? { question_id: questionId } : {}), ...(questionId ? { question_id: questionId } : {}),
...(authorPhotoUrl ? { sender_avatar_url: authorPhotoUrl } : {}),
}, },
} }

View File

@ -20,6 +20,16 @@ service firebase.storage {
allow read: if request.auth != null && request.auth.uid == uid; allow read: if request.auth != null && request.auth.uid == uid;
} }
// Encrypted chat media: the author writes under their own path (already E2E-encrypted
// ciphertext, so Storage never holds anything readable). The partner reads via the tokenized
// download URL, which bypasses these rules — same model as profile photos. 15 MB cap.
match /users/{uid}/chat_media/{file} {
allow write: if request.auth != null
&& request.auth.uid == uid
&& request.resource.size < 15 * 1024 * 1024;
allow read: if request.auth != null && request.auth.uid == uid;
}
// Deny all other paths by default. // Deny all other paths by default.
match /{allPaths=**} { match /{allPaths=**} {
allow read, write: if false; allow read, write: if false;