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: Frames Client #213

Merged
merged 8 commits into from
Apr 3, 2024
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 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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
val inputText = inputs.inputText
val inputText = inputs.inputText ?: ""

Just to match iOS and remove the null check inline

val state = inputs.state
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
val state = inputs.state
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
}
Comment on lines +31 to +36
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a reason you null check here? Seems like state and inputText are okay with null being set?

Suggested change
if (inputText != null) {
frame.inputText = inputText
}
if (state != null) {
frame.state = state
}
frame.inputText = inputText
frame.state = state

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

They are not, it errors if the state is null or inputText is null, instead they use the default values

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ahh okay now I see you did this in iOS

let state = inputs.state ?? ""
Then to clean this up what about this

}

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(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's going to make usability much better if this also included the frameInfo field which does a lot of the parsing of the extracted tags for you and assembles it into a nice object. Could happen in a follow-up PR.

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")}"
}
}
Loading