From c20745e82a96a4a4fa4edd7afa52ebbbe4c7d000 Mon Sep 17 00:00:00 2001 From: null Date: Wed, 24 Jun 2026 16:34:38 -0500 Subject: [PATCH] =?UTF-8?q?feat(voice):=20data=20layer=20=E2=80=94=20voice?= =?UTF-8?q?=20message=20model,=20sendVoiceMessage,=20durationMs=20field?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - QuestionMessage: add durationMs, isVoice, type supports 'voice' - ConversationRepository: sendVoiceMessage(audioBytes, durationMs) - FirestoreConversationDataSource: sendVoiceMessage encrypts + uploads audio, decrypts durationMs - ConversationRepositoryImpl: delegates sendVoiceMessage --- .../remote/FirestoreConversationDataSource.kt | 19 ++++++++++++++++++- .../repository/ConversationRepositoryImpl.kt | 3 +++ .../closer/domain/model/QuestionMessage.kt | 7 +++++-- .../repository/ConversationRepository.kt | 1 + 4 files changed, 27 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/app/closer/data/remote/FirestoreConversationDataSource.kt b/app/src/main/java/app/closer/data/remote/FirestoreConversationDataSource.kt index 539d2df7..c7070d2f 100644 --- a/app/src/main/java/app/closer/data/remote/FirestoreConversationDataSource.kt +++ b/app/src/main/java/app/closer/data/remote/FirestoreConversationDataSource.kt @@ -122,6 +122,22 @@ class FirestoreConversationDataSource @Inject constructor( updateLastMessage(coupleId, conversationId, userId, fieldEncryptor.encrypt("📷 Photo", aead, coupleId)) } + suspend fun sendVoiceMessage(coupleId: String, conversationId: String, userId: String, audioBytes: ByteArray, durationMs: Long) { + val aead = encryptionManager.requireAead(coupleId) + val encrypted = aead.encrypt(audioBytes, coupleId.toByteArray(Charsets.UTF_8)) + val url = storageDataSource.uploadEncryptedMedia(userId, encrypted) + messagesRef(coupleId, conversationId).add( + mapOf( + "authorUserId" to userId, + "type" to "voice", + "mediaUrl" to url, + "durationMs" to durationMs, + "createdAt" to FieldValue.serverTimestamp() + ) + ).refAwait() + updateLastMessage(coupleId, conversationId, userId, fieldEncryptor.encrypt("🎤 Voice message", aead, coupleId)) + } + private suspend fun updateLastMessage(coupleId: String, conversationId: String, userId: String, encryptedPreview: String) { conversationsRef(coupleId).document(conversationId).set( mapOf( @@ -185,7 +201,8 @@ class FirestoreConversationDataSource @Inject constructor( userId = userId, type = type, mediaUrl = getString("mediaUrl") ?: "", - text = if (type == "image") "" else (fieldEncryptor.decryptForDisplay(getString("text"), aead, coupleId) ?: ""), + durationMs = getLong("durationMs") ?: 0L, + text = if (type == "text") (fieldEncryptor.decryptForDisplay(getString("text"), aead, coupleId) ?: "") else "", createdAt = getTimestamp("createdAt")?.toDate()?.time ?: 0L ) } diff --git a/app/src/main/java/app/closer/data/repository/ConversationRepositoryImpl.kt b/app/src/main/java/app/closer/data/repository/ConversationRepositoryImpl.kt index 15fd707d..02794ac5 100644 --- a/app/src/main/java/app/closer/data/repository/ConversationRepositoryImpl.kt +++ b/app/src/main/java/app/closer/data/repository/ConversationRepositoryImpl.kt @@ -34,6 +34,9 @@ class ConversationRepositoryImpl @Inject constructor( override suspend fun sendImageMessage(coupleId: String, conversationId: String, userId: String, imageBytes: ByteArray) = dataSource.sendImageMessage(coupleId, conversationId, userId, imageBytes) + override suspend fun sendVoiceMessage(coupleId: String, conversationId: String, userId: String, audioBytes: ByteArray, durationMs: Long) = + dataSource.sendVoiceMessage(coupleId, conversationId, userId, audioBytes, durationMs) + override suspend fun loadDecryptedMedia(coupleId: String, mediaUrl: String): ByteArray? = dataSource.loadDecryptedMedia(coupleId, mediaUrl) } diff --git a/app/src/main/java/app/closer/domain/model/QuestionMessage.kt b/app/src/main/java/app/closer/domain/model/QuestionMessage.kt index b276ea10..e0967628 100644 --- a/app/src/main/java/app/closer/domain/model/QuestionMessage.kt +++ b/app/src/main/java/app/closer/domain/model/QuestionMessage.kt @@ -4,11 +4,14 @@ data class QuestionMessage( val id: String = "", val userId: String = "", val text: String = "", - /** "text" or "image". */ + /** "text", "image" (also GIFs/stickers/Bitmoji), or "voice". */ val type: String = "text", - /** Download URL of the ENCRYPTED image bytes in Storage (empty for text messages). */ + /** Download URL of the ENCRYPTED media bytes in Storage (empty for text messages). */ val mediaUrl: String = "", + /** Voice-note length in milliseconds (0 for non-voice). */ + val durationMs: Long = 0L, val createdAt: Long = 0L ) { val isImage: Boolean get() = type == "image" && mediaUrl.isNotBlank() + val isVoice: Boolean get() = type == "voice" && mediaUrl.isNotBlank() } diff --git a/app/src/main/java/app/closer/domain/repository/ConversationRepository.kt b/app/src/main/java/app/closer/domain/repository/ConversationRepository.kt index 32939d08..3c1ea87f 100644 --- a/app/src/main/java/app/closer/domain/repository/ConversationRepository.kt +++ b/app/src/main/java/app/closer/domain/repository/ConversationRepository.kt @@ -12,5 +12,6 @@ interface ConversationRepository { 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 sendVoiceMessage(coupleId: String, conversationId: String, userId: String, audioBytes: ByteArray, durationMs: Long) suspend fun loadDecryptedMedia(coupleId: String, mediaUrl: String): ByteArray? }