Skip to content

Commit 23b2673

Browse files
alexrischAlex Risch
and
Alex Risch
authored
feat: Frames Client (#213)
* 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 <alexrisch@Alexs-MacBook-Pro-2.local>
1 parent 6c44637 commit 23b2673

File tree

7 files changed

+375
-1
lines changed

7 files changed

+375
-1
lines changed

library/build.gradle

+1-1
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ dependencies {
8686
implementation 'org.web3j:crypto:5.0.0'
8787
implementation "net.java.dev.jna:jna:5.13.0@aar"
8888
api 'com.google.protobuf:protobuf-kotlin-lite:3.22.3'
89-
api 'org.xmtp:proto-kotlin:3.43.2'
89+
api 'org.xmtp:proto-kotlin:3.47.0'
9090

9191
testImplementation 'junit:junit:4.13.2'
9292
androidTestImplementation 'app.cash.turbine:turbine:0.12.1'
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
package org.xmtp.android.library
2+
3+
import androidx.test.ext.junit.runners.AndroidJUnit4
4+
import kotlinx.coroutines.runBlocking
5+
import org.junit.Assert.assertEquals
6+
import org.junit.Assert.assertNotNull
7+
import org.junit.Test
8+
import org.junit.runner.RunWith
9+
import org.xmtp.android.library.frames.ConversationActionInputs
10+
import org.xmtp.android.library.frames.DmActionInputs
11+
import org.xmtp.android.library.frames.FrameActionInputs
12+
import org.xmtp.android.library.frames.FramePostPayload
13+
import org.xmtp.android.library.frames.FramesClient
14+
import org.xmtp.android.library.frames.GetMetadataResponse
15+
import java.net.HttpURLConnection
16+
import java.net.URL
17+
18+
@RunWith(AndroidJUnit4::class)
19+
class FramesTest {
20+
@Test
21+
fun testFramesClient() {
22+
val frameUrl = "https://fc-polls-five.vercel.app/polls/01032f47-e976-42ee-9e3d-3aac1324f4b8"
23+
val fixtures = fixtures()
24+
val aliceClient = fixtures.aliceClient
25+
26+
val framesClient = FramesClient(xmtpClient = aliceClient)
27+
val conversationTopic = "foo"
28+
val participantAccountAddresses = listOf("alix", "bo")
29+
val metadata: GetMetadataResponse
30+
runBlocking {
31+
metadata = framesClient.proxy.readMetadata(url = frameUrl)
32+
}
33+
34+
val dmInputs = DmActionInputs(
35+
conversationTopic = conversationTopic,
36+
participantAccountAddresses = participantAccountAddresses
37+
)
38+
val conversationInputs = ConversationActionInputs.Dm(dmInputs)
39+
val frameInputs = FrameActionInputs(
40+
frameUrl = frameUrl,
41+
buttonIndex = 1,
42+
inputText = null,
43+
state = null,
44+
conversationInputs = conversationInputs
45+
)
46+
val signedPayload: FramePostPayload
47+
runBlocking {
48+
signedPayload = framesClient.signFrameAction(inputs = frameInputs)
49+
}
50+
val postUrl = metadata.extractedTags["fc:frame:post_url"]
51+
assertNotNull(postUrl)
52+
val response: GetMetadataResponse
53+
runBlocking {
54+
response = framesClient.proxy.post(url = postUrl!!, payload = signedPayload)
55+
}
56+
57+
assertEquals(response.extractedTags["fc:frame"], "vNext")
58+
59+
val imageUrl = response.extractedTags["fc:frame:image"]
60+
assertNotNull(imageUrl)
61+
62+
val mediaUrl = framesClient.proxy.mediaUrl(url = imageUrl!!)
63+
64+
val url = URL(mediaUrl)
65+
val connection = url.openConnection() as HttpURLConnection
66+
connection.requestMethod = "GET"
67+
val responseCode = connection.responseCode
68+
assertEquals(responseCode, 200)
69+
assertEquals(connection.contentType, "image/png")
70+
}
71+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
package org.xmtp.android.library.frames
2+
3+
import android.util.Base64
4+
import org.xmtp.android.library.Client
5+
import org.xmtp.android.library.XMTPException
6+
import org.xmtp.android.library.frames.FramesConstants.PROTOCOL_VERSION
7+
import org.xmtp.android.library.messages.PrivateKeyBuilder
8+
import org.xmtp.android.library.messages.Signature
9+
import org.xmtp.android.library.messages.getPublicKeyBundle
10+
import org.xmtp.proto.message.contents.PublicKeyOuterClass.SignedPublicKeyBundle
11+
import java.security.MessageDigest
12+
import org.xmtp.proto.message.contents.Frames.FrameActionBody
13+
import org.xmtp.proto.message.contents.Frames.FrameAction
14+
import java.util.Date
15+
16+
class FramesClient(private val xmtpClient: Client, var proxy: OpenFramesProxy = OpenFramesProxy()) {
17+
18+
suspend fun signFrameAction(inputs: FrameActionInputs): FramePostPayload {
19+
val opaqueConversationIdentifier = buildOpaqueIdentifier(inputs)
20+
val frameUrl = inputs.frameUrl
21+
val buttonIndex = inputs.buttonIndex
22+
val inputText = inputs.inputText
23+
val state = inputs.state
24+
val now = Date().time * 1_000_000
25+
val frameActionBuilder = FrameActionBody.newBuilder().also { frame ->
26+
frame.frameUrl = frameUrl
27+
frame.buttonIndex = buttonIndex
28+
frame.opaqueConversationIdentifier = opaqueConversationIdentifier
29+
frame.timestamp = now
30+
frame.unixTimestamp = now.toInt()
31+
if (inputText != null) {
32+
frame.inputText = inputText
33+
}
34+
if (state != null) {
35+
frame.state = state
36+
}
37+
}
38+
39+
val toSign = frameActionBuilder.build()
40+
val signedAction = Base64.encodeToString(buildSignedFrameAction(toSign), Base64.NO_WRAP)
41+
42+
val untrustedData = FramePostUntrustedData(frameUrl, now, buttonIndex, inputText, state, xmtpClient.address, opaqueConversationIdentifier, now.toInt())
43+
val trustedData = FramePostTrustedData(signedAction)
44+
45+
return FramePostPayload("xmtp@$PROTOCOL_VERSION", untrustedData, trustedData)
46+
}
47+
48+
private suspend fun signDigest(digest: ByteArray): Signature {
49+
val signedPrivateKey = xmtpClient.keys.identityKey
50+
val privateKey = PrivateKeyBuilder.buildFromSignedPrivateKey(signedPrivateKey)
51+
return PrivateKeyBuilder(privateKey).sign(digest)
52+
}
53+
54+
private fun getPublicKeyBundle(): SignedPublicKeyBundle {
55+
return xmtpClient.keys.getPublicKeyBundle()
56+
}
57+
58+
private suspend fun buildSignedFrameAction(actionBodyInputs: FrameActionBody): ByteArray {
59+
val digest = sha256(actionBodyInputs.toByteArray())
60+
val signature = signDigest(digest)
61+
62+
val publicKeyBundle = getPublicKeyBundle()
63+
val frameAction = FrameAction.newBuilder().also {
64+
it.actionBody = actionBodyInputs.toByteString()
65+
it.signature = signature
66+
it.signedPublicKeyBundle = publicKeyBundle
67+
}.build()
68+
69+
return frameAction.toByteArray()
70+
}
71+
72+
private fun buildOpaqueIdentifier(inputs: FrameActionInputs): String {
73+
return when (inputs.conversationInputs) {
74+
is ConversationActionInputs.Group -> {
75+
val groupInputs = inputs.conversationInputs.inputs
76+
val combined = groupInputs.groupId + groupInputs.groupSecret
77+
val digest = sha256(combined)
78+
Base64.encodeToString(digest, Base64.NO_WRAP)
79+
}
80+
is ConversationActionInputs.Dm -> {
81+
val dmInputs = inputs.conversationInputs.inputs
82+
val conversationTopic = dmInputs.conversationTopic ?: throw XMTPException("No conversation topic")
83+
val combined = (conversationTopic.lowercase() + dmInputs.participantAccountAddresses.map { it.lowercase() }.sorted().joinToString("")).toByteArray()
84+
val digest = sha256(combined)
85+
Base64.encodeToString(digest, Base64.NO_WRAP)
86+
}
87+
}
88+
}
89+
90+
private fun sha256(input: ByteArray): ByteArray {
91+
val digest = MessageDigest.getInstance("SHA-256")
92+
return digest.digest(input)
93+
}
94+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
package org.xmtp.android.library.frames
2+
3+
object FramesConstants {
4+
const val OPEN_FRAMES_PROXY_URL = "https://frames.xmtp.chat/"
5+
const val PROTOCOL_VERSION = "2024-02-09"
6+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
package org.xmtp.android.library.frames
2+
3+
typealias AcceptedFrameClients = Map<String, String>
4+
5+
sealed class OpenFrameButton {
6+
abstract val target: String?
7+
abstract val label: String
8+
9+
data class Link(override val target: String, override val label: String) : OpenFrameButton()
10+
11+
data class Mint(override val target: String, override val label: String) : OpenFrameButton()
12+
13+
data class Post(override val target: String?, override val label: String) : OpenFrameButton()
14+
15+
data class PostRedirect(override val target: String?, override val label: String) : OpenFrameButton()
16+
}
17+
18+
data class OpenFrameImage(
19+
val content: String,
20+
val aspectRatio: AspectRatio?,
21+
val alt: String?
22+
)
23+
24+
enum class AspectRatio(val ratio: String) {
25+
RATIO_1_91_1("1.91.1"),
26+
RATIO_1_1("1:1")
27+
}
28+
29+
data class TextInput(val content: String)
30+
31+
data class OpenFrameResult(
32+
val acceptedClients: AcceptedFrameClients,
33+
val image: OpenFrameImage,
34+
val postUrl: String?,
35+
val textInput: TextInput?,
36+
val buttons: Map<String, OpenFrameButton>?,
37+
val ogImage: String,
38+
val state: String?
39+
)
40+
41+
data class GetMetadataResponse(
42+
val url: String,
43+
val extractedTags: Map<String, String>
44+
)
45+
46+
data class PostRedirectResponse(
47+
val originalUrl: String,
48+
val redirectedTo: String
49+
)
50+
51+
data class OpenFramesUntrustedData(
52+
val url: String,
53+
val timestamp: Int,
54+
val buttonIndex: Int,
55+
val inputText: String?,
56+
val state: String?
57+
)
58+
59+
typealias FramesApiRedirectResponse = PostRedirectResponse
60+
61+
data class FramePostUntrustedData(
62+
val url: String,
63+
val timestamp: Long,
64+
val buttonIndex: Int,
65+
val inputText: String?,
66+
val state: String?,
67+
val walletAddress: String,
68+
val opaqueConversationIdentifier: String,
69+
val unixTimestamp: Int
70+
)
71+
72+
data class FramePostTrustedData(
73+
val messageBytes: String
74+
)
75+
76+
data class FramePostPayload(
77+
val clientProtocol: String,
78+
val untrustedData: FramePostUntrustedData,
79+
val trustedData: FramePostTrustedData
80+
)
81+
82+
data class DmActionInputs(
83+
val conversationTopic: String?,
84+
val participantAccountAddresses: List<String>
85+
)
86+
87+
data class GroupActionInputs(
88+
val groupId: ByteArray,
89+
val groupSecret: ByteArray
90+
)
91+
92+
sealed class ConversationActionInputs {
93+
data class Dm(val inputs: DmActionInputs) : ConversationActionInputs()
94+
data class Group(val inputs: GroupActionInputs) : ConversationActionInputs()
95+
}
96+
97+
data class FrameActionInputs(
98+
val frameUrl: String,
99+
val buttonIndex: Int,
100+
val inputText: String?,
101+
val state: String?,
102+
val conversationInputs: ConversationActionInputs
103+
)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
package org.xmtp.android.library.frames
2+
3+
import org.xmtp.android.library.frames.FramesConstants.OPEN_FRAMES_PROXY_URL
4+
import java.net.URI
5+
6+
class OpenFramesProxy(private val inner: ProxyClient = ProxyClient(OPEN_FRAMES_PROXY_URL)) {
7+
8+
suspend fun readMetadata(url: String): GetMetadataResponse {
9+
return inner.readMetadata(url)
10+
}
11+
12+
suspend fun post(url: String, payload: FramePostPayload): GetMetadataResponse {
13+
return inner.post(url, payload)
14+
}
15+
16+
suspend fun postRedirect(url: String, payload: FramePostPayload): FramesApiRedirectResponse {
17+
return inner.postRedirect(url, payload)
18+
}
19+
20+
fun mediaUrl(url: String): String {
21+
if (URI(url).scheme == "data") {
22+
return url
23+
} else {
24+
return inner.mediaUrl(url)
25+
}
26+
}
27+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
package org.xmtp.android.library.frames
2+
3+
import java.net.HttpURLConnection
4+
import java.net.URL
5+
import com.google.gson.Gson
6+
import org.xmtp.android.library.XMTPException
7+
import java.io.OutputStreamWriter
8+
9+
class ProxyClient(private val baseUrl: String) {
10+
11+
suspend fun readMetadata(url: String): GetMetadataResponse {
12+
val connection = URL("$baseUrl?url=${java.net.URLEncoder.encode(url, "UTF-8")}").openConnection() as HttpURLConnection
13+
14+
if (connection.responseCode != HttpURLConnection.HTTP_OK) {
15+
throw XMTPException("Failed to read metadata for $url, response code $connection.responseCode")
16+
}
17+
18+
val response = connection.inputStream.bufferedReader().use { it.readText() }
19+
return Gson().fromJson(response, GetMetadataResponse::class.java)
20+
}
21+
22+
fun post(url: String, payload: Any): GetMetadataResponse {
23+
val connection = URL("$baseUrl?url=${java.net.URLEncoder.encode(url, "UTF-8")}").openConnection() as HttpURLConnection
24+
connection.requestMethod = "POST"
25+
connection.setRequestProperty("Content-Type", "application/json; utf-8")
26+
connection.doOutput = true
27+
28+
val gson = Gson()
29+
val jsonInputString = gson.toJson(payload)
30+
31+
connection.outputStream.use { os ->
32+
val writer = OutputStreamWriter(os, "UTF-8")
33+
writer.write(jsonInputString)
34+
writer.flush()
35+
writer.close()
36+
}
37+
38+
if (connection.responseCode != HttpURLConnection.HTTP_OK) {
39+
throw Exception("Failed to post to frame: ${connection.responseCode} ${connection.responseMessage}")
40+
}
41+
42+
val response = connection.inputStream.bufferedReader().use { it.readText() }
43+
return gson.fromJson(response, GetMetadataResponse::class.java)
44+
}
45+
46+
suspend fun postRedirect(url: String, payload: Any): PostRedirectResponse {
47+
val connection = URL("$baseUrl/redirect?url=${java.net.URLEncoder.encode(url, "UTF-8")}").openConnection() as HttpURLConnection
48+
connection.requestMethod = "POST"
49+
connection.setRequestProperty("Content-Type", "application/json; utf-8")
50+
connection.doOutput = true
51+
52+
val gson = Gson()
53+
val jsonInputString = gson.toJson(payload)
54+
55+
connection.outputStream.use { os ->
56+
val writer = OutputStreamWriter(os, "UTF-8")
57+
writer.write(jsonInputString)
58+
writer.flush()
59+
writer.close()
60+
}
61+
62+
if (connection.responseCode != HttpURLConnection.HTTP_OK) {
63+
throw XMTPException("Failed to post to frame: ${connection.responseMessage}, resoinse code $connection.responseCode")
64+
}
65+
66+
val response = connection.inputStream.bufferedReader().use { it.readText() }
67+
return gson.fromJson(response, PostRedirectResponse::class.java)
68+
}
69+
70+
fun mediaUrl(url: String): String {
71+
return "${baseUrl}media?url=${java.net.URLEncoder.encode(url, "UTF-8")}"
72+
}
73+
}

0 commit comments

Comments
 (0)