diff --git a/.github/actions/make/android/package/action.yml b/.github/actions/make/android/package/action.yml
index 35f5644ee2..aaa6f340b1 100644
--- a/.github/actions/make/android/package/action.yml
+++ b/.github/actions/make/android/package/action.yml
@@ -6,7 +6,6 @@ inputs:
runs:
using: composite
steps:
- - uses: actions/checkout@v5
- name: download armv7-linux-androideabi binaries
uses: ./.github/actions/make/android/lib
with:
diff --git a/.github/workflows/interop.yml b/.github/workflows/interop.yml
index 76715601c3..a1b4820b43 100644
--- a/.github/workflows/interop.yml
+++ b/.github/workflows/interop.yml
@@ -13,6 +13,9 @@ jobs:
if: github.event_name == 'pull_request'
runs-on: self-hosted
+ env:
+ ANDROID_NDK_VERSION: 28.1.13356709
+
steps:
- uses: actions/checkout@v6
- name: Setup rust macOS
@@ -20,6 +23,18 @@ jobs:
with:
rustflags: ''
cache-key-prefix: e2e-interop-test
+ - uses: actions/checkout@v6
+ - name: set up jdk 17
+ uses: actions/setup-java@v5
+ with:
+ java-version: "17"
+ distribution: "adopt"
+ - name: gradle setup
+ uses: gradle/actions/setup-gradle@v5
+ - name: validate gradle wrapper
+ uses: gradle/actions/wrapper-validation@v5
+ - name: Setup Android SDK
+ uses: android-actions/setup-android@v3
- name: setup Xcode
uses: maxim-lobanov/setup-xcode@v1
with:
@@ -57,25 +72,40 @@ jobs:
with:
gh-token: ${{ secrets.GITHUB_TOKEN }}
+ - name: download android package
+ uses: ./.github/actions/make/android/package
+ with:
+ gh-token: ${{ secrets.GITHUB_TOKEN }}
+
- name: download interop test binary
uses: ./.github/actions/make/interop-build
with:
gh-token: ${{ secrets.GITHUB_TOKEN }}
- - name: create simulator
+ - name: create iOS simulator
run: |
echo "SIMULATOR=$(./scripts/create-ios-sim-device.sh "iPhone 16 e2e-interop-test")" >> $GITHUB_ENV
+
- name: build & install iOS Interop client
run: |
cd interop/src/clients/InteropClient
xcodebuild -scheme InteropClient -sdk iphonesimulator -configuration Release \
-destination 'platform=iOS Simulator,name=iPhone 16 e2e-interop-test' clean build install DSTROOT=./Products
./install-interop-client.sh ${{ env.SIMULATOR }}
- - name: run e2e interop test
+
+ - name: build android interop client & run e2e interop test
env:
RUST_LOG: interop=info
- # We're not building the interop binary in release mode, so it's in `target/debug`.
- run: ./target/debug/interop
+ uses: reactivecircus/android-emulator-runner@v2
+ with:
+ api-level: 32
+ arch: arm64-v8a
+ working-directory: .
+ # We're not building the interop binary in release mode, so it's in `target/debug`.
+ script: |
+ (cd interop/src/clients && ./gradlew android-interop:installRelease)
+ ./target/debug/interop
+
# we separate shutdown from deletion to make sure the device is always removed, even when shutdown failed
- name: delete simulator
if: always()
diff --git a/.github/workflows/pipeline.yml b/.github/workflows/pipeline.yml
index 463f6978f0..f25daedb09 100644
--- a/.github/workflows/pipeline.yml
+++ b/.github/workflows/pipeline.yml
@@ -203,6 +203,7 @@ jobs:
- build-ios
- bindings-swift
- bindings-ts
+ - package-android
- build-interop
uses: ./.github/workflows/interop.yml
diff --git a/Makefile b/Makefile
index 02860cd22a..f155d8df25 100644
--- a/Makefile
+++ b/Makefile
@@ -311,7 +311,7 @@ android-env:
@ndk_version=$$(perl -ne 's/Pkg\.Revision = // and print' $(ANDROID_NDK_HOME)/source.properties) && \
echo "Using Android NDK $${ndk_version} at $(ANDROID_NDK_HOME)"; \
-ANDROID_ARMv7 := target/armv7-linux-androideabi/$(RELEASE_MODE)/libcore_crypto_ffi.$(LIBRARY_EXTENSION)
+ANDROID_ARMv7 := target/armv7-linux-androideabi/$(RELEASE_MODE)/libcore_crypto_ffi.so
android-armv7-deps := $(RUST_SOURCES)
$(ANDROID_ARMv7): $(android-armv7-deps) | android-env
cargo rustc --locked \
@@ -323,7 +323,7 @@ $(ANDROID_ARMv7): $(android-armv7-deps) | android-env
.PHONY: android-armv7
android-armv7: $(ANDROID_ARMv7) ## Build core-crypto-ffi for armv7-linux-androideabi
-ANDROID_ARMv8 := target/aarch64-linux-android/$(RELEASE_MODE)/libcore_crypto_ffi.$(LIBRARY_EXTENSION)
+ANDROID_ARMv8 := target/aarch64-linux-android/$(RELEASE_MODE)/libcore_crypto_ffi.so
android-armv8-deps := $(RUST_SOURCES)
$(ANDROID_ARMv8): $(android-armv8-deps) | android-env
cargo rustc --locked \
@@ -335,7 +335,7 @@ $(ANDROID_ARMv8): $(android-armv8-deps) | android-env
.PHONY: android-armv8
android-armv8: $(ANDROID_ARMv8) ## Build core-crypto-ffi for aarch64-linux-android
-ANDROID_X86 := target/x86_64-linux-android/$(RELEASE_MODE)/libcore_crypto_ffi.$(LIBRARY_EXTENSION)
+ANDROID_X86 := target/x86_64-linux-android/$(RELEASE_MODE)/libcore_crypto_ffi.so
android-x86-deps := $(RUST_SOURCES)
$(ANDROID_X86): $(android-x86-deps) | android-env
# Link clang_rt.builtins statically for x86_64 Android
@@ -653,17 +653,18 @@ swift-check: $(STAMPS)/swift-check ## Lint Swift files via swift-format and swif
KT_WRAPPER = ./crypto-ffi/bindings/jvm/src/main/kotlin
KT_TESTS = ./crypto-ffi/bindings/jvm/src/test
-KT_FILES := $(shell find $(KT_WRAPPER) $(KT_TESTS) -type f -name '*.kt')
+KT_INTEROP = ./interop/src/clients/android-interop/src/main/java
+KT_FILES := $(shell find $(KT_WRAPPER) $(KT_TESTS) $(KT_INTEROP) -type f -name '*.kt')
$(STAMPS)/kotlin-fmt: $(KT_FILES)
- ktlint --format $(KT_WRAPPER) $(KT_TESTS)
+ ktlint --format $(KT_WRAPPER) $(KT_TESTS) $(KT_INTEROP)
$(TOUCH_STAMP)
.PHONY: kotlin-fmt
kotlin-fmt: $(STAMPS)/kotlin-fmt ## Format Kotlin files via ktlint
$(STAMPS)/kotlin-check: $(KT_FILES)
- ktlint $(KT_WRAPPER) $(KT_TESTS)
+ ktlint $(KT_WRAPPER) $(KT_TESTS) $(KT_INTEROP)
$(TOUCH_STAMP)
.PHONY: kotlin-check
diff --git a/crypto-ffi/bindings/android/build.gradle.kts b/crypto-ffi/bindings/android/build.gradle.kts
index d99a1e6777..f359a2d125 100644
--- a/crypto-ffi/bindings/android/build.gradle.kts
+++ b/crypto-ffi/bindings/android/build.gradle.kts
@@ -1,7 +1,7 @@
import org.gradle.api.tasks.bundling.Jar
plugins {
- id("com.android.library")
+ alias(libs.plugins.android.library)
kotlin("android")
id("com.vanniktech.maven.publish.base")
}
diff --git a/crypto-ffi/bindings/build.gradle.kts b/crypto-ffi/bindings/build.gradle.kts
index 1848b9ed9b..4af52beb12 100644
--- a/crypto-ffi/bindings/build.gradle.kts
+++ b/crypto-ffi/bindings/build.gradle.kts
@@ -12,7 +12,9 @@ buildscript {
}
}
+// Top-level build file where you can add configuration options common to all sub-projects/modules.
plugins {
+ alias(libs.plugins.android.library) apply false
id(libs.plugins.vanniktech.publish.get().pluginId) version libs.versions.vanniktech.publish
id(libs.plugins.dokka.get().pluginId) version libs.versions.dokka
}
diff --git a/crypto-ffi/bindings/gradle/libs.versions.toml b/crypto-ffi/bindings/gradle/libs.versions.toml
index c55795c9ec..4e34270a17 100644
--- a/crypto-ffi/bindings/gradle/libs.versions.toml
+++ b/crypto-ffi/bindings/gradle/libs.versions.toml
@@ -1,5 +1,5 @@
[versions]
-kotlin = "1.9.25"
+kotlin = "2.0.21"
coroutines = "1.7.3"
kotlinx-datetime = "0.6.1"
jna = "5.17.0"
@@ -14,8 +14,10 @@ vanniktech-publish = "0.34.0"
kotlin-gradle = "1.9.21"
dokka = "2.0.0"
detekt = "1.23.8"
+agp = "8.12.3"
[plugins]
+android-library = { id = "com.android.library", version.ref = "agp" }
vanniktech-publish = { id = "com.vanniktech.maven.publish", version.ref = "vanniktech-publish" }
dokka = { id = "org.jetbrains.dokka", version.ref = "dokka" }
detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt" }
diff --git a/crypto-ffi/bindings/settings.gradle.kts b/crypto-ffi/bindings/settings.gradle.kts
index 0d5cfd834c..45d9cfc660 100644
--- a/crypto-ffi/bindings/settings.gradle.kts
+++ b/crypto-ffi/bindings/settings.gradle.kts
@@ -7,4 +7,4 @@ pluginManagement {
}
}
-include("jvm", "android")
+include(":jvm", ":android")
diff --git a/interop/src/clients/android-interop/.gitignore b/interop/src/clients/android-interop/.gitignore
new file mode 100644
index 0000000000..a7747886cf
--- /dev/null
+++ b/interop/src/clients/android-interop/.gitignore
@@ -0,0 +1,2 @@
+/build
+
diff --git a/interop/src/clients/android-interop/build.gradle.kts b/interop/src/clients/android-interop/build.gradle.kts
new file mode 100644
index 0000000000..0f65b6c884
--- /dev/null
+++ b/interop/src/clients/android-interop/build.gradle.kts
@@ -0,0 +1,40 @@
+import org.jetbrains.kotlin.gradle.dsl.JvmTarget
+
+plugins {
+ alias(libs.plugins.android.application)
+ alias(libs.plugins.kotlin.android)
+ alias(libs.plugins.kotlin.serialisation)
+}
+
+android {
+ namespace = "com.wire.androidinterop"
+ compileSdk = 36
+
+ defaultConfig {
+ applicationId = "com.wire.androidinterop"
+ minSdk = 30
+ targetSdk = 36
+ versionCode = 1
+ versionName = "1.0"
+ }
+
+ buildTypes {
+ release {
+ isMinifyEnabled = false
+ signingConfig = signingConfigs.getByName("debug")
+ }
+ }
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ }
+ kotlin {
+ jvmToolchain(17)
+ }
+}
+
+dependencies {
+ implementation("core-crypto-kotlin:android")
+ implementation(libs.ktxSerialization)
+ implementation(libs.androidx.activity)
+}
diff --git a/interop/src/clients/android-interop/src/main/AndroidManifest.xml b/interop/src/clients/android-interop/src/main/AndroidManifest.xml
new file mode 100644
index 0000000000..11c0a3dd3a
--- /dev/null
+++ b/interop/src/clients/android-interop/src/main/AndroidManifest.xml
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/interop/src/clients/android-interop/src/main/java/com/wire/androidinterop/InteropAction.kt b/interop/src/clients/android-interop/src/main/java/com/wire/androidinterop/InteropAction.kt
new file mode 100644
index 0000000000..406583c66f
--- /dev/null
+++ b/interop/src/clients/android-interop/src/main/java/com/wire/androidinterop/InteropAction.kt
@@ -0,0 +1,139 @@
+package com.wire.androidinterop
+
+import android.content.Intent
+import kotlinx.serialization.Serializable
+import kotlin.io.encoding.Base64
+import kotlin.io.encoding.ExperimentalEncodingApi
+
+@Serializable
+sealed class InteropAction {
+ sealed class MLS : InteropAction() {
+ class InitMLS(val clientId: ByteArray, val ciphersuite: Int) : MLS()
+
+ class GetKeyPackage(val ciphersuite: Int) : MLS()
+
+ class AddClient(val conversationId: ByteArray, val keyPackage: ByteArray) : MLS()
+
+ class RemoveClient(val conversationId: ByteArray, val clientId: ByteArray) : MLS()
+
+ class ProcessWelcome(val welcome: ByteArray) : MLS()
+
+ class EncryptMessage(val conversationId: ByteArray, val message: ByteArray) : MLS()
+
+ class DecryptMessage(val conversationId: ByteArray, val message: ByteArray) : MLS()
+ }
+
+ sealed class Proteus : InteropAction() {
+ class InitProteus : Proteus()
+
+ class GetPrekey(val id: UShort) : Proteus()
+
+ class SessionFromPrekey(val sessionId: String, val prekey: ByteArray) : Proteus()
+
+ class SessionFromMessage(val sessionId: String, val message: ByteArray) : Proteus()
+
+ class EncryptProteusMessage(val sessionId: String, val message: ByteArray) : Proteus()
+
+ class DecryptProteusMessage(val sessionId: String, val message: ByteArray) : Proteus()
+
+ class GetProteusFingerprint() : Proteus()
+ }
+
+ companion object {
+ @OptIn(ExperimentalEncodingApi::class)
+ fun fromIntent(intent: Intent): InteropAction {
+ return when (intent.getStringExtra("action")) {
+ "init-mls" -> {
+ val clientId = intent.getStringExtra("client_id") ?: throw IllegalArgumentException("client_id is missing")
+ val ciphersuite = intent.getIntExtra("ciphersuite", 0)
+
+ MLS.InitMLS(clientId = Base64.Default.decode(clientId), ciphersuite = ciphersuite)
+ }
+
+ "get-key-package" -> {
+ val ciphersuite = intent.getIntExtra("ciphersuite", 0)
+ MLS.GetKeyPackage(ciphersuite = ciphersuite)
+ }
+
+ "add-client" -> {
+ val conversationId = intent.getStringExtra("cid") ?: throw IllegalArgumentException("conversation_id is missing")
+ val keyPackage = intent.getStringExtra("kp") ?: throw IllegalArgumentException("key_package is missing")
+
+ MLS.AddClient(conversationId = Base64.Default.decode(conversationId), keyPackage = Base64.Default.decode(keyPackage))
+ }
+
+ "remove-client" -> {
+ val conversationId = intent.getStringExtra("cid") ?: throw IllegalArgumentException("conversation_id is missing")
+ val clientId = intent.getStringExtra("client_id") ?: throw IllegalArgumentException("client_id is missing")
+
+ MLS.RemoveClient(conversationId = Base64.Default.decode(conversationId), clientId = Base64.Default.decode(clientId))
+ }
+
+ "process-welcome" -> {
+ val welcome = intent.getStringExtra("welcome") ?: throw IllegalArgumentException("welcome is missing")
+
+ MLS.ProcessWelcome(Base64.Default.decode(welcome))
+ }
+
+ "encrypt-message" -> {
+ val conversationId = intent.getStringExtra("cid") ?: throw IllegalArgumentException("conversation_id is missing")
+ val message = intent.getStringExtra("message") ?: throw IllegalArgumentException("message is missing")
+
+ MLS.EncryptMessage(Base64.Default.decode(conversationId), Base64.Default.decode(message))
+ }
+
+ "decrypt-message" -> {
+ val conversationId = intent.getStringExtra("cid") ?: throw IllegalArgumentException("conversation_id is missing")
+ val message = intent.getStringExtra("message") ?: throw IllegalArgumentException("message is missing")
+
+ MLS.DecryptMessage(Base64.Default.decode(conversationId), Base64.Default.decode(message))
+ }
+
+ "init-proteus" -> {
+ Proteus.InitProteus()
+ }
+
+ "get-prekey" -> {
+ val id = intent.getIntExtra("id", 0).toUShort()
+ Proteus.GetPrekey(id)
+ }
+
+ "session-from-prekey" -> {
+ val sessionId = intent.getStringExtra("session_id") ?: throw IllegalArgumentException("session_id is missing")
+ val prekey = intent.getStringExtra("prekey") ?: throw IllegalArgumentException("prekey is missing")
+
+ Proteus.SessionFromPrekey(sessionId = sessionId, prekey = Base64.Default.decode(prekey))
+ }
+
+ "session-from-message" -> {
+ val sessionId = intent.getStringExtra("session_id") ?: throw IllegalArgumentException("session_id is missing")
+ val message = intent.getStringExtra("message") ?: throw IllegalArgumentException("message is missing")
+
+ Proteus.SessionFromMessage(sessionId = sessionId, message = Base64.Default.decode(message))
+ }
+
+ "encrypt-proteus" -> {
+ val sessionId = intent.getStringExtra("session_id") ?: throw IllegalArgumentException("session_id is missing")
+ val message = intent.getStringExtra("message") ?: throw IllegalArgumentException("message is missing")
+
+ Proteus.EncryptProteusMessage(sessionId = sessionId, message = Base64.Default.decode(message))
+ }
+
+ "decrypt-proteus" -> {
+ val sessionId = intent.getStringExtra("session_id") ?: throw IllegalArgumentException("session_id is missing")
+ val message = intent.getStringExtra("message") ?: throw IllegalArgumentException("message is missing")
+
+ Proteus.DecryptProteusMessage(sessionId = sessionId, message = Base64.Default.decode(message))
+ }
+
+ "get-fingerprint" -> {
+ Proteus.GetProteusFingerprint()
+ }
+
+ else -> {
+ throw IllegalArgumentException("Unknown action: ${intent.getStringExtra("action")}")
+ }
+ }
+ }
+ }
+}
diff --git a/interop/src/clients/android-interop/src/main/java/com/wire/androidinterop/InteropActionHandler.kt b/interop/src/clients/android-interop/src/main/java/com/wire/androidinterop/InteropActionHandler.kt
new file mode 100644
index 0000000000..f59cb51fca
--- /dev/null
+++ b/interop/src/clients/android-interop/src/main/java/com/wire/androidinterop/InteropActionHandler.kt
@@ -0,0 +1,193 @@
+package com.wire.androidinterop
+
+import com.wire.crypto.ClientId
+import com.wire.crypto.ConversationId
+import com.wire.crypto.CoreCrypto
+import com.wire.crypto.DatabaseKey
+import com.wire.crypto.Keypackage
+import com.wire.crypto.Welcome
+import com.wire.crypto.ciphersuiteFromU16
+import com.wire.crypto.credentialBasic
+import com.wire.crypto.openDatabase
+import java.nio.file.Files
+import java.security.SecureRandom
+import kotlin.io.encoding.Base64
+import kotlin.io.encoding.ExperimentalEncodingApi
+import kotlin.random.Random
+
+class InteropActionHandler(val coreCrypto: CoreCrypto) {
+ @OptIn(ExperimentalEncodingApi::class)
+ suspend fun handleAction(action: InteropAction): Result {
+ return when (action) {
+ is InteropAction.MLS.InitMLS -> {
+ coreCrypto.transaction({ context ->
+ context.mlsInit(
+ clientId = ClientId(action.clientId),
+ ciphersuites = listOf(ciphersuiteFromU16(action.ciphersuite.toUShort()))
+ )
+
+ context.addCredential(
+ credentialBasic(
+ clientId = ClientId(action.clientId),
+ ciphersuite = ciphersuiteFromU16(action.ciphersuite.toUShort())
+ )
+ )
+ })
+
+ return Result.success("MLS initialized")
+ }
+
+ is InteropAction.MLS.AddClient -> {
+ coreCrypto.transaction { context ->
+ context.addClientsToConversation(
+ ConversationId(action.conversationId),
+ keyPackages = listOf(
+ Keypackage(action.keyPackage)
+ )
+ )
+ }
+
+ return Result.success("Client added")
+ }
+
+ is InteropAction.MLS.RemoveClient -> {
+ coreCrypto.transaction { context ->
+ context.removeClientsFromConversation(ConversationId(action.conversationId), listOf(ClientId(action.clientId)))
+ }
+
+ return Result.success("Client removed")
+ }
+
+ is InteropAction.MLS.DecryptMessage -> {
+ coreCrypto.transaction { context ->
+ context.decryptMessage(ConversationId(bytes = action.conversationId), action.message)
+ }.message?.let {
+ return Result.success(Base64.Default.encode(it))
+ }
+ Result.success("decrypted protocol message")
+ }
+
+ is InteropAction.MLS.EncryptMessage -> {
+ coreCrypto.transaction { context ->
+ context.encryptMessage(ConversationId(action.conversationId), action.message)
+ }.let {
+ Result.success(Base64.Default.encode(it))
+ }
+ }
+
+ is InteropAction.MLS.GetKeyPackage -> {
+ coreCrypto.transaction { context ->
+ val credential = context.findCredentials(
+ ciphersuite = ciphersuiteFromU16(action.ciphersuite.toUShort()),
+ clientId = null,
+ publicKey = null,
+ credentialType = null,
+ earliestValidity = null
+ ).first()
+ context.generateKeypackage(credential, null)
+ }.let {
+ Result.success(Base64.Default.encode(it.serialize()))
+ }
+ }
+
+ is InteropAction.MLS.ProcessWelcome -> {
+ coreCrypto.transaction { context ->
+ context.processWelcomeMessage(Welcome(action.welcome))
+ }.let {
+ Result.success(Base64.Default.encode(it.id.copyBytes()))
+ }
+ }
+
+ is InteropAction.Proteus.InitProteus -> {
+ coreCrypto.transaction({ context ->
+ context.proteusInit()
+ })
+
+ return Result.success("Proteus initialized")
+ }
+
+ is InteropAction.Proteus.GetPrekey -> {
+ coreCrypto.transaction({ context ->
+ context.proteusNewPrekey(action.id)
+ }).let {
+ Result.success(Base64.Default.encode(it))
+ }
+ }
+
+ is InteropAction.Proteus.SessionFromPrekey -> {
+ coreCrypto.transaction { context ->
+ context.proteusSessionFromPrekey(
+ sessionId = action.sessionId,
+ prekey = action.prekey
+ )
+ }
+ Result.success("Session created")
+ }
+
+ is InteropAction.Proteus.SessionFromMessage -> {
+ coreCrypto.transaction { context ->
+ context.proteusSessionFromMessage(
+ sessionId = action.sessionId,
+ envelope = action.message
+ )
+ }.let {
+ Result.success(Base64.Default.encode(it))
+ }
+ }
+
+ is InteropAction.Proteus.EncryptProteusMessage -> {
+ coreCrypto.transaction({ context ->
+ context.proteusEncrypt(
+ sessionId = action.sessionId,
+ plaintext = action.message
+ )
+ }).let {
+ Result.success(Base64.Default.encode(it))
+ }
+ }
+
+ is InteropAction.Proteus.DecryptProteusMessage -> {
+ coreCrypto.transaction { context ->
+ context.proteusDecrypt(
+ sessionId = action.sessionId,
+ ciphertext = action.message
+ )
+ }.let {
+ Result.success(Base64.Default.encode(it))
+ }
+ }
+
+ is InteropAction.Proteus.GetProteusFingerprint -> {
+ coreCrypto.transaction { context ->
+ context.proteusFingerprint()
+ }.let {
+ Result.success(it)
+ }
+ }
+ }
+ }
+
+ companion object {
+ private fun genDatabaseKey(): DatabaseKey {
+ val bytes = ByteArray(32)
+ val random = SecureRandom()
+ random.nextBytes(bytes)
+ return DatabaseKey(bytes)
+ }
+
+ private fun randomIdentifier(n: Int = 12): String {
+ val charPool: List = ('a'..'z') + ('A'..'Z') + ('0'..'9')
+ return (1..n)
+ .map { Random.nextInt(0, charPool.size).let { charPool[it] } }
+ .joinToString("")
+ }
+
+ suspend fun defaultCoreCryptoClient(): CoreCrypto {
+ val root = Files.createTempDirectory("mls").toFile()
+ val path = root.resolve("keystore-${randomIdentifier()}")
+ val database = openDatabase(path.absolutePath, key = genDatabaseKey())
+
+ return CoreCrypto(database)
+ }
+ }
+}
diff --git a/interop/src/clients/android-interop/src/main/java/com/wire/androidinterop/InteropResponse.kt b/interop/src/clients/android-interop/src/main/java/com/wire/androidinterop/InteropResponse.kt
new file mode 100644
index 0000000000..3a849d7028
--- /dev/null
+++ b/interop/src/clients/android-interop/src/main/java/com/wire/androidinterop/InteropResponse.kt
@@ -0,0 +1,15 @@
+package com.wire.androidinterop
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+@Serializable
+sealed class InteropResponse {
+ @Serializable
+ @SerialName("success")
+ public data class Success(val value: String) : InteropResponse()
+
+ @Serializable
+ @SerialName("failure")
+ public data class Failure(val message: String) : InteropResponse()
+}
diff --git a/interop/src/clients/android-interop/src/main/java/com/wire/androidinterop/MainActivity.kt b/interop/src/clients/android-interop/src/main/java/com/wire/androidinterop/MainActivity.kt
new file mode 100644
index 0000000000..992923140e
--- /dev/null
+++ b/interop/src/clients/android-interop/src/main/java/com/wire/androidinterop/MainActivity.kt
@@ -0,0 +1,39 @@
+package com.wire.androidinterop
+
+import android.content.Intent
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import kotlinx.coroutines.runBlocking
+import kotlinx.serialization.json.Json
+
+class MainActivity : ComponentActivity() {
+ val actionHandler = runBlocking {
+ InteropActionHandler(InteropActionHandler.defaultCoreCryptoClient())
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ println("Ready")
+ }
+
+ override fun onNewIntent(intent: Intent) {
+ super.onNewIntent(intent)
+
+ if (intent.action?.compareTo(Intent.ACTION_RUN) != 0) {
+ return
+ }
+
+ try {
+ val action = InteropAction.fromIntent(intent)
+ runBlocking {
+ actionHandler.handleAction(action)
+ .onSuccess { println(Json.encodeToString(InteropResponse.serializer(), InteropResponse.Success(it))) }
+ .onFailure {
+ println(Json.encodeToString(InteropResponse.serializer(), InteropResponse.Failure(it.message ?: "Unknown error")))
+ }
+ }
+ } catch (e: Throwable) {
+ return println(Json.encodeToString(InteropResponse.serializer(), InteropResponse.Failure(e.message ?: "Unknown error")))
+ }
+ }
+}
diff --git a/interop/src/clients/build.gradle.kts b/interop/src/clients/build.gradle.kts
new file mode 100644
index 0000000000..7f09f7c5a9
--- /dev/null
+++ b/interop/src/clients/build.gradle.kts
@@ -0,0 +1,5 @@
+// Top-level build file where you can add configuration options common to all sub-projects/modules.
+plugins {
+ alias(libs.plugins.android.application) apply false
+ alias(libs.plugins.kotlin.android) apply false
+}
diff --git a/interop/src/clients/corecrypto/android.rs b/interop/src/clients/corecrypto/android.rs
new file mode 100644
index 0000000000..4e99b86ece
--- /dev/null
+++ b/interop/src/clients/corecrypto/android.rs
@@ -0,0 +1,402 @@
+#[cfg(feature = "proteus")]
+use std::cell::Cell;
+use std::{
+ cell::RefCell,
+ io::{BufRead as _, BufReader, Read as _},
+ process::{Child, ChildStdout, Command, Stdio},
+ time::Duration,
+};
+
+use anyhow::Result;
+use base64::{Engine as _, engine::general_purpose};
+use core_crypto::{KeyPackageIn, Keypackage};
+use tls_codec::Deserialize as _;
+
+use crate::{
+ CIPHERSUITE_IN_USE,
+ clients::{EmulatedClient, EmulatedClientProtocol, EmulatedClientType, EmulatedMlsClient},
+};
+
+#[derive(Debug)]
+struct SimulatorDriver {
+ device: String,
+ process: Child,
+ output: RefCell>,
+}
+
+#[derive(Debug, serde::Deserialize)]
+#[serde(tag = "type")]
+enum InteropResult {
+ #[serde(rename = "success")]
+ Success { value: String },
+ #[serde(rename = "failure")]
+ Failure { message: String },
+}
+
+#[derive(thiserror::Error, Debug)]
+#[error("simulator driver error: {msg}")]
+struct SimulatorDriverError {
+ msg: String,
+}
+
+impl SimulatorDriver {
+ fn new(device: String, application: String) -> Self {
+ let application = Self::launch_application(&device, &application).expect("Failed to launch application");
+
+ Self {
+ device,
+ process: application.0,
+ output: RefCell::new(application.1),
+ }
+ }
+
+ fn launch_application(device: &str, application: &str) -> Result<(Child, BufReader)> {
+ log::info!("launching application: {} on {}", application, device);
+
+ let activity = format!("{}/.MainActivity", application);
+
+ log::info!("killing any existing activity of {}", application);
+ // Kill any existing activity to be in a clean state
+ Command::new("adb")
+ .args(["-s", device, "shell", "am", "force-stop", application])
+ .output()
+ .expect("Failed to launch application");
+
+ log::info!("starting {}", application);
+ // Start the interop application
+ Command::new("adb")
+ .args(["-s", device, "shell", "am", "start", "-W", "-n", activity.as_str()])
+ .output()
+ .expect("Failed to launch application");
+
+ // Retrieve the current process id of our application
+ let pidof = Command::new("adb")
+ .args(["-s", device, "shell", "pidof", "-s", application])
+ .output()
+ .expect("Failed to launch application");
+
+ let pid = String::from_utf8(pidof.stdout)
+ .expect("pidof output is not valid utf8")
+ .trim()
+ .to_string();
+ log::info!("retrieved {} pid", pid);
+
+ // Start monitoring the system output of our application
+ //
+ // without formatting (raw)
+ // only include system out and silence all other logs (System.out:I *:S)
+ let mut process = Command::new("adb")
+ .args([
+ "-s",
+ device,
+ "logcat",
+ "--pid",
+ pid.as_str(),
+ "-v",
+ "raw",
+ "System.out:I *:S",
+ ])
+ .stdout(Stdio::piped())
+ .stderr(Stdio::piped())
+ .spawn()
+ .expect("Failed to launch application");
+
+ let mut output = BufReader::new(
+ process
+ .stdout
+ .take()
+ .expect("Expected stdout to be available on child process"),
+ );
+
+ // Wait for the child process to launch or fail
+ std::thread::sleep(Duration::from_secs(3));
+ match process.try_wait() {
+ Ok(None) => {}
+ Ok(Some(exit_status)) => {
+ let mut error_message = String::new();
+ process
+ .stderr
+ .map(|mut stderr| stderr.read_to_string(&mut error_message));
+ panic!("Failed to launch application ({}): {}", exit_status, error_message)
+ }
+ Err(error) => {
+ panic!("Failed to launch application: {}", error)
+ }
+ }
+
+ log::info!("waiting for ready signal on system.out");
+
+ // Waiting for confirmation that the application has launched.
+ let mut line = String::new();
+ while !line.contains("Ready") {
+ line.clear();
+ output
+ .read_line(&mut line)
+ .expect("was expecting ready signal on stdout");
+ }
+
+ log::info!("application launched: {}", line);
+ Ok((process, output))
+ }
+
+ async fn execute(&self, action: String) -> Result {
+ let args = [
+ "-s",
+ self.device.as_str(),
+ "shell",
+ "am",
+ "start",
+ "-W",
+ "-a",
+ "android.intent.action.RUN",
+ action.as_str(),
+ ];
+
+ log::info!("adb {}", args.join(" "));
+
+ Command::new("adb")
+ .args(args)
+ .output()
+ .expect("Failed to execute action");
+
+ let mut result = String::new();
+ let mut output = self.output.try_borrow_mut()?;
+
+ output.read_line(&mut result)?;
+
+ log::info!("{}", result);
+
+ let result: InteropResult = serde_json::from_str(result.trim())?;
+
+ match result {
+ InteropResult::Success { value } => Ok(value),
+ InteropResult::Failure { message } => Err(SimulatorDriverError { msg: message }.into()),
+ }
+ }
+}
+
+impl Drop for SimulatorDriver {
+ fn drop(&mut self) {
+ self.process.kill().expect("expected child process to be killed")
+ }
+}
+
+#[derive(Debug)]
+pub(crate) struct CoreCryptoAndroidClient {
+ driver: SimulatorDriver,
+ client_id: Vec,
+ #[cfg(feature = "proteus")]
+ prekey_last_id: Cell,
+}
+
+impl CoreCryptoAndroidClient {
+ pub(crate) async fn new() -> Result {
+ let client_id = uuid::Uuid::new_v4();
+ let client_id_str = client_id.as_hyphenated().to_string();
+ let client_id_base64 = general_purpose::STANDARD.encode(client_id_str.as_str());
+ let ciphersuite = CIPHERSUITE_IN_USE as u16;
+
+ let output = Command::new("adb")
+ .args(["get-serialno"])
+ .output()
+ .expect("Failed to get connected android device");
+
+ let device = String::from_utf8(output.stdout)
+ .expect("output is not valid utf8")
+ .trim()
+ .to_string();
+ let driver = SimulatorDriver::new(device, "com.wire.androidinterop".into());
+ log::info!("initialising core crypto with ciphersuite {ciphersuite}");
+ driver
+ .execute(format!(
+ "--es action init-mls --es client_id {client_id_base64} --ei ciphersuite {ciphersuite}"
+ ))
+ .await?;
+
+ Ok(Self {
+ driver,
+ client_id: client_id.into_bytes().into(),
+ #[cfg(feature = "proteus")]
+ prekey_last_id: Cell::new(0),
+ })
+ }
+}
+
+#[async_trait::async_trait(?Send)]
+impl EmulatedClient for CoreCryptoAndroidClient {
+ fn client_name(&self) -> &str {
+ "CoreCrypto::android"
+ }
+
+ fn client_type(&self) -> EmulatedClientType {
+ EmulatedClientType::Android
+ }
+
+ fn client_id(&self) -> &[u8] {
+ self.client_id.as_slice()
+ }
+
+ fn client_protocol(&self) -> EmulatedClientProtocol {
+ EmulatedClientProtocol::MLS | EmulatedClientProtocol::PROTEUS
+ }
+
+ async fn wipe(&mut self) -> Result<()> {
+ Ok(())
+ }
+}
+
+#[async_trait::async_trait(?Send)]
+impl EmulatedMlsClient for CoreCryptoAndroidClient {
+ async fn get_keypackage(&self) -> Result> {
+ let ciphersuite = CIPHERSUITE_IN_USE as u16;
+ let start = std::time::Instant::now();
+ let kp_base64 = self
+ .driver
+ .execute(format!("--es action get-key-package --ei ciphersuite {ciphersuite}"))
+ .await?;
+ let kp_raw = general_purpose::STANDARD.decode(kp_base64)?;
+ let kp: Keypackage = KeyPackageIn::tls_deserialize(&mut kp_raw.as_slice())?.into();
+
+ log::info!(
+ "KP Init Key [took {}ms]: Client {} [{}] - {}",
+ start.elapsed().as_millis(),
+ self.client_name(),
+ hex::encode(&self.client_id),
+ hex::encode(kp.hpke_init_key()),
+ );
+
+ Ok(kp_raw)
+ }
+
+ async fn kick_client(&self, conversation_id: &[u8], client_id: &[u8]) -> Result<()> {
+ let cid_base64 = general_purpose::STANDARD.encode(conversation_id);
+ let client_id_base64 = general_purpose::STANDARD.encode(client_id);
+ self.driver
+ .execute(format!(
+ "--es action remove-client --es cid {cid_base64} --es client {client_id_base64}"
+ ))
+ .await?;
+
+ Ok(())
+ }
+
+ async fn process_welcome(&self, welcome: &[u8]) -> Result> {
+ let welcome_base64 = general_purpose::STANDARD.encode(welcome);
+ let conversation_id_base64 = self
+ .driver
+ .execute(format!("--es action process-welcome --es welcome {welcome_base64}"))
+ .await?;
+ let conversation_id = general_purpose::STANDARD.decode(conversation_id_base64)?;
+
+ Ok(conversation_id)
+ }
+
+ async fn encrypt_message(&self, conversation_id: &[u8], message: &[u8]) -> Result> {
+ let cid_base64 = general_purpose::STANDARD.encode(conversation_id);
+ let message_base64 = general_purpose::STANDARD.encode(message);
+ let encrypted_message_base64 = self
+ .driver
+ .execute(format!(
+ "--es action encrypt-message --es cid {cid_base64} --es message {message_base64}"
+ ))
+ .await?;
+ let encrypted_message = general_purpose::STANDARD.decode(encrypted_message_base64)?;
+
+ Ok(encrypted_message)
+ }
+
+ async fn decrypt_message(&self, conversation_id: &[u8], message: &[u8]) -> Result