From 9b1c799bcfab9117e075a99a8669e6575e2fba97 Mon Sep 17 00:00:00 2001 From: Jacob Persson <7156+typfel@users.noreply.github.com> Date: Wed, 19 Nov 2025 13:20:19 +0100 Subject: [PATCH 01/17] chore: add scaffolding for android interop project --- crypto-ffi/bindings/android/build.gradle.kts | 2 +- crypto-ffi/bindings/build.gradle.kts | 1 + crypto-ffi/bindings/gradle/libs.versions.toml | 4 +- crypto-ffi/bindings/settings.gradle.kts | 2 +- .../src/clients/android-interop/.gitignore | 2 + .../clients/android-interop/build.gradle.kts | 43 +++ .../src/main/AndroidManifest.xml | 19 ++ .../com/wire/androidinterop/MainActivity.kt | 13 + interop/src/clients/build.gradle.kts | 6 + interop/src/clients/gradle.properties | 19 ++ interop/src/clients/gradle/libs.versions.toml | 23 ++ .../clients/gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 45457 bytes .../gradle/wrapper/gradle-wrapper.properties | 7 + interop/src/clients/gradlew | 251 ++++++++++++++++++ interop/src/clients/gradlew.bat | 94 +++++++ interop/src/clients/settings.gradle.kts | 25 ++ 16 files changed, 508 insertions(+), 3 deletions(-) create mode 100644 interop/src/clients/android-interop/.gitignore create mode 100644 interop/src/clients/android-interop/build.gradle.kts create mode 100644 interop/src/clients/android-interop/src/main/AndroidManifest.xml create mode 100644 interop/src/clients/android-interop/src/main/java/com/wire/androidinterop/MainActivity.kt create mode 100644 interop/src/clients/build.gradle.kts create mode 100644 interop/src/clients/gradle.properties create mode 100644 interop/src/clients/gradle/libs.versions.toml create mode 100644 interop/src/clients/gradle/wrapper/gradle-wrapper.jar create mode 100644 interop/src/clients/gradle/wrapper/gradle-wrapper.properties create mode 100755 interop/src/clients/gradlew create mode 100644 interop/src/clients/gradlew.bat create mode 100644 interop/src/clients/settings.gradle.kts 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..24dab2bc21 100644 --- a/crypto-ffi/bindings/build.gradle.kts +++ b/crypto-ffi/bindings/build.gradle.kts @@ -12,6 +12,7 @@ buildscript { } } +// Top-level build file where you can add configuration options common to all sub-projects/modules. plugins { 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..3db8971868 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.1" [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..58d9a4f182 --- /dev/null +++ b/interop/src/clients/android-interop/build.gradle.kts @@ -0,0 +1,43 @@ +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.compose) +} + +android { + namespace = "com.wire.androidinterop" + compileSdk = 36 + + defaultConfig { + applicationId = "com.wire.androidinterop" + minSdk = 30 + targetSdk = 36 + versionCode = 1 + versionName = "1.0" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + kotlinOptions { + jvmTarget = "11" + } + buildFeatures { + compose = true + } +} + +dependencies { + implementation("core-crypto-kotlin:android") + implementation(libs.ktxSerialization) + implementation(libs.androidx.activity.compose) +} 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..867f5a99c7 --- /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/MainActivity.kt b/interop/src/clients/android-interop/src/main/java/com/wire/androidinterop/MainActivity.kt new file mode 100644 index 0000000000..774374245d --- /dev/null +++ b/interop/src/clients/android-interop/src/main/java/com/wire/androidinterop/MainActivity.kt @@ -0,0 +1,13 @@ +package com.wire.androidinterop + +import android.os.Bundle +import androidx.activity.ComponentActivity +import kotlinx.coroutines.runBlocking +import kotlinx.serialization.json.Json + +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + } +} + diff --git a/interop/src/clients/build.gradle.kts b/interop/src/clients/build.gradle.kts new file mode 100644 index 0000000000..5c98ad09e7 --- /dev/null +++ b/interop/src/clients/build.gradle.kts @@ -0,0 +1,6 @@ +// 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 + alias(libs.plugins.kotlin.compose) apply false +} diff --git a/interop/src/clients/gradle.properties b/interop/src/clients/gradle.properties new file mode 100644 index 0000000000..f37988277a --- /dev/null +++ b/interop/src/clients/gradle.properties @@ -0,0 +1,19 @@ +# Specifies the JVM arguments used for the daemon process. +# The setting is particularly useful for tweaking memory settings. +org.gradle.jvmargs=-Xmx1536m + +# AndroidX package structure to make it clearer which packages are bundled with the +# Android operating system, and which are packaged with your app's APK +# https://developer.android.com/topic/libraries/support-library/androidx-rn +android.useAndroidX=true + +# Automatically convert third-party libraries to use AndroidX +android.enableJetifier=true + +# Kotlin code style for this project: "official" or "obsolete": +kotlin.code.style=official + +# Enables namespacing of each library's R class so that its R class includes only the +# resources declared in the library itself and none from the library's dependencies, +# thereby reducing the size of the R class for that library +android.nonTransitiveRClass=true diff --git a/interop/src/clients/gradle/libs.versions.toml b/interop/src/clients/gradle/libs.versions.toml new file mode 100644 index 0000000000..8fbda74c8f --- /dev/null +++ b/interop/src/clients/gradle/libs.versions.toml @@ -0,0 +1,23 @@ +[versions] +kotlin = "2.0.21" +coroutines = "1.7.3" +android-tools = "8.12.1" +kotlin-gradle = "1.9.21" +agp = "8.12.1" +ktx-serialization = "1.8.1" +activityCompose = "1.8.0" + +[plugins] +android-application = { id = "com.android.application", version.ref = "agp" } +kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } +kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +kotlin-serialisation = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } + +[libraries] +coroutines-bom = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-bom", version.ref = "coroutines" } +coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" } +coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" } +android-tools = { module = "com.android.tools.build:gradle", version.ref = "android-tools" } +kotlin-gradle = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin-gradle" } +ktxSerialization = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "ktx-serialization" } +androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } diff --git a/interop/src/clients/gradle/wrapper/gradle-wrapper.jar b/interop/src/clients/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..8bdaf60c75ab801e22807dde59e12a8735a34077 GIT binary patch literal 45457 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 c82=K%QXLKFx%@O{wJjEi6Y56o#$)Bpeg literal 0 HcmV?d00001 diff --git a/interop/src/clients/gradle/wrapper/gradle-wrapper.properties b/interop/src/clients/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000000..2a84e188b8 --- /dev/null +++ b/interop/src/clients/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.0.0-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/interop/src/clients/gradlew b/interop/src/clients/gradlew new file mode 100755 index 0000000000..ef07e0162b --- /dev/null +++ b/interop/src/clients/gradlew @@ -0,0 +1,251 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH="\\\"\\\"" + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/interop/src/clients/gradlew.bat b/interop/src/clients/gradlew.bat new file mode 100644 index 0000000000..5eed7ee845 --- /dev/null +++ b/interop/src/clients/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH= + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/interop/src/clients/settings.gradle.kts b/interop/src/clients/settings.gradle.kts new file mode 100644 index 0000000000..ee17282228 --- /dev/null +++ b/interop/src/clients/settings.gradle.kts @@ -0,0 +1,25 @@ +rootProject.name = "interop" + +pluginManagement { + repositories { + google { + content { + includeGroupByRegex("com\\.android.*") + includeGroupByRegex("com\\.google.*") + includeGroupByRegex("androidx.*") + } + } + mavenCentral() + gradlePluginPortal() + } +} +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +include(":android-interop") +includeBuild("../../../crypto-ffi/bindings") From 41ce45e1f1dc4c2d62be6cf96392a25dbb8bad0c Mon Sep 17 00:00:00 2001 From: Jacob Persson <7156+typfel@users.noreply.github.com> Date: Thu, 27 Nov 2025 16:41:00 +0100 Subject: [PATCH 02/17] feat: add android interop application The application follows the same structure as the iOS interop application. Interop commands are sent via intents and the result is printed to stdout / logcat. --- .../clients/android-interop/build.gradle.kts | 1 + .../src/main/AndroidManifest.xml | 4 +- .../com/wire/androidinterop/InteropAction.kt | 139 +++++++++++++ .../androidinterop/InteropActionHandler.kt | 194 ++++++++++++++++++ .../wire/androidinterop/InteropResponse.kt | 15 ++ .../com/wire/androidinterop/MainActivity.kt | 28 ++- 6 files changed, 379 insertions(+), 2 deletions(-) create mode 100644 interop/src/clients/android-interop/src/main/java/com/wire/androidinterop/InteropAction.kt create mode 100644 interop/src/clients/android-interop/src/main/java/com/wire/androidinterop/InteropActionHandler.kt create mode 100644 interop/src/clients/android-interop/src/main/java/com/wire/androidinterop/InteropResponse.kt diff --git a/interop/src/clients/android-interop/build.gradle.kts b/interop/src/clients/android-interop/build.gradle.kts index 58d9a4f182..1060805522 100644 --- a/interop/src/clients/android-interop/build.gradle.kts +++ b/interop/src/clients/android-interop/build.gradle.kts @@ -2,6 +2,7 @@ plugins { alias(libs.plugins.android.application) alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.compose) + alias(libs.plugins.kotlin.serialisation) } android { diff --git a/interop/src/clients/android-interop/src/main/AndroidManifest.xml b/interop/src/clients/android-interop/src/main/AndroidManifest.xml index 867f5a99c7..681a8e3021 100644 --- a/interop/src/clients/android-interop/src/main/AndroidManifest.xml +++ b/interop/src/clients/android-interop/src/main/AndroidManifest.xml @@ -6,12 +6,14 @@ android:supportsRtl="true"> - + + 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..2314ed6bf5 --- /dev/null +++ b/interop/src/clients/android-interop/src/main/java/com/wire/androidinterop/InteropActionHandler.kt @@ -0,0 +1,194 @@ +package com.wire.androidinterop + +import com.wire.crypto.ClientId +import com.wire.crypto.ConversationId +import com.wire.crypto.CoreCrypto +import com.wire.crypto.CustomConfiguration +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), CustomConfiguration(null, null)) + }.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 index 774374245d..ada0e6fa3f 100644 --- 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 @@ -1,13 +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 == null || 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"))) + } + } +} From 9f7b39a0445d88324b2828419aa91f4d1a729795 Mon Sep 17 00:00:00 2001 From: Jacob Persson <7156+typfel@users.noreply.github.com> Date: Thu, 27 Nov 2025 16:52:53 +0100 Subject: [PATCH 03/17] feat: add rust client for the android interop application. As future work it might make sense to merge the ios and android rust clients with separate drivers which abstracts the platform specific communication. --- interop/src/clients/corecrypto/android.rs | 434 ++++++++++++++++++++++ interop/src/clients/corecrypto/mod.rs | 1 + 2 files changed, 435 insertions(+) create mode 100644 interop/src/clients/corecrypto/android.rs diff --git a/interop/src/clients/corecrypto/android.rs b/interop/src/clients/corecrypto/android.rs new file mode 100644 index 0000000000..ba9e9c3721 --- /dev/null +++ b/interop/src/clients/corecrypto/android.rs @@ -0,0 +1,434 @@ +use std::{ + cell::{Cell, RefCell}, + io::{BufRead, BufReader, Read}, + process::{Child, ChildStdout, Command, Output, Stdio}, + time::Duration, +}; + +use anyhow::Result; +use base64::{Engine as _, engine::general_purpose}; +use core_crypto::{KeyPackageIn, Keypackage}; +use thiserror::Error; +use tls_codec::Deserialize; + +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(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, true).expect("Failed to launch application"); + + Self { + device, + process: application.0, + output: RefCell::new(application.1), + } + } + + fn boot_device(device: &str) -> std::io::Result { + Command::new("xcrun").args(["simctl", "boot", device]).output() + } + + fn launch_application( + device: &str, + application: &str, + boot_device: bool, + ) -> 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 + 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)) => { + if boot_device && exit_status.code() == Some(149) { + log::info!("device is shutdown, booting..."); + Self::boot_device(device)?; + return Self::launch_application(device, application, false); + } + + 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 {} --ei ciphersuite {}", + client_id_base64, 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 add_client(&self, conversation_id: &[u8], kp: &[u8]) -> Result<()> { + let cid_base64 = general_purpose::STANDARD.encode(conversation_id); + let kp_base64 = general_purpose::STANDARD.encode(kp); + let ciphersuite = CIPHERSUITE_IN_USE as u16; + self.driver + .execute(format!( + "--es action add-client --es cid {} --ei ciphersuite={} --es kp {}", + cid_base64, ciphersuite, kp_base64 + )) + .await?; + + Ok(()) + } + + 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 {} --es client {}", + cid_base64, 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 {} --es message {}", + cid_base64, 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>> { + let cid_base64 = general_purpose::STANDARD.encode(conversation_id); + let message_base64 = general_purpose::STANDARD.encode(message); + let result = self + .driver + .execute(format!( + "--es action decrypt-message --es cid {} --es message {}", + cid_base64, message_base64 + )) + .await?; + + if result == "decrypted protocol message" { + Ok(None) + } else { + let decrypted_message = general_purpose::STANDARD.decode(result)?; + Ok(Some(decrypted_message)) + } + } +} + +#[cfg(feature = "proteus")] +#[async_trait::async_trait(?Send)] +impl crate::clients::EmulatedProteusClient for CoreCryptoAndroidClient { + async fn init(&mut self) -> Result<()> { + self.driver.execute("--es action init-proteus".into()).await?; + Ok(()) + } + + async fn get_prekey(&self) -> Result> { + let prekey_last_id = self.prekey_last_id.get() + 1; + self.prekey_last_id.replace(prekey_last_id); + + let prekey_base64 = self + .driver + .execute(format!("--es action get-prekey --es id {}", prekey_last_id)) + .await?; + let prekey = general_purpose::STANDARD.decode(prekey_base64)?; + + Ok(prekey) + } + + async fn session_from_prekey(&self, session_id: &str, prekey: &[u8]) -> Result<()> { + let prekey_base64 = general_purpose::STANDARD.encode(prekey); + self.driver + .execute(format!( + "--es action session-from-prekey --es session_id {} --es prekey {}", + session_id, prekey_base64 + )) + .await?; + + Ok(()) + } + + async fn session_from_message(&self, session_id: &str, message: &[u8]) -> Result> { + let message_base64 = general_purpose::STANDARD.encode(message); + let decrypted_message_base64 = self + .driver + .execute(format!( + "--es action session-from-message --es session_id {} --es message {}", + session_id, message_base64 + )) + .await?; + let decrypted_message = general_purpose::STANDARD.decode(decrypted_message_base64)?; + + Ok(decrypted_message) + } + async fn encrypt(&self, session_id: &str, plaintext: &[u8]) -> Result> { + let plaintext_base64 = general_purpose::STANDARD.encode(plaintext); + let encrypted_message_base64 = self + .driver + .execute(format!( + "--es action encrypt-proteus --es session_id {} --es message {}", + session_id, plaintext_base64 + )) + .await?; + let encrypted_message = general_purpose::STANDARD.decode(encrypted_message_base64)?; + + Ok(encrypted_message) + } + + async fn decrypt(&self, session_id: &str, ciphertext: &[u8]) -> Result> { + let ciphertext_base64 = general_purpose::STANDARD.encode(ciphertext); + let decrypted_message_base64 = self + .driver + .execute(format!( + "--es action decrypt-proteus --es session_id {} --es message {}", + session_id, ciphertext_base64 + )) + .await?; + let decrypted_message = general_purpose::STANDARD.decode(decrypted_message_base64)?; + + Ok(decrypted_message) + } + + async fn fingerprint(&self) -> Result { + let fingerprint = self.driver.execute("--es action get-fingerprint".into()).await?; + + Ok(fingerprint) + } +} diff --git a/interop/src/clients/corecrypto/mod.rs b/interop/src/clients/corecrypto/mod.rs index 51985987d9..f18f3e05bd 100644 --- a/interop/src/clients/corecrypto/mod.rs +++ b/interop/src/clients/corecrypto/mod.rs @@ -1,3 +1,4 @@ +pub(crate) mod android; pub(crate) mod ffi; pub(crate) mod native; pub(crate) mod web; From 63708a4387191509c62bdd65ffc9e6b91afaaa24 Mon Sep 17 00:00:00 2001 From: Jacob Persson <7156+typfel@users.noreply.github.com> Date: Thu, 27 Nov 2025 16:55:49 +0100 Subject: [PATCH 04/17] feat: use the android client in interop testing --- interop/src/main.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/interop/src/main.rs b/interop/src/main.rs index 164cab269f..ed24c2d815 100644 --- a/interop/src/main.rs +++ b/interop/src/main.rs @@ -33,6 +33,11 @@ async fn create_mls_clients<'a>( web_server: &'a std::net::SocketAddr, ) -> Vec> { vec![ + Box::new( + clients::corecrypto::android::CoreCryptoAndroidClient::new() + .await + .unwrap(), + ), #[cfg(target_os = "ios")] Box::new(clients::corecrypto::ios::CoreCryptoIosClient::new().await.unwrap()), Box::new( @@ -55,6 +60,11 @@ async fn create_proteus_clients<'a>( web_server: &'a std::net::SocketAddr, ) -> Vec> { vec![ + Box::new( + clients::corecrypto::android::CoreCryptoAndroidClient::new() + .await + .unwrap(), + ), #[cfg(target_os = "ios")] Box::new(clients::corecrypto::ios::CoreCryptoIosClient::new().await.unwrap()), Box::new( From 4d1a1b77905d5550efab597d08c7af68cf3cad14 Mon Sep 17 00:00:00 2001 From: Jacob Persson <7156+typfel@users.noreply.github.com> Date: Thu, 27 Nov 2025 16:58:45 +0100 Subject: [PATCH 05/17] chore: remove no longer relevant TODOs --- interop/src/main.rs | 2 -- 1 file changed, 2 deletions(-) diff --git a/interop/src/main.rs b/interop/src/main.rs index ed24c2d815..8a5d524db7 100644 --- a/interop/src/main.rs +++ b/interop/src/main.rs @@ -21,8 +21,6 @@ const ROUNDTRIP_MSG_AMOUNT: usize = 100; const CIPHERSUITE_IN_USE: MlsCiphersuite = MlsCiphersuite::MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519; -// TODO: Add support for Android emulator. Tracking issue: WPB-9646 -// TODO: Add support for iOS emulator when on macOS. Tracking issue: WPB-9646 fn main() -> Result<()> { run_test() } From 8c9dbb1f334e026a456ba624f8efd59469bad00e Mon Sep 17 00:00:00 2001 From: Jacob Persson <7156+typfel@users.noreply.github.com> Date: Tue, 2 Dec 2025 16:04:47 +0100 Subject: [PATCH 06/17] ci: include android in the interop workflow --- .github/workflows/interop.yml | 46 +++++++++++++++++-- .github/workflows/pipeline.yml | 1 + .../clients/android-interop/build.gradle.kts | 1 + 3 files changed, 45 insertions(+), 3 deletions(-) diff --git a/.github/workflows/interop.yml b/.github/workflows/interop.yml index 76715601c3..e192a78d4b 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,50 @@ 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: setup android emulator & build android interop client + env: + RUST_LOG: interop=info + uses: reactivecircus/android-emulator-runner@v2 + with: + api-level: 32 + arch: arm64-v8a + working-directory: interop/src/clients + script: ./gradlew android-interop:installRelease + - name: 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: | + adb install interop/src/clients/android-interop/build/outputs/apk/release/android-interop-release.apk + ./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/interop/src/clients/android-interop/build.gradle.kts b/interop/src/clients/android-interop/build.gradle.kts index 1060805522..dbeebe69cb 100644 --- a/interop/src/clients/android-interop/build.gradle.kts +++ b/interop/src/clients/android-interop/build.gradle.kts @@ -23,6 +23,7 @@ android { release { isMinifyEnabled = false proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") + signingConfig = signingConfigs.getByName("debug") } } compileOptions { From 117688be3fa3e084a00827d08514c647efdc0160 Mon Sep 17 00:00:00 2001 From: Jacob Persson <7156+typfel@users.noreply.github.com> Date: Wed, 3 Dec 2025 13:12:15 +0100 Subject: [PATCH 07/17] ci: android always uses .so When I run `make android` on a macOS system I don't want to build dylib libraries because android always uses .so libraries. --- Makefile | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Makefile b/Makefile index 02860cd22a..403a1b9118 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 From 3620fe3ea919f98852d2bd65abe514bf48ddc478 Mon Sep 17 00:00:00 2001 From: Jacob Persson <7156+typfel@users.noreply.github.com> Date: Fri, 5 Dec 2025 13:30:49 +0100 Subject: [PATCH 08/17] ci: remove checkout step from the composite android package action The checkout step resets the target folder making it impossible to combine this action with other actions which download something into the target folder. --- .github/actions/make/android/package/action.yml | 1 - 1 file changed, 1 deletion(-) 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: From 97660ca24f9e09980098ccb3a05520c9014bd2fc Mon Sep 17 00:00:00 2001 From: Jacob Persson <7156+typfel@users.noreply.github.com> Date: Mon, 8 Dec 2025 13:44:39 +0100 Subject: [PATCH 09/17] chore: include android interop client files for kotlin linter --- Makefile | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/Makefile b/Makefile index 403a1b9118..f155d8df25 100644 --- a/Makefile +++ b/Makefile @@ -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 From 5b9102486be88fa795055d6a92e2b238c854813b Mon Sep 17 00:00:00 2001 From: Jacob Persson <7156+typfel@users.noreply.github.com> Date: Wed, 17 Dec 2025 22:22:42 +0100 Subject: [PATCH 10/17] fixup! chore: add scaffolding for android interop project --- crypto-ffi/bindings/build.gradle.kts | 1 + 1 file changed, 1 insertion(+) diff --git a/crypto-ffi/bindings/build.gradle.kts b/crypto-ffi/bindings/build.gradle.kts index 24dab2bc21..4af52beb12 100644 --- a/crypto-ffi/bindings/build.gradle.kts +++ b/crypto-ffi/bindings/build.gradle.kts @@ -14,6 +14,7 @@ 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 } From f920a9ef595b43f61ef9283b53736a2eb79b59d3 Mon Sep 17 00:00:00 2001 From: Jacob Persson <7156+typfel@users.noreply.github.com> Date: Wed, 17 Dec 2025 22:59:59 +0100 Subject: [PATCH 11/17] fixup! chore: add scaffolding for android interop project --- .../clients/android-interop/build.gradle.kts | 17 +++++++---------- interop/src/clients/build.gradle.kts | 1 - interop/src/clients/gradle/libs.versions.toml | 5 ++--- 3 files changed, 9 insertions(+), 14 deletions(-) diff --git a/interop/src/clients/android-interop/build.gradle.kts b/interop/src/clients/android-interop/build.gradle.kts index dbeebe69cb..45518ebfc3 100644 --- a/interop/src/clients/android-interop/build.gradle.kts +++ b/interop/src/clients/android-interop/build.gradle.kts @@ -1,7 +1,8 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + plugins { alias(libs.plugins.android.application) alias(libs.plugins.kotlin.android) - alias(libs.plugins.kotlin.compose) alias(libs.plugins.kotlin.serialisation) } @@ -15,14 +16,11 @@ android { targetSdk = 36 versionCode = 1 versionName = "1.0" - - testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } buildTypes { release { isMinifyEnabled = false - proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") signingConfig = signingConfigs.getByName("debug") } } @@ -30,16 +28,15 @@ android { sourceCompatibility = JavaVersion.VERSION_11 targetCompatibility = JavaVersion.VERSION_11 } - kotlinOptions { - jvmTarget = "11" - } - buildFeatures { - compose = true + kotlin { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_11) + } } } dependencies { implementation("core-crypto-kotlin:android") implementation(libs.ktxSerialization) - implementation(libs.androidx.activity.compose) + implementation(libs.androidx.activity) } diff --git a/interop/src/clients/build.gradle.kts b/interop/src/clients/build.gradle.kts index 5c98ad09e7..7f09f7c5a9 100644 --- a/interop/src/clients/build.gradle.kts +++ b/interop/src/clients/build.gradle.kts @@ -2,5 +2,4 @@ plugins { alias(libs.plugins.android.application) apply false alias(libs.plugins.kotlin.android) apply false - alias(libs.plugins.kotlin.compose) apply false } diff --git a/interop/src/clients/gradle/libs.versions.toml b/interop/src/clients/gradle/libs.versions.toml index 8fbda74c8f..f84901da1d 100644 --- a/interop/src/clients/gradle/libs.versions.toml +++ b/interop/src/clients/gradle/libs.versions.toml @@ -5,12 +5,11 @@ android-tools = "8.12.1" kotlin-gradle = "1.9.21" agp = "8.12.1" ktx-serialization = "1.8.1" -activityCompose = "1.8.0" +activity = "1.8.0" [plugins] android-application = { id = "com.android.application", version.ref = "agp" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } -kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } kotlin-serialisation = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } [libraries] @@ -20,4 +19,4 @@ coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", ve android-tools = { module = "com.android.tools.build:gradle", version.ref = "android-tools" } kotlin-gradle = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin-gradle" } ktxSerialization = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "ktx-serialization" } -androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } +androidx-activity = { group = "androidx.activity", name = "activity", version.ref = "activity" } From 8729cd2cea5a88334a977d08c02f1720757e91dd Mon Sep 17 00:00:00 2001 From: Jacob Persson <7156+typfel@users.noreply.github.com> Date: Wed, 17 Dec 2025 23:16:26 +0100 Subject: [PATCH 12/17] ci: safe time by merging the `build android interop client` step into running the interop --- .github/workflows/interop.yml | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/.github/workflows/interop.yml b/.github/workflows/interop.yml index e192a78d4b..a1b4820b43 100644 --- a/.github/workflows/interop.yml +++ b/.github/workflows/interop.yml @@ -93,17 +93,7 @@ jobs: -destination 'platform=iOS Simulator,name=iPhone 16 e2e-interop-test' clean build install DSTROOT=./Products ./install-interop-client.sh ${{ env.SIMULATOR }} - - name: setup android emulator & build android interop client - env: - RUST_LOG: interop=info - uses: reactivecircus/android-emulator-runner@v2 - with: - api-level: 32 - arch: arm64-v8a - working-directory: interop/src/clients - script: ./gradlew android-interop:installRelease - - - name: run e2e interop test + - name: build android interop client & run e2e interop test env: RUST_LOG: interop=info uses: reactivecircus/android-emulator-runner@v2 @@ -113,7 +103,7 @@ jobs: working-directory: . # We're not building the interop binary in release mode, so it's in `target/debug`. script: | - adb install interop/src/clients/android-interop/build/outputs/apk/release/android-interop-release.apk + (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 From 2222f81be8622f863e111f5cab0d49c580252e35 Mon Sep 17 00:00:00 2001 From: Jacob Persson <7156+typfel@users.noreply.github.com> Date: Fri, 19 Dec 2025 16:32:02 +0100 Subject: [PATCH 13/17] fixup! feat: add android interop application --- .../src/main/java/com/wire/androidinterop/MainActivity.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 index ada0e6fa3f..992923140e 100644 --- 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 @@ -16,10 +16,10 @@ class MainActivity : ComponentActivity() { println("Ready") } - override fun onNewIntent(intent: Intent?) { + override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) - if (intent == null || intent.action?.compareTo(Intent.ACTION_RUN) != 0) { + if (intent.action?.compareTo(Intent.ACTION_RUN) != 0) { return } From 1843675d1a7d52e0fdb411ed791fc7cc854f623a Mon Sep 17 00:00:00 2001 From: Jacob Persson <7156+typfel@users.noreply.github.com> Date: Fri, 19 Dec 2025 16:32:21 +0100 Subject: [PATCH 14/17] fixup! chore: add scaffolding for android interop project --- crypto-ffi/bindings/gradle/libs.versions.toml | 2 +- interop/src/clients/android-interop/build.gradle.kts | 6 +++--- .../android-interop/src/main/AndroidManifest.xml | 4 +--- interop/src/clients/gradle.properties | 8 -------- interop/src/clients/gradle/libs.versions.toml | 12 ++++++------ interop/src/clients/settings.gradle.kts | 8 +------- 6 files changed, 12 insertions(+), 28 deletions(-) diff --git a/crypto-ffi/bindings/gradle/libs.versions.toml b/crypto-ffi/bindings/gradle/libs.versions.toml index 3db8971868..4e34270a17 100644 --- a/crypto-ffi/bindings/gradle/libs.versions.toml +++ b/crypto-ffi/bindings/gradle/libs.versions.toml @@ -14,7 +14,7 @@ vanniktech-publish = "0.34.0" kotlin-gradle = "1.9.21" dokka = "2.0.0" detekt = "1.23.8" -agp = "8.12.1" +agp = "8.12.3" [plugins] android-library = { id = "com.android.library", version.ref = "agp" } diff --git a/interop/src/clients/android-interop/build.gradle.kts b/interop/src/clients/android-interop/build.gradle.kts index 45518ebfc3..b39a5554ff 100644 --- a/interop/src/clients/android-interop/build.gradle.kts +++ b/interop/src/clients/android-interop/build.gradle.kts @@ -25,12 +25,12 @@ android { } } compileOptions { - sourceCompatibility = JavaVersion.VERSION_11 - targetCompatibility = JavaVersion.VERSION_11 + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 } kotlin { compilerOptions { - jvmTarget.set(JvmTarget.JVM_11) + jvmTarget.set(JvmTarget.JVM_17) } } } diff --git a/interop/src/clients/android-interop/src/main/AndroidManifest.xml b/interop/src/clients/android-interop/src/main/AndroidManifest.xml index 681a8e3021..11c0a3dd3a 100644 --- a/interop/src/clients/android-interop/src/main/AndroidManifest.xml +++ b/interop/src/clients/android-interop/src/main/AndroidManifest.xml @@ -1,9 +1,7 @@ - + Date: Wed, 7 Jan 2026 15:26:31 +0100 Subject: [PATCH 15/17] fixup! feat: add rust client for the android interop application. --- interop/src/clients/corecrypto/android.rs | 80 +++++++---------------- interop/src/clients/corecrypto/ios.rs | 14 ---- 2 files changed, 24 insertions(+), 70 deletions(-) diff --git a/interop/src/clients/corecrypto/android.rs b/interop/src/clients/corecrypto/android.rs index ba9e9c3721..4e99b86ece 100644 --- a/interop/src/clients/corecrypto/android.rs +++ b/interop/src/clients/corecrypto/android.rs @@ -1,15 +1,16 @@ +#[cfg(feature = "proteus")] +use std::cell::Cell; use std::{ - cell::{Cell, RefCell}, - io::{BufRead, BufReader, Read}, - process::{Child, ChildStdout, Command, Output, Stdio}, + 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 thiserror::Error; -use tls_codec::Deserialize; +use tls_codec::Deserialize as _; use crate::{ CIPHERSUITE_IN_USE, @@ -32,7 +33,7 @@ enum InteropResult { Failure { message: String }, } -#[derive(Error, Debug)] +#[derive(thiserror::Error, Debug)] #[error("simulator driver error: {msg}")] struct SimulatorDriverError { msg: String, @@ -40,7 +41,7 @@ struct SimulatorDriverError { impl SimulatorDriver { fn new(device: String, application: String) -> Self { - let application = Self::launch_application(&device, &application, true).expect("Failed to launch application"); + let application = Self::launch_application(&device, &application).expect("Failed to launch application"); Self { device, @@ -49,15 +50,7 @@ impl SimulatorDriver { } } - fn boot_device(device: &str) -> std::io::Result { - Command::new("xcrun").args(["simctl", "boot", device]).output() - } - - fn launch_application( - device: &str, - application: &str, - boot_device: bool, - ) -> Result<(Child, BufReader)> { + fn launch_application(device: &str, application: &str) -> Result<(Child, BufReader)> { log::info!("launching application: {} on {}", application, device); let activity = format!("{}/.MainActivity", application); @@ -89,6 +82,9 @@ impl SimulatorDriver { 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", @@ -117,12 +113,6 @@ impl SimulatorDriver { match process.try_wait() { Ok(None) => {} Ok(Some(exit_status)) => { - if boot_device && exit_status.code() == Some(149) { - log::info!("device is shutdown, booting..."); - Self::boot_device(device)?; - return Self::launch_application(device, application, false); - } - let mut error_message = String::new(); process .stderr @@ -216,11 +206,10 @@ impl CoreCryptoAndroidClient { .trim() .to_string(); let driver = SimulatorDriver::new(device, "com.wire.androidinterop".into()); - log::info!("initialising core crypto with ciphersuite {}", ciphersuite); + log::info!("initialising core crypto with ciphersuite {ciphersuite}"); driver .execute(format!( - "--es action init-mls --es client_id {} --ei ciphersuite {}", - client_id_base64, ciphersuite + "--es action init-mls --es client_id {client_id_base64} --ei ciphersuite {ciphersuite}" )) .await?; @@ -263,7 +252,7 @@ impl EmulatedMlsClient for CoreCryptoAndroidClient { let start = std::time::Instant::now(); let kp_base64 = self .driver - .execute(format!("--es action get-key-package --ei ciphersuite {}", ciphersuite)) + .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(); @@ -279,27 +268,12 @@ impl EmulatedMlsClient for CoreCryptoAndroidClient { Ok(kp_raw) } - async fn add_client(&self, conversation_id: &[u8], kp: &[u8]) -> Result<()> { - let cid_base64 = general_purpose::STANDARD.encode(conversation_id); - let kp_base64 = general_purpose::STANDARD.encode(kp); - let ciphersuite = CIPHERSUITE_IN_USE as u16; - self.driver - .execute(format!( - "--es action add-client --es cid {} --ei ciphersuite={} --es kp {}", - cid_base64, ciphersuite, kp_base64 - )) - .await?; - - Ok(()) - } - 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 {} --es client {}", - cid_base64, client_id_base64 + "--es action remove-client --es cid {cid_base64} --es client {client_id_base64}" )) .await?; @@ -310,7 +284,7 @@ impl EmulatedMlsClient for CoreCryptoAndroidClient { let welcome_base64 = general_purpose::STANDARD.encode(welcome); let conversation_id_base64 = self .driver - .execute(format!("--es action process-welcome --es welcome {}", welcome_base64)) + .execute(format!("--es action process-welcome --es welcome {welcome_base64}")) .await?; let conversation_id = general_purpose::STANDARD.decode(conversation_id_base64)?; @@ -323,8 +297,7 @@ impl EmulatedMlsClient for CoreCryptoAndroidClient { let encrypted_message_base64 = self .driver .execute(format!( - "--es action encrypt-message --es cid {} --es message {}", - cid_base64, message_base64 + "--es action encrypt-message --es cid {cid_base64} --es message {message_base64}" )) .await?; let encrypted_message = general_purpose::STANDARD.decode(encrypted_message_base64)?; @@ -338,8 +311,7 @@ impl EmulatedMlsClient for CoreCryptoAndroidClient { let result = self .driver .execute(format!( - "--es action decrypt-message --es cid {} --es message {}", - cid_base64, message_base64 + "--es action decrypt-message --es cid {cid_base64} --es message {message_base64}" )) .await?; @@ -366,7 +338,7 @@ impl crate::clients::EmulatedProteusClient for CoreCryptoAndroidClient { let prekey_base64 = self .driver - .execute(format!("--es action get-prekey --es id {}", prekey_last_id)) + .execute(format!("--es action get-prekey --es id {prekey_last_id}")) .await?; let prekey = general_purpose::STANDARD.decode(prekey_base64)?; @@ -377,8 +349,7 @@ impl crate::clients::EmulatedProteusClient for CoreCryptoAndroidClient { let prekey_base64 = general_purpose::STANDARD.encode(prekey); self.driver .execute(format!( - "--es action session-from-prekey --es session_id {} --es prekey {}", - session_id, prekey_base64 + "--es action session-from-prekey --es session_id {session_id} --es prekey {prekey_base64}" )) .await?; @@ -390,8 +361,7 @@ impl crate::clients::EmulatedProteusClient for CoreCryptoAndroidClient { let decrypted_message_base64 = self .driver .execute(format!( - "--es action session-from-message --es session_id {} --es message {}", - session_id, message_base64 + "--es action session-from-message --es session_id {session_id} --es message {message_base64}" )) .await?; let decrypted_message = general_purpose::STANDARD.decode(decrypted_message_base64)?; @@ -403,8 +373,7 @@ impl crate::clients::EmulatedProteusClient for CoreCryptoAndroidClient { let encrypted_message_base64 = self .driver .execute(format!( - "--es action encrypt-proteus --es session_id {} --es message {}", - session_id, plaintext_base64 + "--es action encrypt-proteus --es session_id {session_id} --es message {plaintext_base64}" )) .await?; let encrypted_message = general_purpose::STANDARD.decode(encrypted_message_base64)?; @@ -417,8 +386,7 @@ impl crate::clients::EmulatedProteusClient for CoreCryptoAndroidClient { let decrypted_message_base64 = self .driver .execute(format!( - "--es action decrypt-proteus --es session_id {} --es message {}", - session_id, ciphertext_base64 + "--es action decrypt-proteus --es session_id {session_id} --es message {ciphertext_base64}" )) .await?; let decrypted_message = general_purpose::STANDARD.decode(decrypted_message_base64)?; diff --git a/interop/src/clients/corecrypto/ios.rs b/interop/src/clients/corecrypto/ios.rs index 3d8c6923e7..fa0465382d 100644 --- a/interop/src/clients/corecrypto/ios.rs +++ b/interop/src/clients/corecrypto/ios.rs @@ -231,20 +231,6 @@ impl EmulatedMlsClient for CoreCryptoIosClient { Ok(kp_raw) } - async fn add_client(&self, conversation_id: &[u8], kp: &[u8]) -> Result<()> { - let cid_base64 = general_purpose::STANDARD.encode(conversation_id); - let kp_base64 = general_purpose::STANDARD.encode(kp); - let ciphersuite = CIPHERSUITE_IN_USE as u16; - self.driver - .execute(format!( - "add-client?cid={}&ciphersuite={}&kp={}", - cid_base64, ciphersuite, kp_base64 - )) - .await?; - - Ok(()) - } - 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); From 790a8e4b441b0737783672eecd856d63ccfe0a6d Mon Sep 17 00:00:00 2001 From: Jacob Persson <7156+typfel@users.noreply.github.com> Date: Wed, 7 Jan 2026 16:58:50 +0100 Subject: [PATCH 16/17] fixup! feat: add android interop application --- .../main/java/com/wire/androidinterop/InteropActionHandler.kt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) 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 index 2314ed6bf5..f59cb51fca 100644 --- 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 @@ -3,7 +3,6 @@ package com.wire.androidinterop import com.wire.crypto.ClientId import com.wire.crypto.ConversationId import com.wire.crypto.CoreCrypto -import com.wire.crypto.CustomConfiguration import com.wire.crypto.DatabaseKey import com.wire.crypto.Keypackage import com.wire.crypto.Welcome @@ -93,7 +92,7 @@ class InteropActionHandler(val coreCrypto: CoreCrypto) { is InteropAction.MLS.ProcessWelcome -> { coreCrypto.transaction { context -> - context.processWelcomeMessage(Welcome(action.welcome), CustomConfiguration(null, null)) + context.processWelcomeMessage(Welcome(action.welcome)) }.let { Result.success(Base64.Default.encode(it.id.copyBytes())) } From 13313410023503cae93af4a0d2aaaa7f90d24576 Mon Sep 17 00:00:00 2001 From: Jacob Persson <7156+typfel@users.noreply.github.com> Date: Thu, 8 Jan 2026 14:33:00 +0100 Subject: [PATCH 17/17] fixup! chore: add scaffolding for android interop project --- interop/src/clients/android-interop/build.gradle.kts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/interop/src/clients/android-interop/build.gradle.kts b/interop/src/clients/android-interop/build.gradle.kts index b39a5554ff..0f65b6c884 100644 --- a/interop/src/clients/android-interop/build.gradle.kts +++ b/interop/src/clients/android-interop/build.gradle.kts @@ -29,9 +29,7 @@ android { targetCompatibility = JavaVersion.VERSION_17 } kotlin { - compilerOptions { - jvmTarget.set(JvmTarget.JVM_17) - } + jvmToolchain(17) } }