feat(voice): data layer — voice message model, sendVoiceMessage, durationMs field
- QuestionMessage: add durationMs, isVoice, type supports 'voice' - ConversationRepository: sendVoiceMessage(audioBytes, durationMs) - FirestoreConversationDataSource: sendVoiceMessage encrypts + uploads audio, decrypts durationMs - ConversationRepositoryImpl: delegates sendVoiceMessage
This commit is contained in:
parent
29beff1702
commit
c20745e82a
|
|
@ -122,6 +122,22 @@ class FirestoreConversationDataSource @Inject constructor(
|
||||||
updateLastMessage(coupleId, conversationId, userId, fieldEncryptor.encrypt("📷 Photo", aead, coupleId))
|
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) {
|
private suspend fun updateLastMessage(coupleId: String, conversationId: String, userId: String, encryptedPreview: String) {
|
||||||
conversationsRef(coupleId).document(conversationId).set(
|
conversationsRef(coupleId).document(conversationId).set(
|
||||||
mapOf(
|
mapOf(
|
||||||
|
|
@ -185,7 +201,8 @@ class FirestoreConversationDataSource @Inject constructor(
|
||||||
userId = userId,
|
userId = userId,
|
||||||
type = type,
|
type = type,
|
||||||
mediaUrl = getString("mediaUrl") ?: "",
|
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
|
createdAt = getTimestamp("createdAt")?.toDate()?.time ?: 0L
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,9 @@ class ConversationRepositoryImpl @Inject constructor(
|
||||||
override suspend fun sendImageMessage(coupleId: String, conversationId: String, userId: String, imageBytes: ByteArray) =
|
override suspend fun sendImageMessage(coupleId: String, conversationId: String, userId: String, imageBytes: ByteArray) =
|
||||||
dataSource.sendImageMessage(coupleId, conversationId, userId, imageBytes)
|
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? =
|
override suspend fun loadDecryptedMedia(coupleId: String, mediaUrl: String): ByteArray? =
|
||||||
dataSource.loadDecryptedMedia(coupleId, mediaUrl)
|
dataSource.loadDecryptedMedia(coupleId, mediaUrl)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,14 @@ data class QuestionMessage(
|
||||||
val id: String = "",
|
val id: String = "",
|
||||||
val userId: String = "",
|
val userId: String = "",
|
||||||
val text: String = "",
|
val text: String = "",
|
||||||
/** "text" or "image". */
|
/** "text", "image" (also GIFs/stickers/Bitmoji), or "voice". */
|
||||||
val type: String = "text",
|
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 = "",
|
val mediaUrl: String = "",
|
||||||
|
/** Voice-note length in milliseconds (0 for non-voice). */
|
||||||
|
val durationMs: Long = 0L,
|
||||||
val createdAt: Long = 0L
|
val createdAt: Long = 0L
|
||||||
) {
|
) {
|
||||||
val isImage: Boolean get() = type == "image" && mediaUrl.isNotBlank()
|
val isImage: Boolean get() = type == "image" && mediaUrl.isNotBlank()
|
||||||
|
val isVoice: Boolean get() = type == "voice" && mediaUrl.isNotBlank()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,5 +12,6 @@ interface ConversationRepository {
|
||||||
fun observeMessages(coupleId: String, conversationId: String): Flow<List<QuestionMessage>>
|
fun observeMessages(coupleId: String, conversationId: String): Flow<List<QuestionMessage>>
|
||||||
suspend fun sendMessage(coupleId: String, conversationId: String, userId: String, text: String)
|
suspend fun sendMessage(coupleId: String, conversationId: String, userId: String, text: String)
|
||||||
suspend fun sendImageMessage(coupleId: String, conversationId: String, userId: String, imageBytes: ByteArray)
|
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?
|
suspend fun loadDecryptedMedia(coupleId: String, mediaUrl: String): ByteArray?
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue