feat(chat): encrypted image messages — Storage upload/download, Firestore send/load, domain model with type+mediaUrl
This commit is contained in:
parent
609ced4095
commit
c9aa5f1e12
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue