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:
parent
4e2c3fdf0d
commit
db5b8a5f8a
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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?
|
||||
}
|
||||
Loading…
Reference in New Issue