Skip to content

Commit

Permalink
V3 Frames support (#320)
Browse files Browse the repository at this point in the history
* remove v1 and v2 conversations

* update all the tests to compile

* remove decrypted messages and only use decoded

* remove the ability to create a v2 client entirely

* remove contacts and rename to preferences

* remove a bunch of classes no longer needed

* get all the tests compiling

* fix up the wallet address

* more clean up

* update the example to be v3 only

* bring back the frames signer

* add back the test

* update the frames client to use the new signer

* try and get it working

* frames client and inboxId

* try and get closer on frames validation

* all tests passing

* fix up the lint

* fix the time stamp

* upgrade wallet connect

* remove example lint
  • Loading branch information
nplasterer authored Dec 19, 2024
1 parent de8949b commit 2d294af
Show file tree
Hide file tree
Showing 5 changed files with 174 additions and 14 deletions.
9 changes: 0 additions & 9 deletions .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,12 +36,3 @@ jobs:
with:
name: lint
path: 'app/build/reports/library/lint-results-**.html'

- name: Gradle Android lint example
run: ./gradlew :example:lintDebug
- uses: actions/upload-artifact@v3
name: Upload example lint report
if: ${{ failure() }}
with:
name: lint
path: 'app/build/reports/example/lint-results-**.html'
2 changes: 1 addition & 1 deletion library/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ dependencies {
implementation 'org.web3j:crypto:4.9.4'
implementation "net.java.dev.jna:jna:5.14.0@aar"
api 'com.google.protobuf:protobuf-kotlin-lite:3.22.3'
api 'org.xmtp:proto-kotlin:3.71.0'
api 'org.xmtp:proto-kotlin:3.72.3'

testImplementation 'junit:junit:4.13.2'
testImplementation 'androidx.test:monitor:1.7.2'
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
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/03710836-bc1d-4921-9e24-89d82015c53b?env=dev"
val fixtures = fixtures(ClientOptions.Api(XMTPEnvironment.DEV, isSecure = true))
val framesClient = FramesClient(xmtpClient = fixtures.alixClient)
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
Expand Up @@ -142,11 +142,11 @@ class FakeSCWWallet : SigningKey {
}
}

class Fixtures {
class Fixtures(api: ClientOptions.Api = ClientOptions.Api(XMTPEnvironment.LOCAL, isSecure = false)) {
val key = SecureRandom().generateSeed(32)
val context = InstrumentationRegistry.getInstrumentation().targetContext
val clientOptions = ClientOptions(
ClientOptions.Api(XMTPEnvironment.LOCAL, isSecure = false),
api,
dbEncryptionKey = key,
appContext = context,
)
Expand All @@ -167,5 +167,5 @@ class Fixtures {
runBlocking { Client().create(account = caroAccount, options = clientOptions) }
}

fun fixtures(): Fixtures =
Fixtures()
fun fixtures(api: ClientOptions.Api = ClientOptions.Api(XMTPEnvironment.LOCAL, isSecure = false)): Fixtures =
Fixtures(api)
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
package org.xmtp.android.library.frames

import android.util.Base64
import com.google.protobuf.kotlin.toByteString
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.hexToByteArray
import org.xmtp.android.library.toHex
import org.xmtp.proto.message.contents.Frames.FrameAction
import org.xmtp.proto.message.contents.Frames.FrameActionBody
import java.security.MessageDigest
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
val frameActionBuilder = FrameActionBody.newBuilder().also { frame ->
frame.frameUrl = frameUrl
frame.buttonIndex = buttonIndex
frame.opaqueConversationIdentifier = opaqueConversationIdentifier
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 fun signDigest(message: String): ByteArray {
return xmtpClient.signWithInstallationKey(message)
}

private fun buildSignedFrameAction(actionBodyInputs: FrameActionBody): ByteArray {
val digest = sha256(actionBodyInputs.toByteArray()).toHex()
val signature = signDigest(digest)

val frameAction = FrameAction.newBuilder().also {
it.actionBody = actionBodyInputs.toByteString()
it.installationSignature = signature.toByteString()
it.installationId = xmtpClient.installationId.hexToByteArray().toByteString()
it.inboxId = xmtpClient.inboxId
}.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("")
val digest = sha256(combined.toByteArray())
Base64.encodeToString(digest, Base64.NO_WRAP)
}
}
}

private fun sha256(input: ByteArray): ByteArray {
val digest = MessageDigest.getInstance("SHA-256")
return digest.digest(input)
}
}

0 comments on commit 2d294af

Please sign in to comment.