feat(conversations): data layer — domain model, Firestore data source, repository, DI bindings

- Conversation model with id, type (couple_chat/question_discussion), questionId, lastMessage fields
- FirestoreConversationDataSource: create, sendMessage, observeConversations, observeMessages
- ConversationRepositoryImpl: wraps data source
- RepositoryModule: bind ConversationRepository
- FirestoreCollections: CONVERSATIONS subcollection constants
This commit is contained in:
null 2026-06-24 16:13:45 -05:00
parent 4e2c3fdf0d
commit db5b8a5f8a
6 changed files with 296 additions and 0 deletions

View File

@ -26,6 +26,7 @@ object FirestoreCollections {
object Couples {
const val SESSIONS = "sessions"
const val QUESTION_THREADS = "question_threads"
const val CONVERSATIONS = "conversations"
const val DATE_SWIPES = "date_swipes"
const val DATE_MATCHES = "date_matches"
const val DATE_PLAN_PREFERENCES = "date_plan_preferences"
@ -60,4 +61,10 @@ object FirestoreCollections {
const val REACTIONS = "reactions"
const val RELEASE_KEYS = "releaseKeys"
}
// ── Subcollections under …/conversations/{conversationId} ─────────────────
object Conversations {
const val MESSAGES = "messages"
const val MAIN_ID = "main"
}
}

View File

@ -0,0 +1,210 @@
package app.closer.data.remote
import app.closer.crypto.CoupleEncryptionManager
import app.closer.crypto.FieldEncryptor
import app.closer.domain.model.Conversation
import app.closer.domain.model.QuestionMessage
import com.google.firebase.firestore.DocumentSnapshot
import com.google.firebase.firestore.FieldValue
import com.google.firebase.firestore.FirebaseFirestore
import com.google.firebase.firestore.Query
import com.google.firebase.firestore.SetOptions
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.suspendCancellableCoroutine
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
/**
* Backs the Messages inbox + each conversation's chat. Every conversation lives under
* couples/{coupleId}/conversations/{conversationId} with a `messages` subcollection. All message
* content (text + images) and the inbox `lastMessagePreview` are E2E-encrypted with the couple key
* the server only ever sees ciphertext. Mirrors the encryption used by the question threads.
*/
@Singleton
class FirestoreConversationDataSource @Inject constructor(
private val db: FirebaseFirestore,
private val encryptionManager: CoupleEncryptionManager,
private val fieldEncryptor: FieldEncryptor,
private val storageDataSource: FirebaseStorageDataSource
) {
private fun conversationsRef(coupleId: String) =
db.collection(FirestoreCollections.COUPLES).document(coupleId)
.collection(FirestoreCollections.Couples.CONVERSATIONS)
private fun messagesRef(coupleId: String, conversationId: String) =
conversationsRef(coupleId).document(conversationId)
.collection(FirestoreCollections.Conversations.MESSAGES)
fun questionConversationId(questionId: String) = "q_$questionId"
// ─── Conversations (inbox) ─────────────────────────────────────────────────────
fun observeConversations(coupleId: String, currentUserId: String): Flow<List<Conversation>> = callbackFlow {
val listener = conversationsRef(coupleId)
.addSnapshotListener { snap, err ->
if (err != null || snap == null) return@addSnapshotListener
val aead = encryptionManager.aeadFor(coupleId)
trySend(snap.documents.map { it.toConversation(aead, coupleId, currentUserId) })
}
awaitClose { listener.remove() }
}
/** Creates the pinned free-form couple conversation if it doesn't exist yet. */
suspend fun ensureMainConversation(coupleId: String) {
val ref = conversationsRef(coupleId).document(FirestoreCollections.Conversations.MAIN_ID)
if (runCatching { ref.get().await().exists() }.getOrDefault(false)) return
ref.set(
mapOf(
"type" to "main",
"createdAt" to FieldValue.serverTimestamp()
),
SetOptions.merge()
).voidAwait()
}
/** Creates the per-question conversation if needed and returns its id. */
suspend fun ensureQuestionConversation(coupleId: String, questionId: String): String {
val convId = questionConversationId(questionId)
val ref = conversationsRef(coupleId).document(convId)
if (!runCatching { ref.get().await().exists() }.getOrDefault(false)) {
ref.set(
mapOf(
"type" to "question",
"questionId" to questionId,
"createdAt" to FieldValue.serverTimestamp()
),
SetOptions.merge()
).voidAwait()
}
return convId
}
suspend fun markRead(coupleId: String, conversationId: String, userId: String) {
conversationsRef(coupleId).document(conversationId)
.set(mapOf("reads" to mapOf(userId to FieldValue.serverTimestamp())), SetOptions.merge())
.voidAwait()
}
// ─── Messages ──────────────────────────────────────────────────────────────────
suspend fun sendMessage(coupleId: String, conversationId: String, userId: String, text: String) {
val aead = encryptionManager.requireAead(coupleId)
val cipher = fieldEncryptor.encrypt(text, aead, coupleId)
messagesRef(coupleId, conversationId).add(
mapOf(
"authorUserId" to userId,
"type" to "text",
"text" to cipher,
"createdAt" to FieldValue.serverTimestamp()
)
).refAwait()
updateLastMessage(coupleId, conversationId, userId, cipher)
}
suspend fun sendImageMessage(coupleId: String, conversationId: 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)
messagesRef(coupleId, conversationId).add(
mapOf(
"authorUserId" to userId,
"type" to "image",
"mediaUrl" to url,
"createdAt" to FieldValue.serverTimestamp()
)
).refAwait()
// Preview a photo with a fixed (still encrypted) label so the inbox decrypt path is uniform.
updateLastMessage(coupleId, conversationId, userId, fieldEncryptor.encrypt("📷 Photo", aead, coupleId))
}
private suspend fun updateLastMessage(coupleId: String, conversationId: String, userId: String, encryptedPreview: String) {
conversationsRef(coupleId).document(conversationId).set(
mapOf(
"lastMessageAt" to FieldValue.serverTimestamp(),
"lastMessagePreview" to encryptedPreview,
"lastMessageSenderId" to userId
),
SetOptions.merge()
).voidAwait()
}
fun observeMessages(coupleId: String, conversationId: String): Flow<List<QuestionMessage>> = callbackFlow {
val listener = messagesRef(coupleId, conversationId)
.orderBy("createdAt", Query.Direction.ASCENDING)
.addSnapshotListener { snap, err ->
if (err != null || snap == null) return@addSnapshotListener
val aead = encryptionManager.aeadFor(coupleId)
trySend(snap.documents.mapNotNull { it.toQuestionMessage(aead, coupleId) })
}
awaitClose { listener.remove() }
}
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()
}
// ─── Mappers / await helpers ────────────────────────────────────────────────────
private fun DocumentSnapshot.toConversation(
aead: com.google.crypto.tink.Aead?,
coupleId: String,
currentUserId: String
): Conversation {
val lastAt = getTimestamp("lastMessageAt")?.toDate()?.time ?: 0L
val senderId = getString("lastMessageSenderId") ?: ""
@Suppress("UNCHECKED_CAST")
val reads = (get("reads") as? Map<String, com.google.firebase.Timestamp>).orEmpty()
val myReadAt = reads[currentUserId]?.toDate()?.time ?: 0L
val unread = lastAt > 0L && senderId != currentUserId && lastAt > myReadAt
return Conversation(
id = id,
type = getString("type") ?: "main",
questionId = getString("questionId") ?: "",
lastMessagePreview = fieldEncryptor.decryptForDisplay(getString("lastMessagePreview"), aead, coupleId) ?: "",
lastMessageAt = lastAt,
lastMessageSenderId = senderId,
unread = unread
)
}
private fun DocumentSnapshot.toQuestionMessage(
aead: com.google.crypto.tink.Aead?,
coupleId: String
): QuestionMessage? {
val userId = getString("authorUserId") ?: return null
val type = getString("type") ?: "text"
return QuestionMessage(
id = id,
userId = userId,
type = type,
mediaUrl = getString("mediaUrl") ?: "",
text = if (type == "image") "" else (fieldEncryptor.decryptForDisplay(getString("text"), aead, coupleId) ?: ""),
createdAt = getTimestamp("createdAt")?.toDate()?.time ?: 0L
)
}
private suspend fun <T> com.google.android.gms.tasks.Task<T>.await(): T =
suspendCancellableCoroutine { cont ->
addOnSuccessListener { cont.resume(it) }
addOnFailureListener { cont.resumeWithException(it) }
}
private suspend fun com.google.android.gms.tasks.Task<Void>.voidAwait() =
suspendCancellableCoroutine<Unit> { cont ->
addOnSuccessListener { cont.resume(Unit) }
addOnFailureListener { cont.resumeWithException(it) }
}
private suspend fun com.google.android.gms.tasks.Task<com.google.firebase.firestore.DocumentReference>.refAwait() =
suspendCancellableCoroutine<Unit> { cont ->
addOnSuccessListener { cont.resume(Unit) }
addOnFailureListener { cont.resumeWithException(it) }
}
}

View File

@ -0,0 +1,39 @@
package app.closer.data.repository
import app.closer.data.remote.FirestoreConversationDataSource
import app.closer.domain.model.Conversation
import app.closer.domain.model.QuestionMessage
import app.closer.domain.repository.ConversationRepository
import kotlinx.coroutines.flow.Flow
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class ConversationRepositoryImpl @Inject constructor(
private val dataSource: FirestoreConversationDataSource
) : ConversationRepository {
override fun observeConversations(coupleId: String, currentUserId: String): Flow<List<Conversation>> =
dataSource.observeConversations(coupleId, currentUserId)
override suspend fun ensureMainConversation(coupleId: String) =
dataSource.ensureMainConversation(coupleId)
override suspend fun ensureQuestionConversation(coupleId: String, questionId: String): String =
dataSource.ensureQuestionConversation(coupleId, questionId)
override suspend fun markRead(coupleId: String, conversationId: String, userId: String) =
dataSource.markRead(coupleId, conversationId, userId)
override fun observeMessages(coupleId: String, conversationId: String): Flow<List<QuestionMessage>> =
dataSource.observeMessages(coupleId, conversationId)
override suspend fun sendMessage(coupleId: String, conversationId: String, userId: String, text: String) =
dataSource.sendMessage(coupleId, conversationId, userId, text)
override suspend fun sendImageMessage(coupleId: String, conversationId: String, userId: String, imageBytes: ByteArray) =
dataSource.sendImageMessage(coupleId, conversationId, userId, imageBytes)
override suspend fun loadDecryptedMedia(coupleId: String, mediaUrl: String): ByteArray? =
dataSource.loadDecryptedMedia(coupleId, mediaUrl)
}

View File

@ -14,6 +14,7 @@ import app.closer.data.repository.InviteRepositoryImpl
import app.closer.data.repository.SharedPreferencesLocalAnswerRepository
import app.closer.data.repository.RoomQuestionRepository
import app.closer.data.repository.QuestionThreadRepositoryImpl
import app.closer.data.repository.ConversationRepositoryImpl
import app.closer.data.repository.RevenueCatBillingRepository
import app.closer.data.repository.UserRepositoryImpl
import app.closer.domain.repository.AuthRepository
@ -28,6 +29,7 @@ import app.closer.domain.repository.InviteRepository
import app.closer.domain.repository.LocalAnswerRepository
import app.closer.domain.repository.QuestionRepository
import app.closer.domain.repository.QuestionThreadRepository
import app.closer.domain.repository.ConversationRepository
import app.closer.domain.repository.SettingsRepository
import app.closer.domain.repository.UserRepository
import dagger.Binds
@ -64,6 +66,9 @@ abstract class RepositoryModule {
@Binds @Singleton
abstract fun bindQuestionThreadRepository(impl: QuestionThreadRepositoryImpl): QuestionThreadRepository
@Binds @Singleton
abstract fun bindConversationRepository(impl: ConversationRepositoryImpl): ConversationRepository
@Binds @Singleton
abstract fun bindQuestionRepository(impl: RoomQuestionRepository): QuestionRepository

View File

@ -0,0 +1,19 @@
package app.closer.domain.model
/**
* A conversation in the Messages inbox. Either the pinned free-form couple chat ("main") or a
* per-question discussion ("question"). [title]/[lastMessagePreview] are resolved/decrypted for
* display; the stored preview is E2E-encrypted like every other message.
*/
data class Conversation(
val id: String = "",
val type: String = "main", // "main" | "question"
val questionId: String = "", // set for question conversations
val title: String = "", // filled in by the ViewModel (partner name / question text)
val lastMessagePreview: String = "",
val lastMessageAt: Long = 0L,
val lastMessageSenderId: String = "",
val unread: Boolean = false
) {
val isMain: Boolean get() = type == "main"
}

View File

@ -0,0 +1,16 @@
package app.closer.domain.repository
import app.closer.domain.model.Conversation
import app.closer.domain.model.QuestionMessage
import kotlinx.coroutines.flow.Flow
interface ConversationRepository {
fun observeConversations(coupleId: String, currentUserId: String): Flow<List<Conversation>>
suspend fun ensureMainConversation(coupleId: String)
suspend fun ensureQuestionConversation(coupleId: String, questionId: String): String
suspend fun markRead(coupleId: String, conversationId: String, userId: String)
fun observeMessages(coupleId: String, conversationId: String): Flow<List<QuestionMessage>>
suspend fun sendMessage(coupleId: String, conversationId: String, userId: String, text: String)
suspend fun sendImageMessage(coupleId: String, conversationId: String, userId: String, imageBytes: ByteArray)
suspend fun loadDecryptedMedia(coupleId: String, mediaUrl: String): ByteArray?
}