feat(chat): encrypted image messages — Storage upload/download, Firestore send/load, domain model with type+mediaUrl

This commit is contained in:
null 2026-06-24 15:20:11 -05:00
parent 609ced4095
commit c9aa5f1e12
5 changed files with 85 additions and 3 deletions

View File

@ -32,4 +32,39 @@ class FirebaseStorageDataSource @Inject constructor(
.addOnSuccessListener { cont.resume(it.toString()) } .addOnSuccessListener { cont.resume(it.toString()) }
.addOnFailureListener { cont.resumeWithException(it) } .addOnFailureListener { cont.resumeWithException(it) }
} }
/**
* Uploads already-encrypted chat-media bytes under the author's own storage path (mirrors the
* profile-photo ownership model) and returns the tokenized download URL. The bytes are
* ciphertext, so Storage never holds anything readable.
*/
suspend fun uploadEncryptedMedia(uid: String, encryptedBytes: ByteArray): String =
suspendCancellableCoroutine { cont ->
val ref = storage.reference.child("users/$uid/chat_media/${java.util.UUID.randomUUID()}")
val metadata = StorageMetadata.Builder()
.setContentType("application/octet-stream")
.build()
ref.putBytes(encryptedBytes, metadata)
.continueWithTask { ref.downloadUrl }
.addOnSuccessListener { cont.resume(it.toString()) }
.addOnFailureListener { cont.resumeWithException(it) }
}
/**
* Downloads the raw (still-encrypted) bytes for a media message over HTTP using the tokenized
* download URL, so the partner can read the author's object (the URL token authorizes it,
* bypassing the owner-scoped Storage rule same model as profile photos).
*/
suspend fun downloadBytes(downloadUrl: String): ByteArray =
kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) {
val connection = (java.net.URL(downloadUrl).openConnection() as java.net.HttpURLConnection).apply {
connectTimeout = 20_000
readTimeout = 20_000
}
try {
connection.inputStream.use { it.readBytes() }
} finally {
connection.disconnect()
}
}
} }

View File

@ -34,7 +34,8 @@ class FirestoreQuestionThreadDataSource @Inject constructor(
private val deviceKeyDataSource: FirestoreDeviceKeyDataSource, private val deviceKeyDataSource: FirestoreDeviceKeyDataSource,
private val sealedAnswerEncryptor: SealedAnswerEncryptor, private val sealedAnswerEncryptor: SealedAnswerEncryptor,
private val pendingAnswerKeyStore: PendingAnswerKeyStore, private val pendingAnswerKeyStore: PendingAnswerKeyStore,
private val answerCommitment: AnswerCommitment private val answerCommitment: AnswerCommitment,
private val storageDataSource: FirebaseStorageDataSource
) { ) {
private fun threadsRef(coupleId: String) = private fun threadsRef(coupleId: String) =
@ -164,12 +165,41 @@ class FirestoreQuestionThreadDataSource @Inject constructor(
.add( .add(
mapOf( mapOf(
"authorUserId" to message.userId, "authorUserId" to message.userId,
"type" to "text",
"text" to fieldEncryptor.encrypt(message.text, aead, coupleId), "text" to fieldEncryptor.encrypt(message.text, aead, coupleId),
"createdAt" to FieldValue.serverTimestamp() "createdAt" to FieldValue.serverTimestamp()
) )
).refAwait() ).refAwait()
} }
/**
* Sends an image message: the bytes are encrypted with the couple key on-device, the ciphertext
* is uploaded to Storage, and only the (encrypted) media's URL is stored in Firestore.
*/
suspend fun sendImageMessage(coupleId: String, threadId: 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)
threadsRef(coupleId)
.document(threadId)
.collection(FirestoreCollections.QuestionThreads.MESSAGES)
.add(
mapOf(
"authorUserId" to userId,
"type" to "image",
"mediaUrl" to url,
"createdAt" to FieldValue.serverTimestamp()
)
).refAwait()
}
/** Downloads + decrypts an image message's bytes for display (couple key, on-device). */
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()
}
fun observeMessages(coupleId: String, threadId: String): Flow<List<QuestionMessage>> = callbackFlow { fun observeMessages(coupleId: String, threadId: String): Flow<List<QuestionMessage>> = callbackFlow {
val listener = threadsRef(coupleId) val listener = threadsRef(coupleId)
.document(threadId) .document(threadId)
@ -272,10 +302,13 @@ class FirestoreQuestionThreadDataSource @Inject constructor(
coupleId: String coupleId: String
): QuestionMessage? { ): QuestionMessage? {
val userId = getString("authorUserId") ?: return null val userId = getString("authorUserId") ?: return null
val type = getString("type") ?: "text"
return QuestionMessage( return QuestionMessage(
id = id, id = id,
userId = userId, userId = userId,
text = fieldEncryptor.decryptForDisplay(getString("text"), aead, coupleId) ?: "", type = type,
mediaUrl = getString("mediaUrl") ?: "",
text = if (type == "image") "" else (fieldEncryptor.decryptForDisplay(getString("text"), aead, coupleId) ?: ""),
createdAt = getTimestamp("createdAt")?.toDate()?.time ?: 0L createdAt = getTimestamp("createdAt")?.toDate()?.time ?: 0L
) )
} }

View File

@ -58,6 +58,12 @@ class QuestionThreadRepositoryImpl @Inject constructor(
override suspend fun sendMessage(coupleId: String, threadId: String, message: QuestionMessage) = override suspend fun sendMessage(coupleId: String, threadId: String, message: QuestionMessage) =
dataSource.sendMessage(coupleId, threadId, message) dataSource.sendMessage(coupleId, threadId, message)
override suspend fun sendImageMessage(coupleId: String, threadId: String, userId: String, imageBytes: ByteArray) =
dataSource.sendImageMessage(coupleId, threadId, userId, imageBytes)
override suspend fun loadDecryptedMedia(coupleId: String, mediaUrl: String): ByteArray? =
dataSource.loadDecryptedMedia(coupleId, mediaUrl)
override fun observeMessages(coupleId: String, threadId: String): Flow<List<QuestionMessage>> = override fun observeMessages(coupleId: String, threadId: String): Flow<List<QuestionMessage>> =
dataSource.observeMessages(coupleId, threadId) dataSource.observeMessages(coupleId, threadId)

View File

@ -4,5 +4,11 @@ data class QuestionMessage(
val id: String = "", val id: String = "",
val userId: String = "", val userId: String = "",
val text: String = "", val text: String = "",
/** "text" or "image". */
val type: String = "text",
/** Download URL of the ENCRYPTED image bytes in Storage (empty for text messages). */
val mediaUrl: String = "",
val createdAt: Long = 0L val createdAt: Long = 0L
) ) {
val isImage: Boolean get() = type == "image" && mediaUrl.isNotBlank()
}

View File

@ -12,6 +12,8 @@ interface QuestionThreadRepository {
suspend fun submitAnswer(coupleId: String, threadId: String, userId: String, answer: QuestionAnswer) suspend fun submitAnswer(coupleId: String, threadId: String, userId: String, answer: QuestionAnswer)
fun observeAnswers(coupleId: String, threadId: String): Flow<List<QuestionAnswer>> fun observeAnswers(coupleId: String, threadId: String): Flow<List<QuestionAnswer>>
suspend fun sendMessage(coupleId: String, threadId: String, message: QuestionMessage) suspend fun sendMessage(coupleId: String, threadId: String, message: QuestionMessage)
suspend fun sendImageMessage(coupleId: String, threadId: String, userId: String, imageBytes: ByteArray)
suspend fun loadDecryptedMedia(coupleId: String, mediaUrl: String): ByteArray?
fun observeMessages(coupleId: String, threadId: String): Flow<List<QuestionMessage>> fun observeMessages(coupleId: String, threadId: String): Flow<List<QuestionMessage>>
suspend fun addReaction(coupleId: String, threadId: String, reaction: QuestionReaction) suspend fun addReaction(coupleId: String, threadId: String, reaction: QuestionReaction)
fun observeReactions(coupleId: String, threadId: String): Flow<List<QuestionReaction>> fun observeReactions(coupleId: String, threadId: String): Flow<List<QuestionReaction>>