diff --git a/app/src/main/java/app/closer/data/remote/FirestoreCollections.kt b/app/src/main/java/app/closer/data/remote/FirestoreCollections.kt index 9915a2d2..3e13ff39 100644 --- a/app/src/main/java/app/closer/data/remote/FirestoreCollections.kt +++ b/app/src/main/java/app/closer/data/remote/FirestoreCollections.kt @@ -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" + } } diff --git a/app/src/main/java/app/closer/data/remote/FirestoreConversationDataSource.kt b/app/src/main/java/app/closer/data/remote/FirestoreConversationDataSource.kt new file mode 100644 index 00000000..539d2df7 --- /dev/null +++ b/app/src/main/java/app/closer/data/remote/FirestoreConversationDataSource.kt @@ -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> = 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> = 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).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 com.google.android.gms.tasks.Task.await(): T = + suspendCancellableCoroutine { cont -> + addOnSuccessListener { cont.resume(it) } + addOnFailureListener { cont.resumeWithException(it) } + } + + private suspend fun com.google.android.gms.tasks.Task.voidAwait() = + suspendCancellableCoroutine { cont -> + addOnSuccessListener { cont.resume(Unit) } + addOnFailureListener { cont.resumeWithException(it) } + } + + private suspend fun com.google.android.gms.tasks.Task.refAwait() = + suspendCancellableCoroutine { cont -> + addOnSuccessListener { cont.resume(Unit) } + addOnFailureListener { cont.resumeWithException(it) } + } +} diff --git a/app/src/main/java/app/closer/data/repository/ConversationRepositoryImpl.kt b/app/src/main/java/app/closer/data/repository/ConversationRepositoryImpl.kt new file mode 100644 index 00000000..15fd707d --- /dev/null +++ b/app/src/main/java/app/closer/data/repository/ConversationRepositoryImpl.kt @@ -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> = + 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> = + 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) +} diff --git a/app/src/main/java/app/closer/di/RepositoryModule.kt b/app/src/main/java/app/closer/di/RepositoryModule.kt index f88de782..fc5441c8 100644 --- a/app/src/main/java/app/closer/di/RepositoryModule.kt +++ b/app/src/main/java/app/closer/di/RepositoryModule.kt @@ -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 diff --git a/app/src/main/java/app/closer/domain/model/Conversation.kt b/app/src/main/java/app/closer/domain/model/Conversation.kt new file mode 100644 index 00000000..45ebcfdb --- /dev/null +++ b/app/src/main/java/app/closer/domain/model/Conversation.kt @@ -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" +} diff --git a/app/src/main/java/app/closer/domain/repository/ConversationRepository.kt b/app/src/main/java/app/closer/domain/repository/ConversationRepository.kt new file mode 100644 index 00000000..32939d08 --- /dev/null +++ b/app/src/main/java/app/closer/domain/repository/ConversationRepository.kt @@ -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> + 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> + 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? +}