Skip to content

Commit

Permalink
feat: Frames Client (#213)
Browse files Browse the repository at this point in the history
* feat: Frames Client

Added Frames package port from JS
Bumped protos library

* feat: Frames CLient

Updated handling
Added tests

* Fixed Lint

* Fixed tests fixed lint

* Fix lint on builds

Fixed lint on builds

* Fix lint on builds

Fixed lint on builds

* Updates

Updated builder pattern usage
Updated timestamp logic
Removed FramesErrors and just used XmtpException

* Updates

Updated builder pattern usage
Updated timestamp logic
Removed FramesErrors and just used XmtpException

---------

Co-authored-by: Alex Risch <[email protected]>
  • Loading branch information
alexrisch and Alex Risch authored Apr 3, 2024
1 parent 6c44637 commit 23b2673
Show file tree
Hide file tree
Showing 7 changed files with 375 additions and 1 deletion.
2 changes: 1 addition & 1 deletion library/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
Original file line number Diff line number Diff line change
@@ -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")
}
}
Original file line number Diff line number Diff line change
@@ -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)
}
}
Original file line number Diff line number Diff line change
@@ -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"
}
103 changes: 103 additions & 0 deletions library/src/main/java/org/xmtp/android/library/frames/FramesTypes.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
package org.xmtp.android.library.frames

typealias AcceptedFrameClients = Map<String, String>

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<String, OpenFrameButton>?,
val ogImage: String,
val state: String?
)

data class GetMetadataResponse(
val url: String,
val extractedTags: Map<String, String>
)

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<String>
)

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
)
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
Original file line number Diff line number Diff line change
@@ -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")}"
}
}

0 comments on commit 23b2673

Please sign in to comment.