diff --git a/library/build.gradle b/library/build.gradle index 126525620..27408f40b 100644 --- a/library/build.gradle +++ b/library/build.gradle @@ -86,7 +86,7 @@ dependencies { implementation 'org.web3j:crypto:5.0.0' implementation "net.java.dev.jna:jna:5.13.0@aar" api 'com.google.protobuf:protobuf-kotlin-lite:3.22.3' - api 'org.xmtp:proto-kotlin:3.43.2' + api 'org.xmtp:proto-kotlin:3.47.0' testImplementation 'junit:junit:4.13.2' androidTestImplementation 'app.cash.turbine:turbine:0.12.1' diff --git a/library/src/androidTest/java/org/xmtp/android/library/FramesTest.kt b/library/src/androidTest/java/org/xmtp/android/library/FramesTest.kt new file mode 100644 index 000000000..db0bc52c8 --- /dev/null +++ b/library/src/androidTest/java/org/xmtp/android/library/FramesTest.kt @@ -0,0 +1,71 @@ +package org.xmtp.android.library + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Test +import org.junit.runner.RunWith +import org.xmtp.android.library.frames.ConversationActionInputs +import org.xmtp.android.library.frames.DmActionInputs +import org.xmtp.android.library.frames.FrameActionInputs +import org.xmtp.android.library.frames.FramePostPayload +import org.xmtp.android.library.frames.FramesClient +import org.xmtp.android.library.frames.GetMetadataResponse +import java.net.HttpURLConnection +import java.net.URL + +@RunWith(AndroidJUnit4::class) +class FramesTest { + @Test + fun testFramesClient() { + val frameUrl = "https://fc-polls-five.vercel.app/polls/01032f47-e976-42ee-9e3d-3aac1324f4b8" + val fixtures = fixtures() + val aliceClient = fixtures.aliceClient + + val framesClient = FramesClient(xmtpClient = aliceClient) + val conversationTopic = "foo" + val participantAccountAddresses = listOf("alix", "bo") + val metadata: GetMetadataResponse + runBlocking { + metadata = framesClient.proxy.readMetadata(url = frameUrl) + } + + val dmInputs = DmActionInputs( + conversationTopic = conversationTopic, + participantAccountAddresses = participantAccountAddresses + ) + val conversationInputs = ConversationActionInputs.Dm(dmInputs) + val frameInputs = FrameActionInputs( + frameUrl = frameUrl, + buttonIndex = 1, + inputText = null, + state = null, + conversationInputs = conversationInputs + ) + val signedPayload: FramePostPayload + runBlocking { + signedPayload = framesClient.signFrameAction(inputs = frameInputs) + } + val postUrl = metadata.extractedTags["fc:frame:post_url"] + assertNotNull(postUrl) + val response: GetMetadataResponse + runBlocking { + response = framesClient.proxy.post(url = postUrl!!, payload = signedPayload) + } + + assertEquals(response.extractedTags["fc:frame"], "vNext") + + val imageUrl = response.extractedTags["fc:frame:image"] + assertNotNull(imageUrl) + + val mediaUrl = framesClient.proxy.mediaUrl(url = imageUrl!!) + + val url = URL(mediaUrl) + val connection = url.openConnection() as HttpURLConnection + connection.requestMethod = "GET" + val responseCode = connection.responseCode + assertEquals(responseCode, 200) + assertEquals(connection.contentType, "image/png") + } +} diff --git a/library/src/main/java/org/xmtp/android/library/frames/FramesClient.kt b/library/src/main/java/org/xmtp/android/library/frames/FramesClient.kt new file mode 100644 index 000000000..d08defdc1 --- /dev/null +++ b/library/src/main/java/org/xmtp/android/library/frames/FramesClient.kt @@ -0,0 +1,94 @@ +package org.xmtp.android.library.frames + +import android.util.Base64 +import org.xmtp.android.library.Client +import org.xmtp.android.library.XMTPException +import org.xmtp.android.library.frames.FramesConstants.PROTOCOL_VERSION +import org.xmtp.android.library.messages.PrivateKeyBuilder +import org.xmtp.android.library.messages.Signature +import org.xmtp.android.library.messages.getPublicKeyBundle +import org.xmtp.proto.message.contents.PublicKeyOuterClass.SignedPublicKeyBundle +import java.security.MessageDigest +import org.xmtp.proto.message.contents.Frames.FrameActionBody +import org.xmtp.proto.message.contents.Frames.FrameAction +import java.util.Date + +class FramesClient(private val xmtpClient: Client, var proxy: OpenFramesProxy = OpenFramesProxy()) { + + suspend fun signFrameAction(inputs: FrameActionInputs): FramePostPayload { + val opaqueConversationIdentifier = buildOpaqueIdentifier(inputs) + val frameUrl = inputs.frameUrl + val buttonIndex = inputs.buttonIndex + val inputText = inputs.inputText + val state = inputs.state + val now = Date().time * 1_000_000 + val frameActionBuilder = FrameActionBody.newBuilder().also { frame -> + frame.frameUrl = frameUrl + frame.buttonIndex = buttonIndex + frame.opaqueConversationIdentifier = opaqueConversationIdentifier + frame.timestamp = now + frame.unixTimestamp = now.toInt() + if (inputText != null) { + frame.inputText = inputText + } + if (state != null) { + frame.state = state + } + } + + val toSign = frameActionBuilder.build() + val signedAction = Base64.encodeToString(buildSignedFrameAction(toSign), Base64.NO_WRAP) + + val untrustedData = FramePostUntrustedData(frameUrl, now, buttonIndex, inputText, state, xmtpClient.address, opaqueConversationIdentifier, now.toInt()) + val trustedData = FramePostTrustedData(signedAction) + + return FramePostPayload("xmtp@$PROTOCOL_VERSION", untrustedData, trustedData) + } + + private suspend fun signDigest(digest: ByteArray): Signature { + val signedPrivateKey = xmtpClient.keys.identityKey + val privateKey = PrivateKeyBuilder.buildFromSignedPrivateKey(signedPrivateKey) + return PrivateKeyBuilder(privateKey).sign(digest) + } + + private fun getPublicKeyBundle(): SignedPublicKeyBundle { + return xmtpClient.keys.getPublicKeyBundle() + } + + private suspend fun buildSignedFrameAction(actionBodyInputs: FrameActionBody): ByteArray { + val digest = sha256(actionBodyInputs.toByteArray()) + val signature = signDigest(digest) + + val publicKeyBundle = getPublicKeyBundle() + val frameAction = FrameAction.newBuilder().also { + it.actionBody = actionBodyInputs.toByteString() + it.signature = signature + it.signedPublicKeyBundle = publicKeyBundle + }.build() + + return frameAction.toByteArray() + } + + private fun buildOpaqueIdentifier(inputs: FrameActionInputs): String { + return when (inputs.conversationInputs) { + is ConversationActionInputs.Group -> { + val groupInputs = inputs.conversationInputs.inputs + val combined = groupInputs.groupId + groupInputs.groupSecret + val digest = sha256(combined) + Base64.encodeToString(digest, Base64.NO_WRAP) + } + is ConversationActionInputs.Dm -> { + val dmInputs = inputs.conversationInputs.inputs + val conversationTopic = dmInputs.conversationTopic ?: throw XMTPException("No conversation topic") + val combined = (conversationTopic.lowercase() + dmInputs.participantAccountAddresses.map { it.lowercase() }.sorted().joinToString("")).toByteArray() + val digest = sha256(combined) + Base64.encodeToString(digest, Base64.NO_WRAP) + } + } + } + + private fun sha256(input: ByteArray): ByteArray { + val digest = MessageDigest.getInstance("SHA-256") + return digest.digest(input) + } +} diff --git a/library/src/main/java/org/xmtp/android/library/frames/FramesConstants.kt b/library/src/main/java/org/xmtp/android/library/frames/FramesConstants.kt new file mode 100644 index 000000000..5e42c94a5 --- /dev/null +++ b/library/src/main/java/org/xmtp/android/library/frames/FramesConstants.kt @@ -0,0 +1,6 @@ +package org.xmtp.android.library.frames + +object FramesConstants { + const val OPEN_FRAMES_PROXY_URL = "https://frames.xmtp.chat/" + const val PROTOCOL_VERSION = "2024-02-09" +} diff --git a/library/src/main/java/org/xmtp/android/library/frames/FramesTypes.kt b/library/src/main/java/org/xmtp/android/library/frames/FramesTypes.kt new file mode 100644 index 000000000..1a00cf87d --- /dev/null +++ b/library/src/main/java/org/xmtp/android/library/frames/FramesTypes.kt @@ -0,0 +1,103 @@ +package org.xmtp.android.library.frames + +typealias AcceptedFrameClients = Map + +sealed class OpenFrameButton { + abstract val target: String? + abstract val label: String + + data class Link(override val target: String, override val label: String) : OpenFrameButton() + + data class Mint(override val target: String, override val label: String) : OpenFrameButton() + + data class Post(override val target: String?, override val label: String) : OpenFrameButton() + + data class PostRedirect(override val target: String?, override val label: String) : OpenFrameButton() +} + +data class OpenFrameImage( + val content: String, + val aspectRatio: AspectRatio?, + val alt: String? +) + +enum class AspectRatio(val ratio: String) { + RATIO_1_91_1("1.91.1"), + RATIO_1_1("1:1") +} + +data class TextInput(val content: String) + +data class OpenFrameResult( + val acceptedClients: AcceptedFrameClients, + val image: OpenFrameImage, + val postUrl: String?, + val textInput: TextInput?, + val buttons: Map?, + val ogImage: String, + val state: String? +) + +data class GetMetadataResponse( + val url: String, + val extractedTags: Map +) + +data class PostRedirectResponse( + val originalUrl: String, + val redirectedTo: String +) + +data class OpenFramesUntrustedData( + val url: String, + val timestamp: Int, + val buttonIndex: Int, + val inputText: String?, + val state: String? +) + +typealias FramesApiRedirectResponse = PostRedirectResponse + +data class FramePostUntrustedData( + val url: String, + val timestamp: Long, + val buttonIndex: Int, + val inputText: String?, + val state: String?, + val walletAddress: String, + val opaqueConversationIdentifier: String, + val unixTimestamp: Int +) + +data class FramePostTrustedData( + val messageBytes: String +) + +data class FramePostPayload( + val clientProtocol: String, + val untrustedData: FramePostUntrustedData, + val trustedData: FramePostTrustedData +) + +data class DmActionInputs( + val conversationTopic: String?, + val participantAccountAddresses: List +) + +data class GroupActionInputs( + val groupId: ByteArray, + val groupSecret: ByteArray +) + +sealed class ConversationActionInputs { + data class Dm(val inputs: DmActionInputs) : ConversationActionInputs() + data class Group(val inputs: GroupActionInputs) : ConversationActionInputs() +} + +data class FrameActionInputs( + val frameUrl: String, + val buttonIndex: Int, + val inputText: String?, + val state: String?, + val conversationInputs: ConversationActionInputs +) diff --git a/library/src/main/java/org/xmtp/android/library/frames/OpenFramesProxy.kt b/library/src/main/java/org/xmtp/android/library/frames/OpenFramesProxy.kt new file mode 100644 index 000000000..2ff7ef1e4 --- /dev/null +++ b/library/src/main/java/org/xmtp/android/library/frames/OpenFramesProxy.kt @@ -0,0 +1,27 @@ +package org.xmtp.android.library.frames + +import org.xmtp.android.library.frames.FramesConstants.OPEN_FRAMES_PROXY_URL +import java.net.URI + +class OpenFramesProxy(private val inner: ProxyClient = ProxyClient(OPEN_FRAMES_PROXY_URL)) { + + suspend fun readMetadata(url: String): GetMetadataResponse { + return inner.readMetadata(url) + } + + suspend fun post(url: String, payload: FramePostPayload): GetMetadataResponse { + return inner.post(url, payload) + } + + suspend fun postRedirect(url: String, payload: FramePostPayload): FramesApiRedirectResponse { + return inner.postRedirect(url, payload) + } + + fun mediaUrl(url: String): String { + if (URI(url).scheme == "data") { + return url + } else { + return inner.mediaUrl(url) + } + } +} diff --git a/library/src/main/java/org/xmtp/android/library/frames/ProxyClient.kt b/library/src/main/java/org/xmtp/android/library/frames/ProxyClient.kt new file mode 100644 index 000000000..93c604772 --- /dev/null +++ b/library/src/main/java/org/xmtp/android/library/frames/ProxyClient.kt @@ -0,0 +1,73 @@ +package org.xmtp.android.library.frames + +import java.net.HttpURLConnection +import java.net.URL +import com.google.gson.Gson +import org.xmtp.android.library.XMTPException +import java.io.OutputStreamWriter + +class ProxyClient(private val baseUrl: String) { + + suspend fun readMetadata(url: String): GetMetadataResponse { + val connection = URL("$baseUrl?url=${java.net.URLEncoder.encode(url, "UTF-8")}").openConnection() as HttpURLConnection + + if (connection.responseCode != HttpURLConnection.HTTP_OK) { + throw XMTPException("Failed to read metadata for $url, response code $connection.responseCode") + } + + val response = connection.inputStream.bufferedReader().use { it.readText() } + return Gson().fromJson(response, GetMetadataResponse::class.java) + } + + fun post(url: String, payload: Any): GetMetadataResponse { + val connection = URL("$baseUrl?url=${java.net.URLEncoder.encode(url, "UTF-8")}").openConnection() as HttpURLConnection + connection.requestMethod = "POST" + connection.setRequestProperty("Content-Type", "application/json; utf-8") + connection.doOutput = true + + val gson = Gson() + val jsonInputString = gson.toJson(payload) + + connection.outputStream.use { os -> + val writer = OutputStreamWriter(os, "UTF-8") + writer.write(jsonInputString) + writer.flush() + writer.close() + } + + if (connection.responseCode != HttpURLConnection.HTTP_OK) { + throw Exception("Failed to post to frame: ${connection.responseCode} ${connection.responseMessage}") + } + + val response = connection.inputStream.bufferedReader().use { it.readText() } + return gson.fromJson(response, GetMetadataResponse::class.java) + } + + suspend fun postRedirect(url: String, payload: Any): PostRedirectResponse { + val connection = URL("$baseUrl/redirect?url=${java.net.URLEncoder.encode(url, "UTF-8")}").openConnection() as HttpURLConnection + connection.requestMethod = "POST" + connection.setRequestProperty("Content-Type", "application/json; utf-8") + connection.doOutput = true + + val gson = Gson() + val jsonInputString = gson.toJson(payload) + + connection.outputStream.use { os -> + val writer = OutputStreamWriter(os, "UTF-8") + writer.write(jsonInputString) + writer.flush() + writer.close() + } + + if (connection.responseCode != HttpURLConnection.HTTP_OK) { + throw XMTPException("Failed to post to frame: ${connection.responseMessage}, resoinse code $connection.responseCode") + } + + val response = connection.inputStream.bufferedReader().use { it.readText() } + return gson.fromJson(response, PostRedirectResponse::class.java) + } + + fun mediaUrl(url: String): String { + return "${baseUrl}media?url=${java.net.URLEncoder.encode(url, "UTF-8")}" + } +}