Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Multi Remote Attachment #378

Merged
merged 14 commits into from
Feb 15, 2025
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
package org.xmtp.android.library

import androidx.test.ext.junit.runners.AndroidJUnit4
import com.google.protobuf.kotlin.toByteString
import com.google.protobuf.kotlin.toByteStringUtf8
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
import org.xmtp.android.library.codecs.Attachment
import org.xmtp.android.library.codecs.AttachmentCodec
import org.xmtp.android.library.codecs.ContentTypeMultiRemoteAttachment
import org.xmtp.android.library.codecs.ContentTypeText
import org.xmtp.android.library.codecs.EncodedContent
import org.xmtp.android.library.codecs.EncryptedEncodedContent
import org.xmtp.android.library.codecs.MultiRemoteAttachment
import org.xmtp.android.library.codecs.MultiRemoteAttachmentCodec
import org.xmtp.android.library.codecs.RemoteAttachment
import org.xmtp.android.library.codecs.RemoteAttachmentCodec
import org.xmtp.android.library.codecs.RemoteAttachmentInfo
import org.xmtp.android.library.codecs.id
import org.xmtp.android.library.messages.walletAddress
import uniffi.xmtpv3.FfiMultiRemoteAttachment
import java.net.URL
import kotlin.random.Random

@RunWith(AndroidJUnit4::class)
class MultiRemoteAttachmentTest {

private val encryptedPayloadUrls = HashMap<String, ByteArray>()

private fun testUploadEncryptedPayload(encryptedPayload: ByteArray): String {
val randomUrl: String = "https://" + Random(encryptedPayload.hashCode()).nextInt(0, 1000000)
encryptedPayloadUrls.put(randomUrl, encryptedPayload)
return randomUrl
}

@Test
fun testCanUseMultiRemoteAttachmentCodec() {
Client.register(codec = AttachmentCodec())
Client.register(codec = RemoteAttachmentCodec())
Client.register(codec = MultiRemoteAttachmentCodec())

val attachment1 = Attachment(
filename = "test1.txt",
mimeType = "text/plain",
data = "hello world".toByteStringUtf8(),
)

val attachment2 = Attachment(
filename = "test2.txt",
mimeType = "text/plain",
data = "hello world".toByteStringUtf8(),
)

val attachmentCodec = AttachmentCodec()
val remoteAttachmentInfos: MutableList<RemoteAttachmentInfo> = ArrayList()

for (attachment: Attachment in listOf(attachment1, attachment2)) {
val encodedBytes = attachmentCodec.encode(attachment).toByteArray()
val encryptedAttachment = MultiRemoteAttachmentCodec.encryptBytesForLocalAttachment(encodedBytes, attachment.filename)
val url = testUploadEncryptedPayload(encryptedAttachment.payload.toByteArray())
val remoteAttachmentInfo = MultiRemoteAttachmentCodec.buildRemoteAttachmentInfo(encryptedAttachment, URL(url))
remoteAttachmentInfos.add(remoteAttachmentInfo)
}

val multiRemoteAttachment = MultiRemoteAttachment(remoteAttachments = remoteAttachmentInfos.toList())

val fixtures = fixtures()
val aliceClient = fixtures.alixClient
val aliceConversation = runBlocking {
aliceClient.conversations.newConversation(fixtures.bo.walletAddress)
}
runBlocking {
aliceConversation.send(
content = multiRemoteAttachment,
options = SendOptions(contentType = ContentTypeMultiRemoteAttachment),
)
}

val messages = runBlocking { aliceConversation.messages() }
assertEquals(messages.size, 1)

// Below steps outlines how to handle receiving a MultiRemoteAttachment message
if (messages.size == 1 && messages[0].encodedContent.type.id.equals(ContentTypeMultiRemoteAttachment)) {
val loadedMultiRemoteAttachment: FfiMultiRemoteAttachment = messages[0].content()!!

val textAttachments: MutableList<Attachment> = ArrayList()

for (
remoteAttachment: RemoteAttachment in loadedMultiRemoteAttachment.attachments.map { attachment ->
RemoteAttachment(
url = URL(attachment.url),
filename = attachment.filename,
contentDigest = attachment.contentDigest,
nonce = attachment.nonce.toByteString(),
scheme = attachment.scheme,
salt = attachment.salt.toByteString(),
secret = attachment.secret.toByteString(),
contentLength = attachment.contentLength?.toInt(),
)
}
) {
val url = remoteAttachment.url.toString()
// Simulate Download
val encryptedPayload: ByteArray = encryptedPayloadUrls[url]!!
// Combine encrypted payload with RemoteAttachmentInfo
val encryptedAttachment: EncryptedEncodedContent = MultiRemoteAttachmentCodec.buildEncryptAttachmentResult(remoteAttachment, encryptedPayload)
// Decrypt payload
val encodedContent: EncodedContent = MultiRemoteAttachmentCodec.decryptAttachment(encryptedAttachment)
assertEquals(encodedContent.type.id, ContentTypeText.id)
// Convert EncodedContent to Attachment
val attachment = attachmentCodec.decode(encodedContent)
textAttachments.add(attachment)
}

assertEquals(textAttachments[0].filename, "test1.txt")
assertEquals(textAttachments[1].filename, "test2.txt")
} else {
AssertionError("expected a MultiRemoteAttachment message")
}
}
}
23 changes: 15 additions & 8 deletions library/src/main/java/org/xmtp/android/library/Dm.kt
Original file line number Diff line number Diff line change
Expand Up @@ -205,22 +205,29 @@ class Dm(
if (decodedMessage != null) {
trySend(decodedMessage)
} else {
Log.w("XMTP Dm stream", "Failed to decode message: id=${message.id.toHex()}, " +
"convoId=${message.convoId.toHex()}, " +
"senderInboxId=${message.senderInboxId}")
Log.w(
"XMTP Dm stream",
"Failed to decode message: id=${message.id.toHex()}, " +
"convoId=${message.convoId.toHex()}, " +
"senderInboxId=${message.senderInboxId}"
)
}
} catch (e: Exception) {
Log.e("XMTP Dm stream", "Error decoding message: id=${message.id.toHex()}, " +
"convoId=${message.convoId.toHex()}, " +
"senderInboxId=${message.senderInboxId}", e)
Log.e(
"XMTP Dm stream",
"Error decoding message: id=${message.id.toHex()}, " +
"convoId=${message.convoId.toHex()}, " +
"senderInboxId=${message.senderInboxId}",
e
)
}
}

override fun onError(error: FfiSubscribeException) {
Log.e("XMTP Dm stream", "Stream error: ${error.message}", error)
}
}

val stream = libXMTPGroup.stream(messageCallback)
awaitClose { stream.end() }
}
Expand Down
23 changes: 15 additions & 8 deletions library/src/main/java/org/xmtp/android/library/Group.kt
Original file line number Diff line number Diff line change
Expand Up @@ -432,22 +432,29 @@ class Group(
if (decodedMessage != null) {
trySend(decodedMessage)
} else {
Log.w("XMTP Group stream", "Failed to decode message: id=${message.id.toHex()}, " +
"convoId=${message.convoId.toHex()}, " +
"senderInboxId=${message.senderInboxId}")
Log.w(
"XMTP Group stream",
"Failed to decode message: id=${message.id.toHex()}, " +
"convoId=${message.convoId.toHex()}, " +
"senderInboxId=${message.senderInboxId}"
)
}
} catch (e: Exception) {
Log.e("XMTP Group stream", "Error decoding message: id=${message.id.toHex()}, " +
"convoId=${message.convoId.toHex()}, " +
"senderInboxId=${message.senderInboxId}", e)
Log.e(
"XMTP Group stream",
"Error decoding message: id=${message.id.toHex()}, " +
"convoId=${message.convoId.toHex()}, " +
"senderInboxId=${message.senderInboxId}",
e
)
}
}

override fun onError(error: FfiSubscribeException) {
Log.e("XMTP Group stream", "Stream error: ${error.message}", error)
}
}

val stream = libXMTPGroup.stream(messageCallback)
awaitClose { stream.end() }
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
package org.xmtp.android.library.codecs

import com.google.protobuf.ByteString
import org.xmtp.android.library.XMTPException
import org.xmtp.android.library.codecs.RemoteAttachment.Companion.decryptEncoded
import org.xmtp.android.library.codecs.RemoteAttachment.Companion.encodeEncryptedBytes
import uniffi.xmtpv3.FfiMultiRemoteAttachment
import uniffi.xmtpv3.FfiRemoteAttachmentInfo
import uniffi.xmtpv3.decodeMultiRemoteAttachment
import uniffi.xmtpv3.encodeMultiRemoteAttachment
import java.net.URI
import java.net.URL

val ContentTypeMultiRemoteAttachment = ContentTypeIdBuilder.builderFromAuthorityId(
"xmtp.org",
"multiRemoteStaticAttachment",
versionMajor = 1,
versionMinor = 0,
)

data class MultiRemoteAttachment(
val remoteAttachments: List<RemoteAttachmentInfo>
)

data class RemoteAttachmentInfo(
val url: String,
val filename: String,
val contentLength: Long,
val contentDigest: String,
val nonce: ByteString,
val scheme: String,
val salt: ByteString,
val secret: ByteString
) {
companion object {
fun from(url: URL, encryptedEncodedContent: EncryptedEncodedContent): RemoteAttachmentInfo {
if (URI(url.toString()).scheme != "https") {
throw XMTPException("scheme must be https://")
}

return RemoteAttachmentInfo(
url = url.toString(),
contentDigest = encryptedEncodedContent.contentDigest,
secret = encryptedEncodedContent.secret,
salt = encryptedEncodedContent.salt,
nonce = encryptedEncodedContent.nonce,
scheme = URI(url.toString()).scheme,
contentLength = encryptedEncodedContent.contentLength?.toLong() ?: 0,
filename = encryptedEncodedContent.filename ?: ""
)
}
}
}

data class MultiRemoteAttachmentCodec(override var contentType: ContentTypeId = ContentTypeMultiRemoteAttachment) :
ContentCodec<MultiRemoteAttachment> {

override fun encode(content: MultiRemoteAttachment): EncodedContent {
val ffiMultiRemoteAttachment = FfiMultiRemoteAttachment(
attachments = content.remoteAttachments.map { attachment ->
FfiRemoteAttachmentInfo(
url = attachment.url,
filename = attachment.filename,
contentDigest = attachment.contentDigest,
nonce = attachment.nonce.toByteArray(),
scheme = attachment.scheme,
salt = attachment.salt.toByteArray(),
secret = attachment.secret.toByteArray(),
contentLength = attachment.contentLength.toUInt(),
)
}
)
return EncodedContent.parseFrom(encodeMultiRemoteAttachment(ffiMultiRemoteAttachment))
}

override fun decode(content: EncodedContent): MultiRemoteAttachment {
val ffiMultiRemoteAttachment = decodeMultiRemoteAttachment(content.toByteArray())
return MultiRemoteAttachment(
remoteAttachments = ffiMultiRemoteAttachment.attachments.map { attachment ->
RemoteAttachmentInfo(
url = attachment.url,
filename = attachment.filename ?: "",
contentLength = attachment.contentLength?.toLong() ?: 0,
contentDigest = attachment.contentDigest,
nonce = attachment.nonce.toProtoByteString(),
scheme = attachment.scheme,
salt = attachment.salt.toProtoByteString(),
secret = attachment.secret.toProtoByteString(),
)
}
)
}

override fun fallback(content: MultiRemoteAttachment): String = "MultiRemoteAttachment not supported"

override fun shouldPush(content: MultiRemoteAttachment): Boolean = true

companion object {

fun encryptBytesForLocalAttachment(bytesToEncrypt: ByteArray, filename: String): EncryptedEncodedContent {
return encodeEncryptedBytes(bytesToEncrypt, filename)
}

fun buildRemoteAttachmentInfo(encryptedAttachment: EncryptedEncodedContent, remoteUrl: URL): RemoteAttachmentInfo {
return RemoteAttachmentInfo.from(remoteUrl, encryptedAttachment)
}

fun buildEncryptAttachmentResult(remoteAttachment: RemoteAttachment, encryptedPayload: ByteArray): EncryptedEncodedContent {
return EncryptedEncodedContent(
remoteAttachment.contentDigest,
remoteAttachment.secret,
remoteAttachment.salt,
remoteAttachment.nonce,
encryptedPayload.toProtoByteString(),
remoteAttachment.contentLength,
remoteAttachment.filename,
)
}

fun decryptAttachment(encryptedAttachment: EncryptedEncodedContent): EncodedContent {
val decrypted = decryptEncoded(encryptedAttachment)

return decrypted
}
}
}

private fun ByteArray.toProtoByteString(): ByteString {
return ByteString.copyFrom(this)
}
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,23 @@ data class RemoteAttachment(
)
}

fun encodeEncryptedBytes(encodedContent: ByteArray, filename: String): EncryptedEncodedContent {
val secret = SecureRandom().generateSeed(32)
val ciphertext = Crypto.encrypt(secret, encodedContent)
?: throw XMTPException("ciphertext not created")
val contentDigest =
Hash.sha256(ciphertext.aes256GcmHkdfSha256.payload.toByteArray()).toHex()
return EncryptedEncodedContent(
contentDigest = contentDigest,
secret = secret.toByteString(),
salt = ciphertext.aes256GcmHkdfSha256.hkdfSalt,
nonce = ciphertext.aes256GcmHkdfSha256.gcmNonce,
payload = ciphertext.aes256GcmHkdfSha256.payload,
contentLength = null,
filename = filename,
)
}

fun from(url: URL, encryptedEncodedContent: EncryptedEncodedContent): RemoteAttachment {
if (URI(url.toString()).scheme != "https") {
throw XMTPException("scheme must be https://")
Expand Down
Loading