diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 06c4ad60b..ffb7a7d3a 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -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' diff --git a/library/build.gradle b/library/build.gradle index bea9cc977..e8a4de20b 100644 --- a/library/build.gradle +++ b/library/build.gradle @@ -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' diff --git a/library/src/androidTest/java/org/xmtp/android/library/FramesTest.kt b/library/src/androidTest/java/org/xmtp/android/library/FramesTest.kt new file mode 100644 index 000000000..665e1f38f --- /dev/null +++ b/library/src/androidTest/java/org/xmtp/android/library/FramesTest.kt @@ -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") + } +} diff --git a/library/src/androidTest/java/org/xmtp/android/library/TestHelpers.kt b/library/src/androidTest/java/org/xmtp/android/library/TestHelpers.kt index 430128966..077ed6ad7 100644 --- a/library/src/androidTest/java/org/xmtp/android/library/TestHelpers.kt +++ b/library/src/androidTest/java/org/xmtp/android/library/TestHelpers.kt @@ -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, ) @@ -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) diff --git a/library/src/main/java/org/xmtp/android/library/frames/FramesClient.kt b/library/src/main/java/org/xmtp/android/library/frames/FramesClient.kt new file mode 100644 index 000000000..97b0f127f --- /dev/null +++ b/library/src/main/java/org/xmtp/android/library/frames/FramesClient.kt @@ -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) + } +}