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

feat: support remote attachments #85

Merged
merged 2 commits into from
Aug 23, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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)
dmccartney marked this conversation as resolved.
Show resolved Hide resolved
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
Loading