Skip to content

Commit

Permalink
Merge pull request #85 from xmtp/daniel-remote-attachment
Browse files Browse the repository at this point in the history
feat: support remote attachments
  • Loading branch information
nplasterer authored Aug 23, 2023
2 parents 630af81 + d820596 commit 8f74391
Show file tree
Hide file tree
Showing 26 changed files with 1,457 additions and 111 deletions.
2 changes: 1 addition & 1 deletion android/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ repositories {
dependencies {
implementation project(':expo-modules-core')
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:${getKotlinVersion()}"
implementation "org.xmtp:android:0.3.5"
implementation "org.xmtp:android:0.4.0"
implementation 'com.google.code.gson:gson:2.10.1'
implementation 'com.facebook.react:react-native:0.71.3'
implementation "com.daveanthonythomas.moshipack:moshipack:1.0.1"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,19 @@
package expo.modules.xmtpreactnativesdk

import android.net.Uri
import android.util.Base64
import android.util.Base64.NO_WRAP
import android.util.Log
import androidx.core.net.toUri
import com.google.gson.JsonParser
import com.google.protobuf.kotlin.toByteString
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
import expo.modules.xmtpreactnativesdk.wrappers.ContentJson
import expo.modules.xmtpreactnativesdk.wrappers.ConversationWrapper
import expo.modules.xmtpreactnativesdk.wrappers.DecodedMessageWrapper
import expo.modules.xmtpreactnativesdk.wrappers.DecryptedLocalAttachment
import expo.modules.xmtpreactnativesdk.wrappers.EncryptedLocalAttachment
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
Expand All @@ -23,6 +27,12 @@ import org.xmtp.android.library.SendOptions
import org.xmtp.android.library.SigningKey
import org.xmtp.android.library.XMTPEnvironment
import org.xmtp.android.library.XMTPException
import org.xmtp.android.library.codecs.Attachment
import org.xmtp.android.library.codecs.AttachmentCodec
import org.xmtp.android.library.codecs.EncodedContent
import org.xmtp.android.library.codecs.EncryptedEncodedContent
import org.xmtp.android.library.codecs.RemoteAttachment
import org.xmtp.android.library.codecs.decoded
import org.xmtp.android.library.messages.EnvelopeBuilder
import org.xmtp.android.library.messages.InvitationV1ContextBuilder
import org.xmtp.android.library.messages.Pagination
Expand All @@ -31,6 +41,7 @@ import org.xmtp.android.library.messages.Signature
import org.xmtp.android.library.push.XMTPPush
import org.xmtp.proto.keystore.api.v1.Keystore.TopicMap.TopicData
import org.xmtp.proto.message.contents.PrivateKeyOuterClass
import java.io.File
import java.util.Date
import java.util.UUID
import kotlin.coroutines.Continuation
Expand Down Expand Up @@ -199,6 +210,59 @@ class XMTPModule : Module() {
client.canMessage(peerAddress)
}

AsyncFunction("encryptAttachment") { clientAddress: String, fileJson: String ->
logV("encryptAttachment")
val client = clients[clientAddress] ?: throw XMTPException("No client")
val file = DecryptedLocalAttachment.fromJson(fileJson)
val uri = Uri.parse(file.fileUri)
val data = appContext.reactContext?.contentResolver
?.openInputStream(uri)
?.use { it.buffered().readBytes() }!!
val attachment = Attachment(
filename = uri.lastPathSegment ?: "",
mimeType = file.mimeType,
data.toByteString(),
)
val encrypted = RemoteAttachment.encodeEncrypted(
attachment,
AttachmentCodec()
)
val encryptedFile = File.createTempFile(UUID.randomUUID().toString(), null)
encryptedFile.writeBytes(encrypted.payload.toByteArray())

EncryptedLocalAttachment.from(
attachment,
encrypted,
encryptedFile.toUri()
).toJson()
}

AsyncFunction("decryptAttachment") { clientAddress: String, encryptedFileJson: String ->
logV("decryptAttachment")
val client = clients[clientAddress] ?: throw XMTPException("No client")
val encryptedFile = EncryptedLocalAttachment.fromJson(encryptedFileJson)
val encryptedData = appContext.reactContext?.contentResolver
?.openInputStream(Uri.parse(encryptedFile.encryptedLocalFileUri))
?.use { it.buffered().readBytes() }!!
val encrypted = EncryptedEncodedContent(
encryptedFile.metadata.contentDigest,
encryptedFile.metadata.secret,
encryptedFile.metadata.salt,
encryptedFile.metadata.nonce,
encryptedData.toByteString(),
encryptedData.size,
encryptedFile.metadata.filename,
)
val encoded: EncodedContent = RemoteAttachment.decryptEncoded(encrypted)
val attachment = encoded.decoded<Attachment>()!!
val file = File.createTempFile(UUID.randomUUID().toString(), null)
file.writeBytes(attachment.data.toByteArray())
DecryptedLocalAttachment(
fileUri = file.toURI().toString(),
mimeType = attachment.mimeType,
).toJson()
}

AsyncFunction("listConversations") { clientAddress: String ->
logV("listConversations")
val client = clients[clientAddress] ?: throw XMTPException("No client")
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,10 @@
package expo.modules.xmtpreactnativesdk.wrappers

import android.util.Base64
import com.google.gson.GsonBuilder
import com.google.gson.JsonObject
import com.google.gson.JsonParser
import com.google.protobuf.ByteString
import org.xmtp.android.library.Client
import org.xmtp.android.library.DecodedMessage
import org.xmtp.proto.message.contents.Content.EncodedContent
import org.xmtp.android.library.codecs.decoded
import org.xmtp.android.library.codecs.ContentTypeAttachment
Expand All @@ -15,17 +13,21 @@ import org.xmtp.android.library.codecs.ContentTypeReaction
import org.xmtp.android.library.codecs.ContentTypeText
import org.xmtp.android.library.codecs.AttachmentCodec
import org.xmtp.android.library.codecs.Attachment
import org.xmtp.android.library.codecs.ContentTypeRemoteAttachment
import org.xmtp.android.library.codecs.ContentTypeReply
import org.xmtp.android.library.codecs.ReactionAction
import org.xmtp.android.library.codecs.ReactionSchema
import org.xmtp.android.library.codecs.ReactionCodec
import org.xmtp.android.library.codecs.Reaction
import org.xmtp.android.library.codecs.RemoteAttachment
import org.xmtp.android.library.codecs.RemoteAttachmentCodec
import org.xmtp.android.library.codecs.Reply
import org.xmtp.android.library.codecs.ReplyCodec
import org.xmtp.android.library.codecs.TextCodec
import org.xmtp.android.library.codecs.id

import java.lang.Exception
import java.net.URL

class ContentJson(
val type: ContentTypeId,
Expand All @@ -41,12 +43,10 @@ class ContentJson(
Client.register(TextCodec())
Client.register(AttachmentCodec())
Client.register(ReactionCodec())
Client.register(RemoteAttachmentCodec())
Client.register(ReplyCodec())
// TODO:
//Client.register(CompositeCodec())
//Client.register(GroupChatMemberAddedCodec())
//Client.register(GroupChatTitleChangedCodec())
//Client.register(RemoteAttachmentCodec())
}

fun fromJsonObject(obj: JsonObject): ContentJson {
Expand All @@ -59,6 +59,22 @@ class ContentJson(
mimeType = attachment.get("mimeType").asString,
data = ByteString.copyFrom(bytesFrom64(attachment.get("data").asString)),
))
} else if (obj.has("remoteAttachment")) {
val remoteAttachment = obj.get("remoteAttachment").asJsonObject
val metadata = EncryptedAttachmentMetadata.fromJsonObj(remoteAttachment)
val url = URL(remoteAttachment.get("url").asString)
return ContentJson(
ContentTypeRemoteAttachment, RemoteAttachment(
url = url,
contentDigest = metadata.contentDigest,
secret = metadata.secret,
salt = metadata.salt,
nonce = metadata.nonce,
scheme = "https://",
contentLength = metadata.contentLength,
filename = metadata.filename,
)
)
} else if (obj.has("reaction")) {
val reaction = obj.get("reaction").asJsonObject
return ContentJson(ContentTypeReaction, Reaction(
Expand Down Expand Up @@ -109,6 +125,15 @@ class ContentJson(
)
)

ContentTypeRemoteAttachment.id -> mapOf(
"remoteAttachment" to mapOf(
"scheme" to "https://",
"url" to (content as RemoteAttachment).url.toString(),
) + EncryptedAttachmentMetadata
.fromRemoteAttachment(content)
.toJsonMap()
)

ContentTypeReaction.id -> mapOf(
"reaction" to mapOf(
"reference" to (content as Reaction).reference,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package expo.modules.xmtpreactnativesdk.wrappers

import com.google.gson.GsonBuilder
import com.google.gson.JsonObject
import com.google.gson.JsonParser

/**
* Refers to a decrypted attachment that is stored locally on the device.
*/
class DecryptedLocalAttachment(
val fileUri: String,
val mimeType: String,
) {
companion object {
fun fromJsonObject(obj: JsonObject) = DecryptedLocalAttachment(
obj.get("fileUri").asString,
obj.get("mimeType").asString,
)

fun fromJson(json: String): DecryptedLocalAttachment {
val obj = JsonParser.parseString(json).asJsonObject
return fromJsonObject(obj);
}
}

fun toJsonMap(): Map<String, Any> = mapOf(
"fileUri" to fileUri,
"mimeType" to mimeType,
)

fun toJson(): String = GsonBuilder().create().toJson(toJsonMap())
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
package expo.modules.xmtpreactnativesdk.wrappers

import com.facebook.common.util.Hex
import com.google.gson.JsonObject
import com.google.protobuf.ByteString
import com.google.protobuf.kotlin.toByteString
import org.xmtp.android.library.codecs.Attachment
import org.xmtp.android.library.codecs.EncryptedEncodedContent
import org.xmtp.android.library.codecs.RemoteAttachment

/**
* Describes the metadata for an encrypted attachment used to encrypt/decrypt the payload.
*/
class EncryptedAttachmentMetadata(
val filename: String,
val secret: ByteString,
val salt: ByteString,
val nonce: ByteString,
val contentDigest: String,
val contentLength: Int,
) {
companion object {
fun fromAttachment(
attachment: Attachment,
encrypted: EncryptedEncodedContent
) = EncryptedAttachmentMetadata(
attachment.filename,
encrypted.secret,
encrypted.salt,
encrypted.nonce,
encrypted.contentDigest,
attachment.data.size(),
)

fun fromRemoteAttachment(
remoteAttachment: RemoteAttachment,
) = EncryptedAttachmentMetadata(
remoteAttachment.filename ?: "",
remoteAttachment.secret,
remoteAttachment.salt,
remoteAttachment.nonce,
remoteAttachment.contentDigest,
remoteAttachment.contentLength ?: 0,
)

fun fromJsonObj(
obj: JsonObject,
) = EncryptedAttachmentMetadata(
obj.get("filename").asString,
Hex.hexStringToByteArray(obj.get("secret").asString).toByteString(),
Hex.hexStringToByteArray(obj.get("salt").asString).toByteString(),
Hex.hexStringToByteArray(obj.get("nonce").asString).toByteString(),
obj.get("contentDigest").asString,
Integer.parseInt(obj.get("contentLength").asString),
)
}

fun toJsonMap(): Map<String, Any> = mapOf(
"filename" to filename,
"secret" to Hex.encodeHex(secret.toByteArray(), false),
"salt" to Hex.encodeHex(salt.toByteArray(), false),
"nonce" to Hex.encodeHex(nonce.toByteArray(), false),
"contentDigest" to contentDigest,
"contentLength" to contentLength.toString(),
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package expo.modules.xmtpreactnativesdk.wrappers

import android.net.Uri
import com.google.gson.GsonBuilder
import com.google.gson.JsonObject
import com.google.gson.JsonParser
import org.xmtp.android.library.codecs.Attachment
import org.xmtp.android.library.codecs.EncryptedEncodedContent

/**
* Refers to an encrypted attachment that is stored locally on the device
* alongside the metadata that can be used to encrypt/decrypt it.
*/
class EncryptedLocalAttachment(
val encryptedLocalFileUri: String,
val metadata: EncryptedAttachmentMetadata,
) {
companion object {
fun from(
attachment: Attachment,
encrypted: EncryptedEncodedContent,
encryptedFile: Uri,
) = EncryptedLocalAttachment(
encryptedFile.toString(),
EncryptedAttachmentMetadata.fromAttachment(
attachment,
encrypted
)
)

fun fromJsonObject(obj: JsonObject) = EncryptedLocalAttachment(
obj.get("encryptedLocalFileUri").asString,
EncryptedAttachmentMetadata.fromJsonObj(obj.get("metadata").asJsonObject),
)

fun fromJson(json: String): EncryptedLocalAttachment {
val obj = JsonParser.parseString(json).asJsonObject
return fromJsonObject(obj);
}
}

fun toJsonMap(): Map<String, Any> = mapOf(
"encryptedLocalFileUri" to encryptedLocalFileUri,
"metadata" to metadata.toJsonMap()
)

fun toJson(): String = GsonBuilder().create().toJson(toJsonMap())
}
9 changes: 9 additions & 0 deletions example/app.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,15 @@
"assetBundlePatterns": [
"**/*"
],
"plugins": [
[
"expo-image-picker",
{
"photosPermission": "The app accesses your photos to let you attach them as messages.",
"cameraPermission": "The app accesses your camera to let you attach photos as messages."
}
]
],
"ios": {
"supportsTablet": true,
"bundleIdentifier": "expo.modules.xmtpreactnativesdk.example"
Expand Down
27 changes: 20 additions & 7 deletions example/dev/local/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,23 @@ services:
image: postgres:13
environment:
POSTGRES_PASSWORD: xmtp
js:
restart: always
depends_on:
wakunode:
condition: service_healthy
build: ./test


upload-service:
build: ./upload-service
ports:
- 4567:4567

caddy:
image: caddy:latest
ports:
- "443:443"
volumes:
- ./upload-service/Caddyfile:/etc/caddy/Caddyfile
- ./upload-service/data/data:/data
- ./upload-service/data/config:/config
# js:
# restart: always
# depends_on:
# wakunode:
# condition: service_healthy
# build: ./test
1 change: 1 addition & 0 deletions example/dev/local/upload-service/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
data/
Loading

0 comments on commit 8f74391

Please sign in to comment.