diff --git a/app/src/main/java/app/closer/data/remote/FirebaseStorageDataSource.kt b/app/src/main/java/app/closer/data/remote/FirebaseStorageDataSource.kt index 4d548d00..b70dce06 100644 --- a/app/src/main/java/app/closer/data/remote/FirebaseStorageDataSource.kt +++ b/app/src/main/java/app/closer/data/remote/FirebaseStorageDataSource.kt @@ -32,4 +32,39 @@ class FirebaseStorageDataSource @Inject constructor( .addOnSuccessListener { cont.resume(it.toString()) } .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() + } + } } diff --git a/app/src/main/java/app/closer/data/remote/FirestoreQuestionThreadDataSource.kt b/app/src/main/java/app/closer/data/remote/FirestoreQuestionThreadDataSource.kt index 88fdfeaf..10ecc203 100644 --- a/app/src/main/java/app/closer/data/remote/FirestoreQuestionThreadDataSource.kt +++ b/app/src/main/java/app/closer/data/remote/FirestoreQuestionThreadDataSource.kt @@ -34,7 +34,8 @@ class FirestoreQuestionThreadDataSource @Inject constructor( private val deviceKeyDataSource: FirestoreDeviceKeyDataSource, private val sealedAnswerEncryptor: SealedAnswerEncryptor, private val pendingAnswerKeyStore: PendingAnswerKeyStore, - private val answerCommitment: AnswerCommitment + private val answerCommitment: AnswerCommitment, + private val storageDataSource: FirebaseStorageDataSource ) { private fun threadsRef(coupleId: String) = @@ -164,12 +165,41 @@ class FirestoreQuestionThreadDataSource @Inject constructor( .add( mapOf( "authorUserId" to message.userId, + "type" to "text", "text" to fieldEncryptor.encrypt(message.text, aead, coupleId), "createdAt" to FieldValue.serverTimestamp() ) ).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> = callbackFlow { val listener = threadsRef(coupleId) .document(threadId) @@ -272,10 +302,13 @@ class FirestoreQuestionThreadDataSource @Inject constructor( coupleId: String ): QuestionMessage? { val userId = getString("authorUserId") ?: return null + val type = getString("type") ?: "text" return QuestionMessage( id = id, 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 ) } diff --git a/app/src/main/java/app/closer/data/repository/QuestionThreadRepositoryImpl.kt b/app/src/main/java/app/closer/data/repository/QuestionThreadRepositoryImpl.kt index 9d872b8d..303aaa77 100644 --- a/app/src/main/java/app/closer/data/repository/QuestionThreadRepositoryImpl.kt +++ b/app/src/main/java/app/closer/data/repository/QuestionThreadRepositoryImpl.kt @@ -58,6 +58,12 @@ class QuestionThreadRepositoryImpl @Inject constructor( override suspend fun sendMessage(coupleId: String, threadId: String, message: QuestionMessage) = 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> = dataSource.observeMessages(coupleId, threadId) 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 12eb935d..b276ea10 100644 --- a/app/src/main/java/app/closer/domain/model/QuestionMessage.kt +++ b/app/src/main/java/app/closer/domain/model/QuestionMessage.kt @@ -4,5 +4,11 @@ data class QuestionMessage( val id: String = "", val userId: 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 isImage: Boolean get() = type == "image" && mediaUrl.isNotBlank() +} diff --git a/app/src/main/java/app/closer/domain/repository/QuestionThreadRepository.kt b/app/src/main/java/app/closer/domain/repository/QuestionThreadRepository.kt index 67fa4ec9..8620db7f 100644 --- a/app/src/main/java/app/closer/domain/repository/QuestionThreadRepository.kt +++ b/app/src/main/java/app/closer/domain/repository/QuestionThreadRepository.kt @@ -12,6 +12,8 @@ interface QuestionThreadRepository { suspend fun submitAnswer(coupleId: String, threadId: String, userId: String, answer: QuestionAnswer) fun observeAnswers(coupleId: String, threadId: String): Flow> 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> suspend fun addReaction(coupleId: String, threadId: String, reaction: QuestionReaction) fun observeReactions(coupleId: String, threadId: String): Flow>