Skip to content

Commit 4f19e12

Browse files
Alex RischAlex Risch
Alex Risch
authored and
Alex Risch
committed
feat: Frames Client
Added Frames package port from JS Bumped protos library
1 parent 8a39578 commit 4f19e12

File tree

7 files changed

+314
-1
lines changed

7 files changed

+314
-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,83 @@
1+
package org.xmtp.android.library.frames
2+
3+
import kotlinx.coroutines.Dispatchers
4+
import kotlinx.coroutines.withContext
5+
import org.xmtp.android.library.Client
6+
import org.xmtp.android.library.frames.Constants.PROTOCOL_VERSION
7+
import org.xmtp.android.library.messages.PrivateKey
8+
import org.xmtp.android.library.messages.PrivateKeyBuilder
9+
import org.xmtp.android.library.messages.PublicKeyBundle
10+
import org.xmtp.android.library.messages.Signature
11+
import org.xmtp.android.library.messages.SignedPublicKeyBundle
12+
import java.security.MessageDigest
13+
import java.util.*
14+
import org.xmtp.proto.message.contents.SignatureOuterClass
15+
import org.xmtp.proto.message.contents.FrameActionBody
16+
17+
class FramesClient(private val xmtpClient: Client, var proxy: OpenFramesProxy = OpenFramesProxy()) {
18+
19+
suspend fun signFrameAction(inputs: FrameActionInputs): FramePostPayload = withContext(Dispatchers.IO) {
20+
val opaqueConversationIdentifier = buildOpaqueIdentifier(inputs)
21+
val frameUrl = inputs.frameUrl
22+
val buttonIndex = inputs.buttonIndex
23+
val inputText = inputs.inputText ?: ""
24+
val state = inputs.state ?: ""
25+
val now = System.currentTimeMillis() / 1000
26+
val timestamp = now
27+
28+
val toSign = FrameActionBody(frameUrl, buttonIndex, opaqueConversationIdentifier, timestamp.toULong(), inputText, now.toUInt(), state)
29+
30+
val signedAction = buildSignedFrameAction(toSign)
31+
32+
val untrustedData = FramePostUntrustedData(frameUrl, now, buttonIndex, inputText, state, xmtpClient.address, opaqueConversationIdentifier, now.toInt())
33+
34+
val trustedData = FramePostTrustedData(signedAction.encodeToBase64())
35+
36+
FramePostPayload("xmtp@$PROTOCOL_VERSION", untrustedData, trustedData)
37+
}
38+
39+
private suspend fun signDigest(digest: ByteArray): Signature {
40+
val signedPrivateKey = xmtpClient.keys.identityKey
41+
val privateKey = PrivateKeyBuilder.buildFromSignedPrivateKey(signedPrivateKey)
42+
return PrivateKeyBuilder(privateKey).sign(digest)
43+
}
44+
45+
private suspend fun getPublicKeyBundle(): ByteArray {
46+
// val bundleBytes = xmtpClient.privateKeyBundle.toByteArray()
47+
// Base64.encodeToString(client.privateKeyBundle.toByteArray(), NO_WRAP)
48+
return xmtpClient.privateKeyBundle.toByteArray()
49+
}
50+
51+
private suspend fun buildSignedFrameAction(actionBodyInputs: FrameActionBody): ByteArray = withContext(Dispatchers.IO) {
52+
val digest = sha256(actionBodyInputs.toByteArray())
53+
val signature = signDigest(digest)
54+
55+
val publicKeyBundle = getPublicKeyBundle()
56+
val frameAction = FrameAction(actionBodyInputs.toByteArray(), signature, SignedPublicKeyBundle(publicKeyBundle))
57+
58+
frameAction.toByteArray()
59+
}
60+
61+
private fun buildOpaqueIdentifier(inputs: FrameActionInputs): String {
62+
return when (inputs.conversationInputs) {
63+
is ConversationActionInputs.Group -> {
64+
val groupInputs = inputs.conversationInputs.inputs
65+
val combined = groupInputs.groupId + groupInputs.groupSecret
66+
val digest = sha256(combined)
67+
digest.encodeToBase64()
68+
}
69+
is ConversationActionInputs.Dm -> {
70+
val dmInputs = inputs.conversationInputs.inputs
71+
val conversationTopic = dmInputs.conversationTopic ?: throw InvalidArgumentsError()
72+
val combined = (conversationTopic.lowercase() + dmInputs.participantAccountAddresses.map { it.lowercase() }.sorted().joinToString("")).toByteArray()
73+
val digest = sha256(combined)
74+
digest.encodeToBase64()
75+
}
76+
}
77+
}
78+
79+
private fun sha256(input: ByteArray): ByteArray {
80+
val digest = MessageDigest.getInstance("SHA-256")
81+
return digest.digest(input)
82+
}
83+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
package org.xmtp.android.library.frames
2+
3+
object Constants {
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,5 @@
1+
package org.xmtp.android.library.frames
2+
3+
class FramesApiError(message: String, val status: Int) : Exception(message)
4+
5+
class InvalidArgumentsError : Exception()
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
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+
42+
data class GetMetadataResponse(
43+
val url: String,
44+
val extractedTags: Map<String, String>
45+
)
46+
47+
48+
data class PostRedirectResponse(
49+
val originalUrl: String,
50+
val redirectedTo: String
51+
)
52+
53+
54+
data class OpenFramesUntrustedData(
55+
val url: String,
56+
val timestamp: Int,
57+
val buttonIndex: Int,
58+
val inputText: String?,
59+
val state: String?
60+
)
61+
62+
typealias FramesApiRedirectResponse = PostRedirectResponse
63+
64+
65+
data class FramePostUntrustedData(
66+
val url: String,
67+
val timestamp: Long,
68+
val buttonIndex: Int,
69+
val inputText: String?,
70+
val state: String?,
71+
val walletAddress: String,
72+
val opaqueConversationIdentifier: String,
73+
val unixTimestamp: Int
74+
)
75+
76+
77+
data class FramePostTrustedData(
78+
val messageBytes: String
79+
)
80+
81+
82+
data class FramePostPayload(
83+
val clientProtocol: String,
84+
val untrustedData: FramePostUntrustedData,
85+
val trustedData: FramePostTrustedData
86+
)
87+
88+
89+
data class DmActionInputs(
90+
val conversationTopic: String?,
91+
val participantAccountAddresses: List<String>
92+
)
93+
94+
95+
data class GroupActionInputs(
96+
val groupId: ByteArray,
97+
val groupSecret: ByteArray
98+
)
99+
100+
101+
sealed class ConversationActionInputs {
102+
103+
data class Dm(val inputs: DmActionInputs) : ConversationActionInputs()
104+
105+
106+
data class Group(val inputs: GroupActionInputs) : ConversationActionInputs()
107+
}
108+
109+
110+
data class FrameActionInputs(
111+
val frameUrl: String,
112+
val buttonIndex: Int,
113+
val inputText: String?,
114+
val state: String?,
115+
val conversationInputs: ConversationActionInputs
116+
)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
package org.xmtp.android.library.frames
2+
3+
import ProxyClient
4+
import kotlinx.coroutines.Dispatchers
5+
import kotlinx.coroutines.withContext
6+
import org.xmtp.android.library.frames.Constants.OPEN_FRAMES_PROXY_URL
7+
8+
class OpenFramesProxy(private val inner: ProxyClient = ProxyClient(OPEN_FRAMES_PROXY_URL)) {
9+
10+
suspend fun readMetadata(url: String): GetMetadataResponse = withContext(Dispatchers.IO) {
11+
inner.readMetadata(url)
12+
}
13+
14+
suspend fun post(url: String, payload: FramePostPayload): GetMetadataResponse = withContext(Dispatchers.IO) {
15+
inner.post(url, payload)
16+
}
17+
18+
suspend fun postRedirect(url: String, payload: FramePostPayload): FramesApiRedirectResponse = withContext(Dispatchers.IO) {
19+
inner.postRedirect(url, payload)
20+
}
21+
22+
suspend fun mediaUrl(url: String): String = withContext(Dispatchers.IO) {
23+
if (url.startsWith("data:")) {
24+
url
25+
} else {
26+
inner.mediaUrl(url)
27+
}
28+
}
29+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
2+
import org.xmtp.android.library.frames.GetMetadataResponse
3+
import org.xmtp.android.library.frames.PostRedirectResponse
4+
import java.net.HttpURLConnection
5+
import java.net.URL
6+
import com.google.gson.Gson
7+
import org.xmtp.android.library.frames.FramesApiError
8+
import java.io.OutputStreamWriter
9+
10+
class ProxyClient(private val baseUrl: String) {
11+
12+
suspend fun readMetadata(url: String): GetMetadataResponse {
13+
val connection = URL("$baseUrl?url=${java.net.URLEncoder.encode(url, "UTF-8")}").openConnection() as HttpURLConnection
14+
15+
if (connection.responseCode != HttpURLConnection.HTTP_OK) {
16+
throw FramesApiError("Failed to read metadata for $url", connection.responseCode)
17+
}
18+
19+
val response = connection.inputStream.bufferedReader().use { it.readText() }
20+
return Gson().fromJson(response, GetMetadataResponse::class.java)
21+
}
22+
23+
suspend fun post(url: String, payload: Any): GetMetadataResponse {
24+
val connection = URL("$baseUrl?url=${java.net.URLEncoder.encode(url, "UTF-8")}").openConnection() as HttpURLConnection
25+
connection.requestMethod = "POST"
26+
connection.setRequestProperty("Content-Type", "application/json; utf-8")
27+
connection.doOutput = true
28+
29+
val gson = Gson()
30+
val jsonInputString = gson.toJson(payload)
31+
32+
connection.outputStream.use { os ->
33+
val writer = OutputStreamWriter(os, "UTF-8")
34+
writer.write(jsonInputString)
35+
writer.flush()
36+
writer.close()
37+
}
38+
39+
if (connection.responseCode != HttpURLConnection.HTTP_OK) {
40+
throw Exception("Failed to post to frame: ${connection.responseCode} ${connection.responseMessage}")
41+
}
42+
43+
val response = connection.inputStream.bufferedReader().use { it.readText() }
44+
return gson.fromJson(response, GetMetadataResponse::class.java)
45+
}
46+
47+
suspend fun postRedirect(url: String, payload: Any): PostRedirectResponse {
48+
val connection = URL("$baseUrl/redirect?url=${java.net.URLEncoder.encode(url, "UTF-8")}").openConnection() as HttpURLConnection
49+
connection.requestMethod = "POST"
50+
connection.setRequestProperty("Content-Type", "application/json; utf-8")
51+
connection.doOutput = true
52+
53+
val gson = Gson()
54+
val jsonInputString = gson.toJson(payload)
55+
56+
connection.outputStream.use { os ->
57+
val writer = OutputStreamWriter(os, "UTF-8")
58+
writer.write(jsonInputString)
59+
writer.flush()
60+
writer.close()
61+
}
62+
63+
if (connection.responseCode != HttpURLConnection.HTTP_OK) {
64+
throw FramesApiError("Failed to post to frame: ${connection.responseMessage}", connection.responseCode)
65+
}
66+
67+
val response = connection.inputStream.bufferedReader().use { it.readText() }
68+
return gson.fromJson(response, PostRedirectResponse::class.java)
69+
}
70+
71+
fun mediaUrl(url: String): String {
72+
return "${baseUrl}media?url=${java.net.URLEncoder.encode(url, "UTF-8")}"
73+
}
74+
}

0 commit comments

Comments
 (0)