Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 98 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -495,6 +495,104 @@ jobs:
- name: Run app unit tests
run: ./gradlew :app:testDebugUnitTest --no-daemon --stacktrace

# --------------------------------------------------------------------------
# Android: instrumented tests on a Gradle managed device (emulator, KVM).
# Runs the device-local suite (bridge layer proof, vector rejects, BLE relay
# persistence). Classes marked @RealHardware need two paired phones and the
# live fleet; they compile here and are excluded by runner argument. The app
# loads libdsm_sdk.so, so the SDK library is built first for every packaged ABI.
# --------------------------------------------------------------------------
android-instrumented-tests:
name: Android Instrumented Tests (managed device)
needs: [select]
if: needs.select.outputs.jni_android == 'true'
runs-on: ubuntu-latest
# Three cold NDK builds (all packaged ABIs) plus the managed-device boot.
timeout-minutes: 90
steps:
- uses: actions/checkout@v7

- name: Enable KVM for the managed device
run: |
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
sudo udevadm control --reload-rules
sudo udevadm trigger --name-match=kvm

- name: Set up JDK 17
uses: actions/setup-java@v5
with:
distribution: temurin
java-version: "17"
cache: gradle

- uses: dtolnay/rust-toolchain@1.98.0
with:
targets: aarch64-linux-android,armv7-linux-androideabi,x86_64-linux-android

- uses: Swatinem/rust-cache@v2
with:
workspaces: dsm_client/deterministic_state_machine
key: android-all-abis

- name: Install CI system deps
# The dsm build script compiles the protocol with prost, which needs
# protoc; every other Rust job installs it the same way.
run: |
sudo apt-get update
sudo apt-get install -y protobuf-compiler

- name: Install cargo-ndk
run: cargo install cargo-ndk --locked

- name: Accept SDK licenses (NDK and system image downloads)
run: yes | "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" --licenses > /dev/null || true

- name: Install the NDK version Gradle pins
# app/build.gradle.kts pins ndkVersion, and its CMake build uses that NDK,
# so the Rust libraries are built with the same one. The path is resolved
# in the shell: a `${{ env.* }}` expression reads only workflow-defined
# variables, never the runner image's, and evaluated to an empty
# ANDROID_NDK_HOME that cargo-ndk refused.
run: |
NDK_VERSION="$(sed -n 's/.*ndkVersion = "\([^"]*\)".*/\1/p' dsm_client/android/app/build.gradle.kts | head -1)"
test -n "$NDK_VERSION"
"$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" --install "ndk;$NDK_VERSION" > /dev/null
test -d "$ANDROID_HOME/ndk/$NDK_VERSION/toolchains/llvm/prebuilt"
echo "ANDROID_NDK_HOME=$ANDROID_HOME/ndk/$NDK_VERSION" >> "$GITHUB_ENV"

- name: Build the SDK library for every packaged ABI
# refreshDsmJniLibs (app/build.gradle.kts) refuses to package unless all
# three abiFilters ABIs have a library, so building only the emulator's
# x86_64 would fail before any test ran. This is the repo's own target.
run: make android-libs

- name: Cache Gradle caches
uses: actions/cache@v6
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: ${{ runner.os }}-gradle-${{ hashFiles('dsm_client/android/**/*.gradle*', 'dsm_client/android/**/gradle-wrapper.properties') }}
restore-keys: |
${{ runner.os }}-gradle-

- name: Run the instrumented suite on the managed device
working-directory: dsm_client/android
run: |
./gradlew :app:pixel6Api34DebugAndroidTest \
-Pandroid.testInstrumentationRunnerArguments.notAnnotation=com.dsm.wallet.RealHardware \
--no-daemon --stacktrace

- name: Upload instrumented test reports
if: always()
uses: actions/upload-artifact@v4
with:
name: android-instrumented-reports
path: |
dsm_client/android/app/build/reports/androidTests/managedDevice/
dsm_client/android/app/build/outputs/androidTest-results/managedDevice/
if-no-files-found: ignore

# --------------------------------------------------------------------------
# Coverage: Rust + frontend reports, Codecov upload, aggregate summary
# --------------------------------------------------------------------------
Expand Down
11 changes: 11 additions & 0 deletions dsm_client/android/app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,17 @@ android {
}

testOptions {
// Gradle managed device for the instrumented suite in CI (device-local
// tests only; `@RealHardware` classes are excluded by runner argument).
managedDevices {
localDevices {
create("pixel6Api34") {
device = "Pixel 6"
apiLevel = 34
systemImageSource = "aosp-atd"
}
}
}
unitTests {
isIncludeAndroidResources = true
// Many Android platform APIs in unit tests don't have real implementations; returning defaults makes tests less flaky
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Test-only environment config for the instrumented suite.
#
# The app's bundled dsm_env_config.toml is a deployment file maintained
# outside this suite; the bridge tests must not depend on it. These tests
# replicate MainActivity's startup and exercise the local bridge (codec,
# routing, identity, balances) without reaching a storage node, so the node
# entries only have to satisfy the loader's schema: at least one node, each
# with name, endpoint and register_incarnation. Loopback endpoints need
# allow_localhost, which the Android release loader otherwise refuses.
protocol = "http"
lan_ip = "127.0.0.1"
allow_localhost = true
storage_node_mode = "remote"
ports = [8080]

[[nodes]]
name = "instrumented-node-1"
endpoint = "http://127.0.0.1:8080"
register_incarnation = "INSTRUMENTED-TEST-NODE-1"
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
// SPDX-License-Identifier: Apache-2.0
package com.dsm.wallet

/**
* Marks an instrumented test that needs real hardware: two paired phones and
* the live storage fleet (the SoFi cross-device and real-hardware trade
* harnesses). CI runs the instrumented suite on a Gradle managed device with
* `notAnnotation=com.dsm.wallet.RealHardware`, so these compile in CI and run
* only from a hands-on four-phone session.
*/
@Retention(AnnotationRetention.RUNTIME)
@Target(AnnotationTarget.CLASS, AnnotationTarget.FUNCTION)
annotation class RealHardware
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ package com.dsm.wallet.bridge
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.dsm.wallet.RealHardware
import com.dsm.wallet.ui.MainActivity
import com.google.protobuf.ByteString
import java.io.File
Expand Down Expand Up @@ -248,7 +250,7 @@ class AndroidLayerProofTest {
ensureGenesis()

val messageId = 0x0102030405060708L
val requestBytes = encodeBridgeRpcRequest("hasIdentityDirect", ByteArray(0))
val requestBytes = encodeBridgeRpcRequest("getDeviceIdBin", ByteArray(0))
val framedReq = prependMessageId(messageId, requestBytes)

val framedResp = MainActivity.processBridgeRequestForTest(ctx, framedReq)
Expand All @@ -263,7 +265,7 @@ class AndroidLayerProofTest {

val id1 = 1L
val id2 = 2L
val requestBytes = encodeBridgeRpcRequest("hasIdentityDirect", ByteArray(0))
val requestBytes = encodeBridgeRpcRequest("getDeviceIdBin", ByteArray(0))

val resp1 = MainActivity.processBridgeRequestForTest(ctx, prependMessageId(id1, requestBytes))
val resp2 = MainActivity.processBridgeRequestForTest(ctx, prependMessageId(id2, requestBytes))
Expand All @@ -277,7 +279,7 @@ class AndroidLayerProofTest {
ensureGenesis()

val messageId = Long.MAX_VALUE
val requestBytes = encodeBridgeRpcRequest("hasIdentityDirect", ByteArray(0))
val requestBytes = encodeBridgeRpcRequest("getDeviceIdBin", ByteArray(0))
val framedResp = MainActivity.processBridgeRequestForTest(ctx, prependMessageId(messageId, requestBytes))

assertEquals("Max message ID must survive", messageId, readMessageId(framedResp))
Expand All @@ -290,30 +292,6 @@ class AndroidLayerProofTest {
// returns a valid BridgeRpcResponse, and the data is correct.
// =========================================================================

@Test
fun t20_method_hasIdentityDirect_beforeGenesis() {
// Fresh context may or may not have identity — just verify no crash
val requestBytes = encodeBridgeRpcRequest("hasIdentityDirect", ByteArray(0))
val framedResp = MainActivity.processBridgeRequestForTest(ctx, prependMessageId(1L, requestBytes))

assertTrue("Must get response", framedResp.size > 8)
val respBody = framedResp.copyOfRange(8, framedResp.size)
val (isSuccess, data) = BridgeEnvelopeCodec.parseEnvelopeResponse(respBody)
assertTrue("hasIdentityDirect must return success (even if false)", isSuccess)
assertEquals("Must return 1-byte boolean", 1, data.size)
assertTrue("Value must be 0 or 1", data[0] == 0.toByte() || data[0] == 1.toByte())
}

@Test
fun t21_method_hasIdentityDirect_afterGenesis() {
ensureGenesis()

val resp = callBridgeMethod("hasIdentityDirect", ByteArray(0))
assertTrue("Must be success", resp.first)
assertEquals("Must return 1 byte", 1, resp.second.size)
assertEquals("Identity must exist after genesis", 1.toByte(), resp.second[0])
}

@Test
fun t22_method_getDeviceIdBin() {
ensureGenesis()
Expand All @@ -325,10 +303,10 @@ class AndroidLayerProofTest {
}

@Test
fun t23_method_getPersistedGenesisHash() {
fun t23_method_getGenesisHashBin() {
ensureGenesis()

val resp = callBridgeMethod("getPersistedGenesisHash", ByteArray(0))
val resp = callBridgeMethod("getGenesisHashBin", ByteArray(0))
assertTrue("Must be success", resp.first)
assertEquals("Genesis hash must be 32 bytes", 32, resp.second.size)
assertFalse("Genesis hash must not be all zeros", resp.second.all { it == 0.toByte() })
Expand Down Expand Up @@ -487,14 +465,6 @@ class AndroidLayerProofTest {
// Key may be 32 or 33 bytes depending on key type, or empty if not available
}

@Test
fun t33_method_getBluetoothStatus() {
// No BLE needed — just proves the method doesn't crash
val resp = callBridgeMethod("getBluetoothStatus", ByteArray(0))
assertTrue("Must be success", resp.first)
assertEquals("Must return 1-byte boolean", 1, resp.second.size)
}

@Test
fun t34_method_getPersistedGenesisEnvelope() {
ensureGenesis()
Expand All @@ -511,13 +481,15 @@ class AndroidLayerProofTest {
// MessagePort protocol works end-to-end through the Kotlin layer.
// =========================================================================

// Claims the faucet from the live storage fleet, so it runs only on real hardware.
@RealHardware
@Test
fun t40_fullFrame_identityCheckAndBalanceFetch() {
ensureGenesis()
claimFaucet()

// Step 1: Identity check (same bytes JS would send)
val identityReq = encodeBridgeRpcRequest("hasIdentityDirect", ByteArray(0))
val identityReq = encodeBridgeRpcRequest("getDeviceIdBin", ByteArray(0))
val identityFramed = prependMessageId(1001L, identityReq)
val identityResp = MainActivity.processBridgeRequestForTest(ctx, identityFramed)

Expand All @@ -526,7 +498,7 @@ class AndroidLayerProofTest {
identityResp.copyOfRange(8, identityResp.size)
)
assertTrue("Identity must succeed", idOk)
assertEquals("Identity = true", 1.toByte(), idData[0])
assertEquals("An identity has a 32-byte device id", 32, idData.size)

// Step 2: Fetch balances (same bytes JS would send)
val balReq = encodeBridgeRpcRequest("getAllBalancesStrict", ByteArray(0))
Expand Down Expand Up @@ -563,23 +535,6 @@ class AndroidLayerProofTest {
assertTrue("ERA balance must be positive after faucet", eraBalance > 0L)
}

@Test
fun t41_fullFrame_deviceId_matchesBetweenMethods() {
ensureGenesis()

// Get device ID via getDeviceIdBin
val resp1 = callBridgeMethod("getDeviceIdBin", ByteArray(0))
val deviceId1 = resp1.second

// Get device ID via getPersistedDeviceId (alias)
val resp2 = callBridgeMethod("getPersistedDeviceId", ByteArray(0))
val deviceId2 = resp2.second

assertEquals("Both must be 32 bytes", 32, deviceId1.size)
assertEquals("Both must be 32 bytes", 32, deviceId2.size)
assertTrue("Device IDs from both methods must match", deviceId1.contentEquals(deviceId2))
}

@Test
fun t42_fullFrame_headersContainDeviceId() {
ensureGenesis()
Expand Down Expand Up @@ -617,8 +572,8 @@ class AndroidLayerProofTest {
Thread {
try {
barrier.await() // All threads start simultaneously
val resp = callBridgeMethod("hasIdentityDirect", ByteArray(0))
if (resp.first && resp.second.size == 1 && resp.second[0] == 1.toByte()) {
val resp = callBridgeMethod("getDeviceIdBin", ByteArray(0))
if (resp.first && resp.second.size == 32 && resp.second.any { it != 0.toByte() }) {
successes.incrementAndGet()
} else {
errors.incrementAndGet()
Expand Down Expand Up @@ -675,11 +630,11 @@ class AndroidLayerProofTest {
ensureGenesis()

val methods = listOf(
"hasIdentityDirect" to ByteArray(0),
"getDeviceIdBin" to ByteArray(0),
"getPersistedGenesisHash" to ByteArray(0),
"getBluetoothStatus" to ByteArray(0),
"getGenesisHashBin" to ByteArray(0),
"getSigningPublicKeyBin" to ByteArray(0),
"getTransportHeadersV3Bin" to ByteArray(0),
"getAllBalancesStrict" to ByteArray(0),
)

val threadCount = methods.size * 2
Expand Down Expand Up @@ -720,7 +675,7 @@ class AndroidLayerProofTest {
try {
barrier.await()
val msgId = (1000L + i)
val reqBytes = encodeBridgeRpcRequest("hasIdentityDirect", ByteArray(0))
val reqBytes = encodeBridgeRpcRequest("getDeviceIdBin", ByteArray(0))
val framedReq = prependMessageId(msgId, reqBytes)
val framedResp = MainActivity.processBridgeRequestForTest(ctx, framedReq)

Expand Down Expand Up @@ -822,9 +777,9 @@ class AndroidLayerProofTest {
MainActivity.processBridgeRequestForTest(ctx, prependMessageId(1L, garbage))

// Then: send valid request — bridge must still work
val resp = callBridgeMethod("hasIdentityDirect", ByteArray(0))
val resp = callBridgeMethod("getDeviceIdBin", ByteArray(0))
assertTrue("Bridge must work after error", resp.first)
assertEquals("Identity must still exist", 1.toByte(), resp.second[0])
assertEquals("Identity must still exist", 32, resp.second.size)
}

@Test
Expand All @@ -835,8 +790,8 @@ class AndroidLayerProofTest {
var successCount = 0
for (i in 0 until 100) {
try {
val resp = callBridgeMethod("hasIdentityDirect", ByteArray(0))
if (resp.first) successCount++
val resp = callBridgeMethod("getDeviceIdBin", ByteArray(0))
if (resp.first && resp.second.size == 32) successCount++
} catch (_: Throwable) {
// count as failure
}
Expand All @@ -855,13 +810,21 @@ class AndroidLayerProofTest {
// Replicate the SDK init that MainActivity does at startup:
// 1. Set storage base dir (required before AppState can persist)
Unified.initStorageBaseDir(ctx.filesDir.absolutePath.toByteArray(Charsets.UTF_8))
// 2. Copy dsm_env_config.toml from APK assets to app files dir
// 2. Install a TEST-ONLY env config (androidTest/assets) into the app
// files dir. The app's bundled dsm_env_config.toml is a deployment
// file this suite must not depend on; it is read here from the
// instrumentation APK's assets, not the app's.
val cfgFile = File(ctx.filesDir, "dsm_env_config.toml")
ctx.assets.open("dsm_env_config.toml").use { input ->
InstrumentationRegistry.getInstrumentation().context.assets
.open("dsm_env_config.instrumented.toml").use { input ->
FileOutputStream(cfgFile, false).use { out -> input.copyTo(out) }
}
// 3. Tell Rust where the config is (sets ENV_CONFIG_PATH + DSM_ALLOW_LOCALHOST)
Unified.initDsmSdk(cfgFile.absolutePath)
// 4. Initialize the SDK the way MainActivity.initDsmAndSignalReady does:
// this is the step that installs the app router behind the ingress;
// without it every routed method answers "app router not installed".
assertTrue("initSdk must install the app router", Unified.initSdk(ctx.filesDir.absolutePath))

// Canonical mnemonic-rooted Genesis v2: generate a mnemonic, then create the wallet from
// it. No storage nodes, no silicon — the BIP39 mnemonic is the sole root.
Expand Down
Loading
Loading