-
Notifications
You must be signed in to change notification settings - Fork 14
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
* 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
Showing
7 changed files
with
375 additions
and
1 deletion.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
71 changes: 71 additions & 0 deletions
71
library/src/androidTest/java/org/xmtp/android/library/FramesTest.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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") | ||
} | ||
} |
94 changes: 94 additions & 0 deletions
94
library/src/main/java/org/xmtp/android/library/frames/FramesClient.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
} | ||
} |
6 changes: 6 additions & 0 deletions
6
library/src/main/java/org/xmtp/android/library/frames/FramesConstants.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
103
library/src/main/java/org/xmtp/android/library/frames/FramesTypes.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
) |
27 changes: 27 additions & 0 deletions
27
library/src/main/java/org/xmtp/android/library/frames/OpenFramesProxy.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
} | ||
} | ||
} |
73 changes: 73 additions & 0 deletions
73
library/src/main/java/org/xmtp/android/library/frames/ProxyClient.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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")}" | ||
} | ||
} |