From 13596c6e5b6570b83085661d6935c12bce9e8cb9 Mon Sep 17 00:00:00 2001
From: Cryptskii <47649969+cryptskii@users.noreply.github.com>
Date: Sat, 26 Sep 2026 11:22:39 -0400
Subject: [PATCH 01/11] fix(ble): the radio is native's; the frontend makes no
radio decision
Advertising follows the device's identity, and native owns it. The BLE
service advertises whenever the device has an identity, derived again on
its own worker thread when the service starts or binds, when the activity
resumes, once genesis or init has produced the identity, when a Bluetooth
permission is granted and when the adapter turns on. The GATT server reads
the identity characteristic from Rust when a peer reads it.
The frontend sends no radio request. offlineSend is one wallet.sendOffline
call; it used to set the advertised identity, start advertising and
scanning, and sleep 1.5 s first, swallowing every failure, and re-advertise
when the send finished. The contacts provider started advertising on
identity readiness and on each learned address, and the wallet screen
started and stopped it on mount, visibility and unmount, so a device on any
other screen could not be reached.
Deleted: the four BLE host controls (NativeHostRequestKind 4-7 reserved),
the BLUETOOTH_PERMISSIONS host event (2 reserved), BridgeRpcRequest
ble_identity (10 reserved) with BleIdentityPayload and the
setBleIdentityForAdvertising RPC, stopBlePairingAdvertise, the caller-less
BleCoordinator.ensureBleReady, the GATT host's pushed identity value, and
PairingTestActivity, a start/stop advertising panel in the production
manifest. The pairing loop stops only its scan.
Tests: the offline send, the wallet screen's lifecycle and the contacts
provider make no radio request; six mutation controls, each red on its
named test. The native lifecycle is compile-checked; a device run shows it.
---
.../android/app/src/main/AndroidManifest.xml | 7 -
.../com/dsm/wallet/bridge/BridgeBleHandler.kt | 44 -----
.../dsm/wallet/bridge/BridgeEnvelopeCodec.kt | 6 -
.../wallet/bridge/BridgeIdentityHandler.kt | 4 +
.../com/dsm/wallet/bridge/NativeHostBridge.kt | 46 -----
.../wallet/bridge/SinglePathWebViewBridge.kt | 22 ---
.../java/com/dsm/wallet/bridge/Unified.kt | 7 -
.../com/dsm/wallet/bridge/UnifiedBleBridge.kt | 32 +---
.../dsm/wallet/bridge/ble/BleCoordinator.kt | 31 ---
.../dsm/wallet/bridge/ble/GattServerHost.kt | 77 +++-----
.../dsm/wallet/debug/PairingTestActivity.kt | 58 ------
.../wallet/service/BleBackgroundService.kt | 177 ++++++++++--------
.../java/com/dsm/wallet/ui/MainActivity.kt | 103 ++++------
.../src/bluetooth/pairing_orchestrator.rs | 11 +-
.../screens/EnhancedWalletScreen.tsx | 26 ---
.../EnhancedWalletScreen.events.test.tsx | 32 ++++
.../frontend/src/contexts/ContactsContext.tsx | 40 +---
.../__tests__/ContactsContext.radio.test.tsx | 53 ++++++
dsm_client/frontend/src/dsm/EventBridge.ts | 10 -
.../frontend/src/dsm/NativeHostBridge.ts | 22 +--
.../frontend/src/dsm/WebViewBridge/ble.ts | 73 +-------
.../frontend/src/dsm/WebViewBridge/index.ts | 5 -
.../dsm/__tests__/offlineSend.radio.test.ts | 89 +++++++++
.../dsm/__tests__/protobufPayloads.test.ts | 23 ---
dsm_client/frontend/src/dsm/transactions.ts | 29 +--
dsm_client/frontend/src/proto/dsm_app_pb.ts | 80 --------
proto/dsm_app.proto | 28 +--
specs/requirements/CONFORMANCE_GAPS.md | 8 +-
28 files changed, 383 insertions(+), 760 deletions(-)
delete mode 100644 dsm_client/android/app/src/main/java/com/dsm/wallet/debug/PairingTestActivity.kt
create mode 100644 dsm_client/frontend/src/contexts/__tests__/ContactsContext.radio.test.tsx
create mode 100644 dsm_client/frontend/src/dsm/__tests__/offlineSend.radio.test.ts
diff --git a/dsm_client/android/app/src/main/AndroidManifest.xml b/dsm_client/android/app/src/main/AndroidManifest.xml
index e9dbc699b..962463a88 100644
--- a/dsm_client/android/app/src/main/AndroidManifest.xml
+++ b/dsm_client/android/app/src/main/AndroidManifest.xml
@@ -157,13 +157,6 @@
-
-
-
diff --git a/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/BridgeBleHandler.kt b/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/BridgeBleHandler.kt
index e9a9e7066..17fdedd77 100644
--- a/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/BridgeBleHandler.kt
+++ b/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/BridgeBleHandler.kt
@@ -2,9 +2,6 @@
package com.dsm.wallet.bridge
-import android.util.Log
-import com.dsm.wallet.bridge.ble.BleCoordinator
-
internal object BridgeBleHandler {
fun requestBlePermissions() {
@@ -23,45 +20,4 @@ internal object BridgeBleHandler {
// ignore
}
}
-
- fun setBleIdentityForAdvertising(payload: ByteArray, logTag: String): ByteArray {
- // Payload is protobuf-encoded BleIdentityCharValue from Rust's
- // encodeIdentityCharValue. Decode the proto fields.
- val genesisHash: ByteArray
- val deviceId: ByteArray
- try {
- val parsed = dsm.types.proto.BleIdentityCharValue.parseFrom(payload)
- genesisHash = parsed.genesisHash.toByteArray()
- deviceId = parsed.deviceId.toByteArray()
- if (genesisHash.size != 32 || deviceId.size != 32) {
- Log.e(logTag, "setBleIdentityForAdvertising: proto field sizes wrong genesis=${genesisHash.size} device=${deviceId.size}")
- return ByteArray(0)
- }
- } catch (e: Exception) {
- Log.e(logTag, "setBleIdentityForAdvertising: failed to decode ${payload.size} bytes: ${e.message}")
- return ByteArray(0)
- }
-
- try {
- val ctx = com.dsm.wallet.ui.MainActivity.getActiveInstance()?.baseContext
- if (ctx == null) {
- Log.w(logTag, "setBleIdentityForAdvertising: no active MainActivity")
- return ByteArray(0)
- }
- val bleService = BleCoordinator.getInstance(ctx)
-
- val gattReady = bleService.ensureGattServerStarted()
- if (!gattReady) {
- Log.w(logTag, "setBleIdentityForAdvertising: GATT server not ready (permissions not granted yet)")
- bleService.setIdentityValue(genesisHash, deviceId)
- return ByteArray(0)
- }
-
- bleService.setIdentityValue(genesisHash, deviceId)
- Log.i(logTag, "setBleIdentityForAdvertising: identity injected into BLE (genesis=${genesisHash.size}B, deviceId=${deviceId.size}B)")
- } catch (t: Throwable) {
- Log.w(logTag, "setBleIdentityForAdvertising failed", t)
- }
- return ByteArray(0)
- }
}
diff --git a/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/BridgeEnvelopeCodec.kt b/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/BridgeEnvelopeCodec.kt
index 16341fd4f..5a1a19c15 100644
--- a/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/BridgeEnvelopeCodec.kt
+++ b/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/BridgeEnvelopeCodec.kt
@@ -445,7 +445,6 @@ internal object BridgeEnvelopeCodec {
6 -> parseAppRouterPayload(bytes)
8 -> parseSingleBytesPayload(bytes)
9 -> parseSingleBytesPayload(bytes)
- 10 -> parseBleIdentityPayload(bytes)
11 -> parseBilateralPayload(bytes)
else -> ByteArray(0)
}
@@ -505,11 +504,6 @@ internal object BridgeEnvelopeCodec {
return parseBytesPayload(bytes)
}
- private fun parseBleIdentityPayload(bytes: ByteArray): ByteArray {
- // Keep canonical protobuf bytes for downstream typed decoders.
- return bytes
- }
-
private fun parseBilateralPayload(bytes: ByteArray): ByteArray {
// Keep canonical protobuf bytes for downstream typed decoders.
return bytes
diff --git a/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/BridgeIdentityHandler.kt b/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/BridgeIdentityHandler.kt
index cca8489a7..0c1c79e7c 100644
--- a/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/BridgeIdentityHandler.kt
+++ b/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/BridgeIdentityHandler.kt
@@ -203,6 +203,10 @@ internal object BridgeIdentityHandler {
// The Rust route already initialized the SDK context (wallet unlocked this session).
sdkContextInitialized.set(true)
Log.i(logTag, "createGenesisV2: identity persisted + SDK context initialized")
+ // The device has an identity now; advertising follows it.
+ com.dsm.wallet.ui.MainActivity.getActiveInstance()?.let { act ->
+ act.runOnUiThread { act.startBleForIdentity() }
+ }
envelopeBytes
} catch (t: Throwable) {
Log.e(logTag, "createGenesisV2 failed", t)
diff --git a/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/NativeHostBridge.kt b/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/NativeHostBridge.kt
index 7fb15f422..77058ae4f 100644
--- a/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/NativeHostBridge.kt
+++ b/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/NativeHostBridge.kt
@@ -10,7 +10,6 @@ import android.util.Log
import androidx.core.content.ContextCompat
import com.google.protobuf.ByteString
import com.google.protobuf.InvalidProtocolBufferException
-import com.dsm.wallet.bridge.ble.BleCoordinator
import com.dsm.wallet.ui.MainActivity
import dsm.types.proto.BiometricAuthorizePayload
import dsm.types.proto.HostPermissionsRequestPayload
@@ -82,10 +81,6 @@ internal object NativeHostBridge {
.addSupportedRequests(NativeHostRequestKind.NATIVE_HOST_REQUEST_KIND_HOST_CONTROL_CAPABILITIES_GET)
.addSupportedRequests(NativeHostRequestKind.NATIVE_HOST_REQUEST_KIND_HOST_CONTROL_QR_START_SCAN)
.addSupportedRequests(NativeHostRequestKind.NATIVE_HOST_REQUEST_KIND_HOST_CONTROL_QR_STOP_SCAN)
- .addSupportedRequests(NativeHostRequestKind.NATIVE_HOST_REQUEST_KIND_HOST_CONTROL_BLE_SCAN_START)
- .addSupportedRequests(NativeHostRequestKind.NATIVE_HOST_REQUEST_KIND_HOST_CONTROL_BLE_SCAN_STOP)
- .addSupportedRequests(NativeHostRequestKind.NATIVE_HOST_REQUEST_KIND_HOST_CONTROL_BLE_ADVERTISE_START)
- .addSupportedRequests(NativeHostRequestKind.NATIVE_HOST_REQUEST_KIND_HOST_CONTROL_BLE_ADVERTISE_STOP)
.addSupportedRequests(NativeHostRequestKind.NATIVE_HOST_REQUEST_KIND_HOST_CONTROL_NFC_READER_START)
.addSupportedRequests(NativeHostRequestKind.NATIVE_HOST_REQUEST_KIND_HOST_CONTROL_NFC_READER_STOP)
.addSupportedRequests(NativeHostRequestKind.NATIVE_HOST_REQUEST_KIND_HOST_CONTROL_PERMISSIONS_REQUEST)
@@ -116,47 +111,6 @@ internal object NativeHostBridge {
okAck()
}
- NativeHostRequestKind.NATIVE_HOST_REQUEST_KIND_HOST_CONTROL_BLE_SCAN_START -> {
- val act = MainActivity.getActiveInstance()
- ?: return errorResponse(503, "BLE unavailable: no active activity")
- val ctx = act.baseContext
- val ok = BleCoordinator.getInstance(ctx).startScanning()
- Log.i(logTag, "host_control.ble.scan.start: result=$ok")
- act.runOnUiThread { act.publishCurrentSessionState("host_control.ble.scan.start") }
- okAck(ok)
- }
-
- NativeHostRequestKind.NATIVE_HOST_REQUEST_KIND_HOST_CONTROL_BLE_SCAN_STOP -> {
- val act = MainActivity.getActiveInstance()
- ?: return errorResponse(503, "BLE unavailable: no active activity")
- BleCoordinator.getInstance(act.baseContext).stopScanning()
- Log.i(logTag, "host_control.ble.scan.stop")
- act.runOnUiThread { act.publishCurrentSessionState("host_control.ble.scan.stop") }
- okAck()
- }
-
- NativeHostRequestKind.NATIVE_HOST_REQUEST_KIND_HOST_CONTROL_BLE_ADVERTISE_START -> {
- val act = MainActivity.getActiveInstance()
- ?: return errorResponse(503, "BLE unavailable: no active activity")
- val ok = BleCoordinator.getInstance(act.baseContext).startAdvertising()
- if (ok) {
- act.setBleAdvertisingDesired(true)
- }
- Log.i(logTag, "host_control.ble.advertise.start: result=$ok")
- act.runOnUiThread { act.publishCurrentSessionState("host_control.ble.advertise.start") }
- okAck(ok)
- }
-
- NativeHostRequestKind.NATIVE_HOST_REQUEST_KIND_HOST_CONTROL_BLE_ADVERTISE_STOP -> {
- val act = MainActivity.getActiveInstance()
- ?: return errorResponse(503, "BLE unavailable: no active activity")
- BleCoordinator.getInstance(act.baseContext).stopAdvertising()
- act.setBleAdvertisingDesired(false)
- Log.i(logTag, "host_control.ble.advertise.stop")
- act.runOnUiThread { act.publishCurrentSessionState("host_control.ble.advertise.stop") }
- okAck()
- }
-
NativeHostRequestKind.NATIVE_HOST_REQUEST_KIND_HOST_CONTROL_NFC_READER_START -> {
val act = MainActivity.getActiveInstance()
?: return errorResponse(503, "NFC unavailable: no active activity")
diff --git a/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/SinglePathWebViewBridge.kt b/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/SinglePathWebViewBridge.kt
index 4b49ad073..1b1a24bc4 100644
--- a/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/SinglePathWebViewBridge.kt
+++ b/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/SinglePathWebViewBridge.kt
@@ -469,28 +469,6 @@ class SinglePathWebViewBridge(private val context: Context) {
}
}
- "setBleIdentityForAdvertising" -> {
- val parsed = try {
- dsm.types.proto.BleIdentityPayload.parseFrom(payload)
- } catch (e: com.google.protobuf.InvalidProtocolBufferException) {
- Log.w(TAG, "setBleIdentityForAdvertising: invalid payload: ${e.message}")
- return ByteArray(0)
- }
- val genesisHash = parsed.genesisHash.toByteArray()
- val deviceId = parsed.deviceId.toByteArray()
- if (genesisHash.size != 32 || deviceId.size != 32) {
- Log.w(TAG, "setBleIdentityForAdvertising: invalid field lengths genesis=${genesisHash.size} device=${deviceId.size}")
- return ByteArray(0)
- }
- // Kotlin MUST NOT concatenate raw bytes — encodeIdentityCharValue is the canonical encoder.
- val out = Unified.encodeIdentityCharValue(genesisHash, deviceId)
- if (out.isEmpty()) {
- Log.w(TAG, "setBleIdentityForAdvertising: encodeIdentityCharValue returned empty")
- return ByteArray(0)
- }
- BridgeBleHandler.setBleIdentityForAdvertising(out, TAG)
- }
-
// Generic Envelope v3 processing (online transfers, DBRW export, etc.)
else -> throw IllegalArgumentException("Unknown binary RPC method: $method")
}
diff --git a/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/Unified.kt b/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/Unified.kt
index 10e78c993..1d79ad41e 100644
--- a/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/Unified.kt
+++ b/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/Unified.kt
@@ -209,13 +209,6 @@ object Unified {
return UnifiedBleBridge.stopBlePairingScan()
}
- /**
- * Stop BLE advertising. Called by Rust pairing loop on exit to prevent lingering advertise.
- */
- @Keep @JvmStatic fun stopBlePairingAdvertise(): Boolean {
- return UnifiedBleBridge.stopBlePairingAdvertise()
- }
-
// ---------- Event notifications ----------
@Keep @JvmStatic fun bleNotifyConnectionState(address: String, connected: Boolean) {
UnifiedNativeApi.bleNotifyConnectionState(address, connected)
diff --git a/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/UnifiedBleBridge.kt b/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/UnifiedBleBridge.kt
index 612b0b128..5243063d2 100644
--- a/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/UnifiedBleBridge.kt
+++ b/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/UnifiedBleBridge.kt
@@ -93,25 +93,12 @@ internal object UnifiedBleBridge {
}
}
- private fun publishLocalIdentityIfAvailable(svc: BleCoordinator): Boolean {
- try {
- val deviceIdBytes = try { Unified.getDeviceIdBin() } catch (_: Throwable) { byteArrayOf() }
- val genesisHashBytes = try { Unified.getGenesisHashBin() } catch (_: Throwable) { byteArrayOf() }
- if (deviceIdBytes.size == 32 && genesisHashBytes.size == 32) {
- svc.setIdentityValue(genesisHashBytes, deviceIdBytes)
- Log.i("UnifiedBleBridge", "publishLocalIdentityIfAvailable: local BLE identity published to GATT")
- return true
- } else {
- Log.w(
- "UnifiedBleBridge",
- "publishLocalIdentityIfAvailable: identity bytes unavailable (genesis=${genesisHashBytes.size}, device=${deviceIdBytes.size})"
- )
- return false
- }
- } catch (t: Throwable) {
- Log.w("UnifiedBleBridge", "publishLocalIdentityIfAvailable failed", t)
- return false
- }
+ // The GATT server reads the identity from Rust when a peer asks for it;
+ // advertising without one would answer every identity read with a failure.
+ private fun localIdentityAvailable(): Boolean = try {
+ Unified.getDeviceIdBin().size == 32 && Unified.getGenesisHashBin().size == 32
+ } catch (_: Throwable) {
+ false
}
fun initBleCoordinator(
@@ -136,7 +123,7 @@ internal object UnifiedBleBridge {
fun startBlePairingAdvertise(): Boolean {
val svc = bleCoordinator ?: return false
return try {
- if (!publishLocalIdentityIfAvailable(svc)) {
+ if (!localIdentityAvailable()) {
Log.w("UnifiedBleBridge", "startBlePairingAdvertise: refusing to advertise without local identity")
return false
}
@@ -154,10 +141,6 @@ internal object UnifiedBleBridge {
return try { svc.stopScanning() } catch (_: Throwable) { false }
}
- fun stopBlePairingAdvertise(): Boolean {
- val svc = bleCoordinator ?: return false
- return try { svc.stopAdvertising() } catch (_: Throwable) { false }
- }
fun requestGattWriteChunks(deviceAddress: String, chunks: Array): Boolean {
val svc = bleCoordinator ?: return false
@@ -241,7 +224,6 @@ internal object UnifiedBleBridge {
Log.i("BleTransferTrace", "requestGattWriteChunks routing: no route -> $effectiveAddr (on-demand connect)")
Log.i("UnifiedBleBridge", "requestGattWriteChunks: no route for $effectiveAddr — on-demand connect")
svc.ensureGattServerStarted()
- publishLocalIdentityIfAvailable(svc)
svc.startAdvertising()
runBlocking {
try {
diff --git a/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/ble/BleCoordinator.kt b/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/ble/BleCoordinator.kt
index ec916778f..bb9fd4939 100644
--- a/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/ble/BleCoordinator.kt
+++ b/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/ble/BleCoordinator.kt
@@ -364,37 +364,6 @@ class BleCoordinator private constructor(private val context: Context) : BleScan
}
}
- /**
- * Set local identity value for GATT server.
- */
- fun setIdentityValue(genesisHash: ByteArray, deviceId: ByteArray) {
- runOperation(BleOpLane.LIFECYCLE) {
- gattServer.setIdentityValue(genesisHash, deviceId)
- }
- }
-
- /**
- * Ensure BLE is ready to receive bilateral transfers: GATT server running
- * and advertising active. Called by the frontend wallet screen lifecycle
- * via the native host boundary, and also called internally by
- * connectToDevice as a safety net.
- */
- fun ensureBleReady(): Boolean {
- val gattReady = runOperationBool(BleOpLane.LIFECYCLE) {
- if (!gattServer.isReady()) {
- gattServer.ensureStarted()
- }
- gattServer.isReady()
- }
- // Delegate to the public advertising entry point so that
- // permissions and error handling are consistent.
- val advertisingReady = startAdvertising()
- if (!advertisingReady) {
- Log.w("BleCoordinator", "ensureBleReady: startAdvertising returned false")
- }
- return gattReady && advertisingReady
- }
-
/**
* Ensure GATT server is started.
*/
diff --git a/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/ble/GattServerHost.kt b/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/ble/GattServerHost.kt
index 89ea29eb7..ea83af733 100644
--- a/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/ble/GattServerHost.kt
+++ b/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/ble/GattServerHost.kt
@@ -78,7 +78,6 @@ class GattServerHost(private val context: Context) {
private val servicesReady = AtomicBoolean(false)
private val serviceRegistrationInProgress = AtomicBoolean(false)
@Volatile private var serviceReadyDeferred: CompletableDeferred? = null
- @Volatile private var identityValue: ByteArray? = null
// Write buffers for handling chunked writes
private val pendingTxWriteBuffers = ConcurrentHashMap()
@@ -128,7 +127,6 @@ class GattServerHost(private val context: Context) {
val success = status == BluetoothGatt.GATT_SUCCESS
if (success) {
servicesReady.set(true)
- updateIdentityCharacteristic()
Log.i("GattServerHost", "GATT service registered via onServiceAdded callback")
} else {
servicesReady.set(false)
@@ -343,16 +341,6 @@ class GattServerHost(private val context: Context) {
fun isReady(): Boolean = gattServer.get() != null && servicesReady.get()
- /** Non-suspend version: triggers service setup if needed but does not await the callback. */
- fun ensureStartedNonBlocking() {
- if (!BleCoordinator.getInstance(context).permissionsGate.hasConnectPermission()) return
- if (gattServer.get() == null) openGattServer()
- val server = gattServer.get() ?: return
- if (!servicesReady.get() && !serviceRegistrationInProgress.get()) {
- setupGattService(server)
- }
- }
-
fun stop() {
try {
gattServer.get()?.close()
@@ -366,29 +354,6 @@ class GattServerHost(private val context: Context) {
Log.i("GattServerHost", "GATT server stopped")
}
- fun getIdentityValue(): ByteArray? = identityValue?.clone()
-
- fun setIdentityValue(genesisHash: ByteArray, deviceId: ByteArray) {
- if (genesisHash.size != 32 || deviceId.size != 32) {
- Log.w("GattServerHost", "Invalid identity value lengths")
- return
- }
-
- // Encode identity as protobuf BleIdentityCharValue via Rust.
- // Kotlin MUST NOT concatenate raw bytes — Rust is the canonical encoder.
- val encoded = com.dsm.wallet.bridge.Unified.encodeIdentityCharValue(genesisHash, deviceId)
- if (encoded.isEmpty()) {
- Log.e("GattServerHost", "encodeIdentityCharValue returned empty — identity not set")
- return
- }
- identityValue = encoded
- Log.i("GattServerHost", "Identity value set (proto-encoded, ${identityValue?.size} bytes)")
-
- // Trigger GATT server setup if needed (non-blocking — doesn't await callback)
- ensureStartedNonBlocking()
- updateIdentityCharacteristic()
- }
-
/**
* Check if the given address is a device connected to our GATT server.
* These are devices that initiated a GATT client connection to us (we are their server).
@@ -750,24 +715,12 @@ class GattServerHost(private val context: Context) {
}
}
- private fun updateIdentityCharacteristic() {
- val server = gattServer.get() ?: return
- val service = server.getService(BleConstants.DSM_SERVICE_UUID_V2) ?: return
- val identityChar = service.getCharacteristic(BleConstants.IDENTITY_UUID) ?: return
-
- identityValue?.let { value ->
- @Suppress("DEPRECATION")
- identityChar.setValue(value)
- Log.d("GattServerHost", "Identity characteristic updated")
- }
- }
-
private fun handleIdentityRead(device: BluetoothDevice, requestId: Int, offset: Int) {
- val value = identityValue
- // Null identity (identity not yet published) and out-of-range offset are distinct
- // error conditions requiring different GATT status codes so the client can distinguish them.
+ val value = localIdentityCharValue()
+ // No identity (pre-genesis) and an out-of-range offset are distinct error
+ // conditions with different GATT status codes, so the client can tell them apart.
if (value == null) {
- Log.w("GattServerHost", "Identity read for ${device.address}: identity not yet set")
+ Log.w("GattServerHost", "Identity read for ${device.address}: no local identity")
try {
gattServer.get()?.sendResponse(device, requestId, BluetoothGatt.GATT_FAILURE, 0, null)
} catch (e: SecurityException) {
@@ -813,6 +766,28 @@ class GattServerHost(private val context: Context) {
}
}
+ /**
+ * The identity a peer reads, from Rust at the moment it asks: the device's
+ * own genesis and device id, encoded by Rust's canonical encoder. Nothing
+ * pushes it here — the frontend used to hand these bytes to the BLE layer,
+ * and a value set by a caller can be stale, or not the device's at all.
+ * Encoding is deterministic, so a long read's chunks agree.
+ */
+ private fun localIdentityCharValue(): ByteArray? = try {
+ val genesisHash = com.dsm.wallet.bridge.Unified.getGenesisHashBin()
+ val deviceId = com.dsm.wallet.bridge.Unified.getDeviceIdBin()
+ if (genesisHash.size == 32 && deviceId.size == 32) {
+ // Kotlin MUST NOT concatenate raw bytes — Rust is the canonical encoder.
+ com.dsm.wallet.bridge.Unified.encodeIdentityCharValue(genesisHash, deviceId)
+ .takeIf { it.isNotEmpty() }
+ } else {
+ null
+ }
+ } catch (t: Throwable) {
+ Log.w("GattServerHost", "Local identity read failed", t)
+ null
+ }
+
private fun handleRelationshipStatusRead(device: BluetoothDevice, requestId: Int, offset: Int) {
val value = try {
com.dsm.wallet.bridge.Unified.getRelationshipStatusCharValue(device.address)
diff --git a/dsm_client/android/app/src/main/java/com/dsm/wallet/debug/PairingTestActivity.kt b/dsm_client/android/app/src/main/java/com/dsm/wallet/debug/PairingTestActivity.kt
deleted file mode 100644
index 6e991d7c1..000000000
--- a/dsm_client/android/app/src/main/java/com/dsm/wallet/debug/PairingTestActivity.kt
+++ /dev/null
@@ -1,58 +0,0 @@
-// SPDX-License-Identifier: MIT OR Apache-2.0
-
-package com.dsm.wallet.debug
-
-import android.annotation.SuppressLint
-import android.os.Bundle
-import android.util.Log
-import android.widget.Button
-import androidx.appcompat.app.AppCompatActivity
-import com.dsm.wallet.bridge.ble.BleCoordinator
-
-class PairingTestActivity : AppCompatActivity() {
- private val tag = "PairingTestActivity"
- @SuppressLint("SetTextI18n")
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
- // Simple programmatic UI to avoid XML file changes
- val startAdvertBtn = Button(this).apply { text = "Start Advertise For Pairing" }
- val startScanBtn = Button(this).apply { text = "Start Scan For Pairing" }
- val stopAdvertBtn = Button(this).apply { text = "Stop Advertising" }
- val stopScanBtn = Button(this).apply { text = "Stop Scanning" }
-
- val layout = androidx.appcompat.widget.LinearLayoutCompat(this).apply {
- orientation = androidx.appcompat.widget.LinearLayoutCompat.VERTICAL
- addView(startAdvertBtn)
- addView(startScanBtn)
- addView(stopAdvertBtn)
- addView(stopScanBtn)
- }
- setContentView(layout)
-
- val bleService = BleCoordinator.getInstance(applicationContext)
-
- startAdvertBtn.setOnClickListener {
- Log.i(tag, "Requesting startAdvertising()")
- val ok = try { bleService.startAdvertising() } catch (t: Throwable) { Log.e(tag, "startAdvertising threw", t); false }
- Log.i(tag, "startAdvertising returned: $ok")
- }
-
- startScanBtn.setOnClickListener {
- Log.i(tag, "Requesting startScanning()")
- val ok = try { bleService.startScanning() } catch (t: Throwable) { Log.e(tag, "startScanning threw", t); false }
- Log.i(tag, "startScanning returned: $ok")
- }
-
- stopAdvertBtn.setOnClickListener {
- Log.i(tag, "Requesting stopAdvertising()")
- val ok = try { bleService.stopAdvertising() } catch (t: Throwable) { Log.e(tag, "stopAdvertising threw", t); false }
- Log.i(tag, "stopAdvertising returned: $ok")
- }
-
- stopScanBtn.setOnClickListener {
- Log.i(tag, "Requesting stopScanning()")
- val ok = try { bleService.stopScanning() } catch (t: Throwable) { Log.e(tag, "stopScanning threw", t); false }
- Log.i(tag, "stopScanning returned: $ok")
- }
- }
-}
diff --git a/dsm_client/android/app/src/main/java/com/dsm/wallet/service/BleBackgroundService.kt b/dsm_client/android/app/src/main/java/com/dsm/wallet/service/BleBackgroundService.kt
index 6ffa9abb9..253c03048 100644
--- a/dsm_client/android/app/src/main/java/com/dsm/wallet/service/BleBackgroundService.kt
+++ b/dsm_client/android/app/src/main/java/com/dsm/wallet/service/BleBackgroundService.kt
@@ -7,8 +7,11 @@ import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
+import android.bluetooth.BluetoothAdapter
+import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
+import android.content.IntentFilter
import android.content.pm.ServiceInfo
import android.os.Build
import android.os.Binder
@@ -16,20 +19,29 @@ import android.os.IBinder
import android.util.Log
import androidx.core.app.NotificationCompat
import com.dsm.wallet.R
+import com.dsm.wallet.bridge.Unified
import com.dsm.wallet.bridge.ble.BleCoordinator
import com.dsm.wallet.ui.MainActivity
+import java.util.concurrent.ExecutorService
+import java.util.concurrent.Executors
/**
* Foreground service that keeps BLE advertising and GATT server running
* in the background for offline bilateral transfers.
- *
+ *
* This service ensures that:
* 1. BLE advertising remains active so peers can discover this device
* 2. GATT server stays registered to receive incoming connections
* 3. Persistent connections to paired devices are maintained
- *
+ *
* Without this, offline transfers would fail when the app is backgrounded
* because Android kills BLE advertising/GATT when apps lose foreground status.
+ *
+ * It is the one owner of advertising. Advertising follows the identity: a
+ * device Rust knows advertises, so a peer can find it for a transfer or a
+ * pairing; a device without one does not. The screen the user is on has no
+ * say — the frontend used to start advertising on the wallet screen and stop
+ * it on leaving, and on a cold start nothing else ever started it.
*/
class BleBackgroundService : Service() {
@@ -60,10 +72,22 @@ class BleBackgroundService : Service() {
}
private var bleCoordinator: BleCoordinator? = null
- private var isAdvertising = false
- private var advertisingDesired = false
private val binder = LocalBinder()
+ // The coordinator's lifecycle operations block their caller (up to 15 s);
+ // they run here, one at a time and in order, never on the main thread.
+ private val lifecycle: ExecutorService =
+ Executors.newSingleThreadExecutor { r -> Thread(r, "ble-advertising") }
+
+ // Bluetooth turned back on: advertising is due again if the identity is.
+ private val adapterStateReceiver = object : BroadcastReceiver() {
+ override fun onReceive(context: Context?, intent: Intent?) {
+ if (intent?.action != BluetoothAdapter.ACTION_STATE_CHANGED) return
+ val state = intent.getIntExtra(BluetoothAdapter.EXTRA_STATE, BluetoothAdapter.ERROR)
+ if (state == BluetoothAdapter.STATE_ON) refreshAdvertising()
+ }
+ }
+
inner class LocalBinder : Binder() {
fun getService(): BleBackgroundService = this@BleBackgroundService
}
@@ -89,23 +113,35 @@ class BleBackgroundService : Service() {
// Initialize BLE coordinator
bleCoordinator = BleCoordinator.getInstance(applicationContext)
+
+ val filter = IntentFilter(BluetoothAdapter.ACTION_STATE_CHANGED)
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ registerReceiver(adapterStateReceiver, filter, Context.RECEIVER_NOT_EXPORTED)
+ } else {
+ registerReceiver(adapterStateReceiver, filter)
+ }
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
- Log.i(TAG, "BLE background service started (idle until explicitly requested)")
- // BLE advertising is NOT started here. It only starts when the UI explicitly
- // requests host-control advertising through the native host boundary.
+ Log.i(TAG, "BLE background service started")
+ refreshAdvertising()
return START_STICKY
}
override fun onDestroy() {
Log.i(TAG, "BLE background service destroyed")
- advertisingDesired = false
- applyAdvertisingState()
-
- // Cleanup timeout jobs to prevent resource leaks
- bleCoordinator?.cleanup()
-
+ try {
+ unregisterReceiver(adapterStateReceiver)
+ } catch (_: IllegalArgumentException) {
+ // Not registered.
+ }
+ val ble = bleCoordinator
+ lifecycle.execute {
+ ble?.stopAdvertising()
+ // Cleanup timeout jobs to prevent resource leaks
+ ble?.cleanup()
+ }
+ lifecycle.shutdown()
super.onDestroy()
}
@@ -113,88 +149,69 @@ class BleBackgroundService : Service() {
return binder
}
- @Synchronized
- fun setAdvertisingDesired(desired: Boolean) {
- advertisingDesired = desired
- applyAdvertisingState()
- }
-
- fun ensureGattServerStarted(): Boolean {
- return bleCoordinator?.ensureGattServerStarted() ?: false
- }
-
- fun closeStaleGattSessions() {
- bleCoordinator?.closeStaleGattSessions()
- }
-
- fun startScanning(): Boolean {
- return bleCoordinator?.startScanning() ?: false
- }
-
- fun stopScanning(): Boolean {
- return bleCoordinator?.stopScanning() ?: false
+ /**
+ * Bring advertising in line with the identity, from Rust: the GATT server
+ * up and advertising on when there is one, advertising off when there is
+ * not. Called on every event that can change the answer — the service
+ * starting, the activity resuming or binding, native init finding the
+ * identity, a Bluetooth permission granted, the adapter turned on.
+ * Idempotent: advertising already on stays on.
+ */
+ fun refreshAdvertising() {
+ val ble = bleCoordinator ?: return
+ onLifecycleThread("refreshAdvertising") {
+ if (!localIdentityAvailable()) {
+ if (ble.isAdvertising()) ble.stopAdvertising()
+ Log.i(TAG, "refreshAdvertising: no local identity; not advertising")
+ return@onLifecycleThread
+ }
+ val gattOk = ble.ensureGattServerStarted()
+ val advertising = gattOk && ble.startAdvertising()
+ Log.i(TAG, "refreshAdvertising: GATT=$gattOk advertising=$advertising")
+ }
}
- fun isScanningActive(): Boolean {
- return try {
- bleCoordinator?.isScanning() ?: false
- } catch (_: Throwable) {
- false
- }
+ /** What the radio is doing, for the session snapshot Rust publishes. */
+ fun isScanningActive(): Boolean = try {
+ bleCoordinator?.isScanning() ?: false
+ } catch (_: Throwable) {
+ false
}
- fun isAdvertisingActive(): Boolean {
- return try {
- bleCoordinator?.isAdvertising() ?: isAdvertising
- } catch (_: Throwable) {
- isAdvertising
- }
+ fun isAdvertisingActive(): Boolean = try {
+ bleCoordinator?.isAdvertising() ?: false
+ } catch (_: Throwable) {
+ false
}
- fun setIdentityValue(genesisHash: ByteArray, deviceId: ByteArray) {
- bleCoordinator?.setIdentityValue(genesisHash, deviceId)
+ /** Stale GATT sessions from before a pause, closed off the main thread. */
+ fun closeStaleGattSessions() {
+ val ble = bleCoordinator ?: return
+ onLifecycleThread("closeStaleGattSessions") { ble.closeStaleGattSessions() }
}
- @Synchronized
- private fun applyAdvertisingState() {
- bleCoordinator?.let { ble ->
- if (advertisingDesired && !isAdvertising) {
- var hasIdentity = false
+ // A caller holding this service after it was destroyed gets a log line,
+ // not a RejectedExecutionException on its own (main) thread.
+ private fun onLifecycleThread(what: String, block: () -> Unit) {
+ try {
+ lifecycle.execute {
try {
- val deviceIdBytes = try { com.dsm.wallet.bridge.Unified.getDeviceIdBin() } catch (_: Throwable) { byteArrayOf() }
- val genesisHashBytes = try { com.dsm.wallet.bridge.Unified.getGenesisHashBin() } catch (_: Throwable) { byteArrayOf() }
- if (deviceIdBytes.size == 32 && genesisHashBytes.size == 32) {
- ble.setIdentityValue(genesisHashBytes, deviceIdBytes)
- hasIdentity = true
- Log.i(TAG, "applyAdvertisingState: local BLE identity published before advertise")
- } else {
- Log.w(TAG, "applyAdvertisingState: local identity unavailable before advertise (genesis=${genesisHashBytes.size}, device=${deviceIdBytes.size})")
- }
+ block()
} catch (t: Throwable) {
- Log.w(TAG, "applyAdvertisingState: failed to publish local identity before advertise", t)
- }
- if (!hasIdentity) {
- Log.w(TAG, "BLE advertising requested but local identity is unavailable")
- return
- }
- val gattOk = ble.ensureGattServerStarted()
- if (gattOk) {
- ble.startAdvertising()
- isAdvertising = true
- Log.i(TAG, "BLE advertising started in background")
- } else {
- Log.w(TAG, "BLE advertising requested but GATT not ready")
+ Log.w(TAG, "$what failed", t)
}
- } else if (!advertisingDesired && isAdvertising) {
- ble.stopAdvertising()
- isAdvertising = false
- Log.i(TAG, "BLE advertising stopped")
- } else {
- // No-op: advertising state already matches desired state.
}
+ } catch (_: java.util.concurrent.RejectedExecutionException) {
+ Log.w(TAG, "$what: the service is destroyed")
}
}
+ private fun localIdentityAvailable(): Boolean = try {
+ Unified.getDeviceIdBin().size == 32 && Unified.getGenesisHashBin().size == 32
+ } catch (_: Throwable) {
+ false
+ }
+
private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
diff --git a/dsm_client/android/app/src/main/java/com/dsm/wallet/ui/MainActivity.kt b/dsm_client/android/app/src/main/java/com/dsm/wallet/ui/MainActivity.kt
index 9cc737f56..05cbe436b 100644
--- a/dsm_client/android/app/src/main/java/com/dsm/wallet/ui/MainActivity.kt
+++ b/dsm_client/android/app/src/main/java/com/dsm/wallet/ui/MainActivity.kt
@@ -60,7 +60,6 @@ import com.dsm.wallet.BuildConfig
import com.dsm.wallet.bridge.BleEventRelay
import com.dsm.wallet.bridge.SinglePathWebViewBridge
import com.dsm.wallet.bridge.Unified
-import com.dsm.wallet.bridge.ble.BleCoordinator
import com.dsm.wallet.mcp.McpService
import com.dsm.wallet.permissions.BluetoothPermissionHelper
import com.dsm.wallet.service.BleBackgroundService
@@ -149,6 +148,8 @@ class MainActivity : AppCompatActivity(), NfcAdapter.ReaderCallback {
bleBackgroundService = binder?.getService()
bleServiceBound = bleBackgroundService != null
Log.i(tag, "BLE service bound: $bleServiceBound")
+ // A resume or init that ran before the bind had no service to ask.
+ bleBackgroundService?.refreshAdvertising()
}
override fun onServiceDisconnected(name: ComponentName?) {
@@ -797,8 +798,21 @@ class MainActivity : AppCompatActivity(), NfcAdapter.ReaderCallback {
}
}
- fun setBleAdvertisingDesired(desired: Boolean) {
- bleBackgroundService?.setAdvertisingDesired(desired)
+ /**
+ * The device has an identity: the BLE foreground service runs (it survives
+ * activity lifecycle transitions) and brings the GATT server and advertising
+ * up on its own thread; a service already running is asked again, since the
+ * identity may only now exist. Called on the UI thread (context
+ * requirement) at init when the identity is read and when genesis creates it.
+ */
+ fun startBleForIdentity() {
+ try {
+ BleBackgroundService.start(this)
+ Log.i(tag, "startBleForIdentity: BLE foreground service started")
+ } catch (t: Throwable) {
+ Log.w(tag, "startBleForIdentity: BLE foreground service start failed", t)
+ }
+ bleBackgroundService?.refreshAdvertising()
}
private fun setSessionFatalError(message: String?) {
@@ -821,7 +835,6 @@ class MainActivity : AppCompatActivity(), NfcAdapter.ReaderCallback {
}
}
- private fun bleCoordinator(): BleCoordinator = BleCoordinator.getInstance(applicationContext)
// The WebView external-host allowlist lives at file scope below so that
@@ -1341,24 +1354,16 @@ class MainActivity : AppCompatActivity(), NfcAdapter.ReaderCallback {
if (hasIdentityViaRust()) {
invokeNativeRouterInvoke("inbox.resume")
}
- // Only restart BLE after genesis — during genesis the device is busy and
- // BLE scanning/advertising wastes resources and causes errors.
+ // Stale GATT sessions from before the pause are closed (a peer's RPA
+ // may have rotated); advertising follows the identity. Pre-genesis
+ // there is no identity and nothing to restart. An unbound service
+ // refreshes when it binds.
if (hasIdentityViaRust()) {
- try {
- val svc = bleBackgroundService
- if (svc != null) {
- svc.closeStaleGattSessions()
- val gattOk = svc.ensureGattServerStarted()
- svc.setAdvertisingDesired(true)
- Log.i(tag, "onResume: BLE restart — stale sessions closed, GATT=$gattOk advertising=desired")
- } else {
- Log.w(tag, "onResume: BLE service not bound yet, GATT restart deferred")
- }
- } catch (t: Throwable) {
- Log.w(tag, "onResume: BLE restart failed: ${t.message}")
+ val svc = bleBackgroundService
+ if (svc != null) {
+ svc.closeStaleGattSessions()
+ svc.refreshAdvertising()
}
- } else {
- Log.d(tag, "onResume: skipping BLE restart (no identity yet, pre-genesis)")
}
// Suppress Android's system NFC popup while the app is in foreground.
@@ -1536,19 +1541,8 @@ class MainActivity : AppCompatActivity(), NfcAdapter.ReaderCallback {
if (blePermsGranted) {
Log.i(tag, "BLE permissions granted")
- // Start the background service so it's bound and ready, but only
- // initialize GATT/advertising after genesis (hasIdentityViaRust).
- try {
- val svc = bleBackgroundService
- if (hasIdentityViaRust()) {
- val gattResult = svc?.ensureGattServerStarted() ?: false
- Log.i(tag, "Bluetooth permissions granted: GATT server ensure-start result=$gattResult")
- } else {
- Log.d(tag, "Bluetooth permissions granted: deferring GATT start until after genesis")
- }
- } catch (t: Throwable) {
- Log.e(tag, "Failed to reinitialize BLE after permissions granted", t)
- }
+ // The radio can now do what the identity asks of it.
+ bleBackgroundService?.refreshAdvertising()
} else {
Log.w(tag, "BLE permissions not granted: $grants")
}
@@ -1813,32 +1807,9 @@ class MainActivity : AppCompatActivity(), NfcAdapter.ReaderCallback {
}
if (capturedDeviceId.size == 32 && capturedGenesis.size == 32) {
- // Start BLE as a foreground service so it survives activity
- // lifecycle transitions. Must happen on the UI thread (context
- // requirement) BEFORE the background GATT init thread.
- try {
- BleBackgroundService.start(this@MainActivity)
- Log.i(tag, "initDsmAndSignalReady: BLE foreground service started")
- } catch (t: Throwable) {
- Log.w(tag, "initDsmAndSignalReady: BLE foreground service start failed", t)
- }
-
- // GATT server init + identity write are synchronous Bluetooth
- // framework calls (100-500ms). Run on a background thread to
- // avoid blocking the UI thread on slower chipsets (MediaTek).
- Thread {
- try {
- val coordinator = bleCoordinator()
- val gattReady = coordinator.ensureGattServerStarted()
- Log.i(tag, "initDsmAndSignalReady: GATT server ensure-started: $gattReady")
- coordinator.setIdentityValue(capturedGenesis, capturedDeviceId)
- Log.i(tag, "initDsmAndSignalReady: BLE identity set (genesis + deviceId)")
- } catch (t: Throwable) {
- Log.w(tag, "initDsmAndSignalReady: GATT/identity setup failed", t)
- }
- }.start()
+ startBleForIdentity()
} else {
- Log.i(tag, "initDsmAndSignalReady: BLE identity not yet present in persisted bytes; skipping setIdentityValue")
+ Log.i(tag, "initDsmAndSignalReady: no identity yet; BLE stays down until genesis")
}
publishSessionState("initComplete")
} catch (t: Throwable) {
@@ -1872,19 +1843,13 @@ class MainActivity : AppCompatActivity(), NfcAdapter.ReaderCallback {
val allGranted = grantResults.isNotEmpty() && grantResults.all { it == PackageManager.PERMISSION_GRANTED }
if (!allGranted) {
Log.w(tag, "Bluetooth permissions not granted")
- dispatchNativeHostEventOnUi(
- NativeHostEventKind.NATIVE_HOST_EVENT_KIND_BLUETOOTH_PERMISSIONS,
- byteArrayOf(0x00),
- )
} else {
- Log.i(tag, "Bluetooth permissions granted, notifying WebView")
- // BLE ops are NOT auto-started here. The UI must explicitly request
- // scanning/advertising via the native host boundary.
- dispatchNativeHostEventOnUi(
- NativeHostEventKind.NATIVE_HOST_EVENT_KIND_BLUETOOTH_PERMISSIONS,
- byteArrayOf(0x01),
- )
+ // The radio can now do what the identity asks of it. The UI is
+ // not asked to start anything: advertising is native policy.
+ Log.i(tag, "Bluetooth permissions granted")
+ bleBackgroundService?.refreshAdvertising()
}
+ // The permission facts reach the UI in the session snapshot.
publishSessionState("runtimePermissions")
}
}
diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/bluetooth/pairing_orchestrator.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/bluetooth/pairing_orchestrator.rs
index f32686066..8b5ab515a 100644
--- a/dsm_client/deterministic_state_machine/dsm_sdk/src/bluetooth/pairing_orchestrator.rs
+++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/bluetooth/pairing_orchestrator.rs
@@ -1115,9 +1115,12 @@ impl PairingOrchestrator {
Ok(())
}
- /// Stop BLE scan and advertise via JNI.
+ /// Stop the scan pairing started, via JNI.
/// Called when the pairing loop exits to prevent lingering radio activity
- /// that causes "stuck scanning" after pairing completes.
+ /// that causes "stuck scanning" after pairing completes. Advertising is not
+ /// pairing's to stop: it follows the device's identity (the Android BLE
+ /// service owns it), and a device that stopped advertising here could not
+ /// be found for an offline transfer by the contact it had just paired with.
#[cfg(all(target_os = "android", feature = "jni"))]
async fn stop_ble_discovery(&self) -> Result<(), String> {
use crate::jni::jni_common::{find_class_with_app_loader, get_java_vm_borrowed};
@@ -1131,11 +1134,9 @@ impl PairingOrchestrator {
let class = find_class_with_app_loader(&mut env, "com/dsm/wallet/bridge/Unified")
.map_err(|e| format!("Failed to find Unified class: {e:?}"))?;
- // Stop both scan and advertise — we don't know which role we were playing
let _ = env.call_static_method(&class, "stopBlePairingScan", "()Z", &[]);
- let _ = env.call_static_method(&class, "stopBlePairingAdvertise", "()Z", &[]);
- log::info!("[PairingOrchestrator] stop_ble_discovery: stopped scan and advertise");
+ log::info!("[PairingOrchestrator] stop_ble_discovery: stopped scan");
Ok(())
}
diff --git a/dsm_client/frontend/src/components/screens/EnhancedWalletScreen.tsx b/dsm_client/frontend/src/components/screens/EnhancedWalletScreen.tsx
index 3dc67681a..b3c90633f 100644
--- a/dsm_client/frontend/src/components/screens/EnhancedWalletScreen.tsx
+++ b/dsm_client/frontend/src/components/screens/EnhancedWalletScreen.tsx
@@ -7,9 +7,6 @@ import SendTab from './wallet/SendTab';
import HistoryTab from './wallet/HistoryTab';
import InboxOverlay from './wallet/InboxOverlay';
import BitcoinTapTab from './bitcoin/BitcoinTapTab';
-import { ensureBleAdvertisingIfContacts } from '../../contexts/ContactsContext';
-import { stopBleAdvertisingViaRouter } from '../../dsm/WebViewBridge';
-import { bridgeEvents } from '../../bridge/bridgeEvents';
import { Notice, ScreenFrame, ScreenTabs } from '../common/ScreenFrame';
import '../../styles/EnhancedWallet.css';
@@ -46,29 +43,6 @@ const EnhancedWalletScreen: React.FC = ({ btcLogoSrc,
return () => window.removeEventListener('resize', measure);
}, []);
- // ── BLE lifecycle: wallet screen visible = BLE advertising active ──
- // Both parties must be on the wallet screen for bilateral transfers.
- // On mount: start GATT server + advertising via protobuf bridge.
- // On unmount or app backgrounded: stop advertising.
- // On app foregrounded: re-ensure advertising.
- useEffect(() => {
- void ensureBleAdvertisingIfContacts();
-
- const handleVisibility = (ev: { state: DocumentVisibilityState }) => {
- if (ev.state === 'visible') {
- void ensureBleAdvertisingIfContacts();
- } else {
- void stopBleAdvertisingViaRouter();
- }
- };
- const off = bridgeEvents.on('visibility.change', handleVisibility);
-
- return () => {
- off();
- void stopBleAdvertisingViaRouter();
- };
- }, []);
-
const [activeTab, setActiveTab] = useState(initialTab || 'overview');
// A tab is a new page: it opens at the top, not wherever the last one was scrolled to.
diff --git a/dsm_client/frontend/src/components/screens/__tests__/EnhancedWalletScreen.events.test.tsx b/dsm_client/frontend/src/components/screens/__tests__/EnhancedWalletScreen.events.test.tsx
index b9aec62f8..22bc30358 100644
--- a/dsm_client/frontend/src/components/screens/__tests__/EnhancedWalletScreen.events.test.tsx
+++ b/dsm_client/frontend/src/components/screens/__tests__/EnhancedWalletScreen.events.test.tsx
@@ -11,6 +11,7 @@ import { UXProvider } from '../../../contexts/UXContext';
import { WalletProvider } from '../../../contexts/WalletContext';
import { walletStore } from '../../../stores/walletStore';
import { contactsStore } from '../../../stores/contactsStore';
+import * as pb from '../../../proto/dsm_app_pb';
/**
* The screen as the app mounts it: inside the wallet provider, whose store is
@@ -436,4 +437,35 @@ describe('EnhancedWalletScreen event-driven refresh', () => {
expect(opened).toHaveBeenLastCalledWith({ open: false });
off();
});
+
+ // The radio is native's: the device advertises while it has an identity,
+ // and an offline send connects to its peer itself. The screen used to start
+ // advertising when it mounted or became visible, and stop it when hidden and
+ // when it unmounted, so a device on any other screen could not be reached.
+ test('the wallet screen makes no radio request as it mounts, hides, shows and unmounts', async () => {
+ installStandardWalletMocks([contactDto('Peer', 0x0a, 'AA:BB:CC:DD:EE:FF')]);
+ (dsmClient.getAllBalances as any) = jest.fn().mockResolvedValue([]);
+ (dsmClient.getWalletHistory as any) = jest.fn().mockResolvedValue({ transactions: [] });
+ const bridge = (window as any).DsmBridge;
+ const answer = bridge.sendMessageBin;
+ const methods: string[] = [];
+ bridge.sendMessageBin = (bytes: Uint8Array) => {
+ methods.push(pb.BridgeRpcRequest.fromBinary(bytes).method);
+ return answer(bytes);
+ };
+ try {
+ const { unmount } = await renderWallet();
+ await waitFor(() => expect(screen.getByText('DSM Wallet')).toBeInTheDocument());
+ await act(async () => {
+ bridgeEvents.emit('visibility.change', { state: 'hidden' });
+ bridgeEvents.emit('visibility.change', { state: 'visible' });
+ });
+ unmount();
+ // Whatever the lifecycle started has reached the port by now.
+ await act(async () => { await new Promise((r) => setTimeout(r, 20)); });
+ } finally {
+ bridge.sendMessageBin = answer;
+ }
+ expect(methods).not.toContain('nativeHostRequest');
+ });
});
diff --git a/dsm_client/frontend/src/contexts/ContactsContext.tsx b/dsm_client/frontend/src/contexts/ContactsContext.tsx
index 734939b3e..12eff8aed 100644
--- a/dsm_client/frontend/src/contexts/ContactsContext.tsx
+++ b/dsm_client/frontend/src/contexts/ContactsContext.tsx
@@ -5,11 +5,6 @@ import React, { createContext, useContext, useEffect, useMemo } from 'react';
import { useBridgeEvent } from '@/hooks/useBridgeEvents';
import { hasIdentity } from '../utils/identity';
import { contactsStore, useContactsStore } from '../stores/contactsStore';
-import {
- setBleIdentityForAdvertising,
- startBleAdvertisingViaRouter,
-} from '../dsm/WebViewBridge';
-import { getHeaders } from '../dsm/identity';
import type { AddContactResult, ContactCard } from '../dsm/types';
import type { DomainContact } from '../domain/types';
@@ -38,46 +33,17 @@ const defaultValue: ContactsContextValue = {
export const ContactsContext = createContext(defaultValue);
-/**
- * Ensure BLE advertising is active so peers can initiate bilateral transfers.
- * Called by EnhancedWalletScreen on mount/visibility change, and by
- * ContactsProvider on identity.ready and contact.bleMapped events.
- */
-export async function ensureBleAdvertisingIfContacts(): Promise {
- try {
- const contacts = contactsStore.getSnapshot().contacts;
- const hasBleContacts = contacts.some((c: any) => c.bleAddress);
- if (!hasBleContacts) return;
-
- // §2.3-2.4: Device is bound to genesis via DevID ∈ R_G.
- // Fetch the real genesis hash — never advertise all-zeros.
- const headers = await getHeaders();
- const devId = headers.deviceId;
- const genesisHash = headers.genesisHash;
- if (!devId || devId.length !== 32) return;
- if (!genesisHash || genesisHash.length !== 32) return;
-
- await setBleIdentityForAdvertising(new Uint8Array(genesisHash), new Uint8Array(devId));
- await startBleAdvertisingViaRouter();
- } catch {
- // Best-effort — don't block contacts flow if BLE advertising fails
- }
-}
-
export function ContactsProvider({ children }: { children: React.ReactNode }) {
const state = useContactsStore();
useBridgeEvent('contact.bleMapped', (detail) => {
contactsStore.handleBleMapped(detail);
- void ensureBleAdvertisingIfContacts();
}, []);
useBridgeEvent('contact.bleUpdated', contactsStore.handleBleUpdated, []);
+ // The list Rust holds once there is an identity. Whether the radio
+ // advertises is native policy (it follows the identity), not the screen's.
useBridgeEvent('identity.ready', () => {
- // After identity is ready, refresh contacts then start advertising
- // so peers can discover us for bilateral transfers.
- void contactsStore.refreshContacts().then(() => {
- void ensureBleAdvertisingIfContacts();
- });
+ void contactsStore.refreshContacts();
}, []);
useEffect(() => {
diff --git a/dsm_client/frontend/src/contexts/__tests__/ContactsContext.radio.test.tsx b/dsm_client/frontend/src/contexts/__tests__/ContactsContext.radio.test.tsx
new file mode 100644
index 000000000..b7cc622a5
--- /dev/null
+++ b/dsm_client/frontend/src/contexts/__tests__/ContactsContext.radio.test.tsx
@@ -0,0 +1,53 @@
+// SPDX-License-Identifier: MIT OR Apache-2.0
+// Whether the radio advertises is native policy: it follows the device's
+// identity. The contacts provider used to set the advertised identity and
+// start advertising when the identity became ready and when a contact's BLE
+// address was learned.
+
+/* eslint-disable @typescript-eslint/no-explicit-any */
+import React from 'react';
+import { act, render, waitFor } from '@testing-library/react';
+import * as pb from '../../proto/dsm_app_pb';
+import { ContactsProvider } from '../ContactsContext';
+import { dsmClient } from '../../services/dsmClient';
+import { bridgeEvents } from '../../bridge/bridgeEvents';
+
+test('identity readiness and a learned BLE address reach the contact list, not the radio', async () => {
+ const peer = {
+ alias: 'Peer',
+ deviceId: new Uint8Array(32).fill(0x0a),
+ genesisHash: new Uint8Array(32).fill(0x0b),
+ publicKey: new Uint8Array(64).fill(0x0c),
+ genesisVerifiedOnline: true,
+ bleAddress: 'AA:BB:CC:DD:EE:FF',
+ };
+ (dsmClient.getContacts as any) = jest.fn().mockResolvedValue({ contacts: [peer] });
+ const bridge = (window as any).DsmBridge;
+ const answer = bridge.sendMessageBin;
+ const methods: string[] = [];
+ bridge.sendMessageBin = (bytes: Uint8Array) => {
+ methods.push(pb.BridgeRpcRequest.fromBinary(bytes).method);
+ return answer(bytes);
+ };
+ try {
+ render();
+ await act(async () => { await new Promise((r) => setTimeout(r, 0)); });
+ const before = (dsmClient.getContacts as jest.Mock).mock.calls.length;
+
+ await act(async () => {
+ bridgeEvents.emit('identity.ready', undefined);
+ });
+ // The provider answers readiness by reading the list Rust holds.
+ await waitFor(() => expect((dsmClient.getContacts as jest.Mock).mock.calls.length).toBeGreaterThan(before));
+
+ await act(async () => {
+ bridgeEvents.emit('contact.bleMapped', { address: peer.bleAddress });
+ });
+ // Whatever either event started has reached the port by now.
+ await act(async () => { await new Promise((r) => setTimeout(r, 200)); });
+ } finally {
+ bridge.sendMessageBin = answer;
+ }
+ expect(methods).not.toContain('nativeHostRequest');
+ expect(methods).not.toContain('setBleIdentityForAdvertising');
+});
diff --git a/dsm_client/frontend/src/dsm/EventBridge.ts b/dsm_client/frontend/src/dsm/EventBridge.ts
index 23e79c792..2cc5e6b41 100644
--- a/dsm_client/frontend/src/dsm/EventBridge.ts
+++ b/dsm_client/frontend/src/dsm/EventBridge.ts
@@ -286,16 +286,6 @@ export function initializeEventBridge(): void {
return;
}
- if (topic === 'bluetooth-permissions') {
- // Payload: [0x01] = granted, [0x00] = denied
- try {
- const granted = bytes.length > 0 && bytes[0] === 0x01;
- window.dispatchEvent(new CustomEvent('bluetooth-permissions', { detail: { granted } }));
- } catch {}
- emit(topic, bytes);
- return;
- }
-
if (topic === 'ble-dev-automation') {
// Payload: UTF-8 "ok:advertising=true,scanning=true" or "error:reason"
try {
diff --git a/dsm_client/frontend/src/dsm/NativeHostBridge.ts b/dsm_client/frontend/src/dsm/NativeHostBridge.ts
index 8b73129dd..8cb392ccf 100644
--- a/dsm_client/frontend/src/dsm/NativeHostBridge.ts
+++ b/dsm_client/frontend/src/dsm/NativeHostBridge.ts
@@ -6,7 +6,7 @@ import { bridgeEvents } from '../bridge/bridgeEvents';
import logger from '../utils/logger';
import type { AndroidBridgeV3 } from './bridgeTypes';
import { bridgeGate } from './BridgeGate';
-import { BiometricAuthorizeResult, NativeHostAck, NativeHostEvent, NativeHostEventKind, NativeHostRequest, NativeHostRequestKind, NativeHostResponse, NfcTagWritePayload, NfcTagWriteResult, QrScanResultPayload } from '../proto/dsm_app_pb';
+import { BiometricAuthorizeResult, NativeHostEvent, NativeHostEventKind, NativeHostRequest, NativeHostRequestKind, NativeHostResponse, NfcTagWritePayload, NfcTagWriteResult, QrScanResultPayload } from '../proto/dsm_app_pb';
function mustBridge(): AndroidBridgeV3 {
const bridge = getBridgeInstance();
@@ -76,24 +76,6 @@ export async function startNativeQrScan(): Promise {
await hostRequestOk(buildHostRequest(NativeHostRequestKind.HOST_CONTROL_QR_START_SCAN));
}
-export async function startBleScanHost(): Promise {
- await hostRequestOk(buildHostRequest(NativeHostRequestKind.HOST_CONTROL_BLE_SCAN_START));
-}
-
-export async function stopBleScanHost(): Promise {
- await hostRequestOk(buildHostRequest(NativeHostRequestKind.HOST_CONTROL_BLE_SCAN_STOP));
-}
-
-export async function startBleAdvertisingHost(): Promise {
- const bytes = await hostRequestOk(buildHostRequest(NativeHostRequestKind.HOST_CONTROL_BLE_ADVERTISE_START));
- return NativeHostAck.fromBinary(bytes);
-}
-
-export async function stopBleAdvertisingHost(): Promise {
- const bytes = await hostRequestOk(buildHostRequest(NativeHostRequestKind.HOST_CONTROL_BLE_ADVERTISE_STOP));
- return NativeHostAck.fromBinary(bytes);
-}
-
export async function startNfcReaderHost(): Promise {
await hostRequestOk(buildHostRequest(NativeHostRequestKind.HOST_CONTROL_NFC_READER_START));
}
@@ -128,8 +110,6 @@ export function decodeNativeHostEventToLegacyTopic(eventBytes: Uint8Array): { to
return null;
}
}
- case NativeHostEventKind.BLUETOOTH_PERMISSIONS:
- return { topic: 'bluetooth-permissions', payload: event.payload };
case NativeHostEventKind.BIOMETRIC_RESULT: {
try {
const payload = BiometricAuthorizeResult.fromBinary(event.payload);
diff --git a/dsm_client/frontend/src/dsm/WebViewBridge/ble.ts b/dsm_client/frontend/src/dsm/WebViewBridge/ble.ts
index 2da09f905..f19e76ddc 100644
--- a/dsm_client/frontend/src/dsm/WebViewBridge/ble.ts
+++ b/dsm_client/frontend/src/dsm/WebViewBridge/ble.ts
@@ -1,15 +1,9 @@
// SPDX-License-Identifier: Apache-2.0
-// BLE-related transport: pairing orchestrator, advertising, scanning, identity
-// injection, and bilateral offline send.
+// BLE-related bridge calls the screens make: permissions, Bluetooth settings,
+// the pairing loop, and the peer relationship read. When the radio advertises
+// and scans is native policy; nothing here starts or stops it.
import { bridgeGate } from "../BridgeGate";
-import { BleIdentityPayload } from "../../proto/dsm_app_pb";
-import {
- startBleAdvertisingHost,
- startBleScanHost,
- stopBleAdvertisingHost,
- stopBleScanHost,
-} from "../NativeHostBridge";
import { callBin } from "./transportCore";
import { log } from "./log";
@@ -59,64 +53,3 @@ export async function readPeerRelationshipStatusBridge(bleAddress: string): Prom
callBin("readPeerRelationshipStatus", new TextEncoder().encode(normalized))
);
}
-
-export async function startBleScanViaRouter(): Promise {
- await startBleScanHost();
-}
-
-export async function stopBleScanViaRouter(): Promise {
- await stopBleScanHost();
-}
-
-export async function startBleAdvertisingViaRouter(): Promise<{
- success: boolean;
- error?: { message?: string };
-}> {
- try {
- const ack = await startBleAdvertisingHost();
- return { success: Boolean(ack.success) };
- } catch (e) {
- return {
- success: false,
- error: { message: e instanceof Error ? e.message : "device.ble.advertise.start failed" },
- };
- }
-}
-
-export async function stopBleAdvertisingViaRouter(): Promise<{
- success: boolean;
- error?: { message?: string };
-}> {
- try {
- const ack = await stopBleAdvertisingHost();
- return { success: Boolean(ack.success) };
- } catch (e) {
- return {
- success: false,
- error: { message: e instanceof Error ? e.message : "device.ble.advertise.stop failed" },
- };
- }
-}
-
-/**
- * Inject genesis + device_id into native BLE layer to enable advertising after
- * genesis creation.
- */
-export async function setBleIdentityForAdvertising(
- genesisHash: Uint8Array,
- deviceId: Uint8Array
-): Promise {
- if (genesisHash.length !== 32) {
- throw new Error("setBleIdentityForAdvertising: genesis_hash must be 32 bytes");
- }
- if (deviceId.length !== 32) {
- throw new Error("setBleIdentityForAdvertising: device_id must be 32 bytes");
- }
-
- const req = new BleIdentityPayload({
- genesisHash: new Uint8Array(genesisHash),
- deviceId: new Uint8Array(deviceId),
- });
-
- await bridgeGate.enqueue(() => callBin("setBleIdentityForAdvertising", req.toBinary()));
-}
diff --git a/dsm_client/frontend/src/dsm/WebViewBridge/index.ts b/dsm_client/frontend/src/dsm/WebViewBridge/index.ts
index a206de892..488aac243 100644
--- a/dsm_client/frontend/src/dsm/WebViewBridge/index.ts
+++ b/dsm_client/frontend/src/dsm/WebViewBridge/index.ts
@@ -39,12 +39,7 @@ export const {
readPeerRelationshipStatusBridge,
requestBlePermissions,
resolveBleAddressForDeviceIdBridge,
- setBleIdentityForAdvertising,
- startBleAdvertisingViaRouter,
- startBleScanViaRouter,
startPairingAll,
- stopBleAdvertisingViaRouter,
- stopBleScanViaRouter,
stopPairingAll,
} = ble;
diff --git a/dsm_client/frontend/src/dsm/__tests__/offlineSend.radio.test.ts b/dsm_client/frontend/src/dsm/__tests__/offlineSend.radio.test.ts
new file mode 100644
index 000000000..3f7d36829
--- /dev/null
+++ b/dsm_client/frontend/src/dsm/__tests__/offlineSend.radio.test.ts
@@ -0,0 +1,89 @@
+// SPDX-License-Identifier: Apache-2.0
+// The frontend makes no radio decisions. An offline send is one router call —
+// `wallet.sendOffline` — and the native side advertises, scans and connects as
+// the dispatch needs. The send used to set the advertised identity, start
+// advertising and scanning through host requests, and sleep 1.5 s first,
+// swallowing every failure.
+
+/* eslint-disable @typescript-eslint/no-explicit-any */
+import * as pb from '../../proto/dsm_app_pb';
+import { emit, initializeEventBridge } from '../EventBridge';
+import { offlineSend } from '../transactions';
+import { encodeBase32Crockford } from '../../utils/textId';
+
+const COMMITMENT = new Uint8Array(32).fill(0x5c);
+const PEER = new Uint8Array(32).fill(9);
+
+function framed(envelope: pb.Envelope): Uint8Array {
+ const bytes = envelope.toBinary();
+ const out = new Uint8Array(1 + bytes.length);
+ out[0] = 0x03;
+ out.set(bytes, 1);
+ return out;
+}
+
+/** What the page sent over the port: each RPC's method, and each router call's name. */
+function recordingBridge() {
+ const methods: string[] = [];
+ const routerCalls: string[] = [];
+ (window as any).DsmBridge = {
+ __binary: true,
+ sendMessageBin: async (reqBytes: Uint8Array) => {
+ const req = pb.BridgeRpcRequest.fromBinary(reqBytes);
+ methods.push(req.method);
+ if (req.method !== 'nativeBoundaryIngress') {
+ return (global as any).createDsmBridgeErrorResponse(`no ${req.method} here`);
+ }
+ const payload = req.payload.case === 'bytes' ? req.payload.value.data : new Uint8Array(0);
+ const op = pb.IngressRequest.fromBinary(payload).operation;
+ const name = op.case === 'routerInvoke' || op.case === 'routerQuery' ? op.value.method : String(op.case);
+ routerCalls.push(name);
+ const answer = name === 'wallet.sendOffline'
+ ? framed(new pb.Envelope({
+ version: 3,
+ payload: {
+ case: 'bilateralPrepareResponse',
+ value: new pb.BilateralPrepareResponse({ commitmentHash: new pb.Hash32({ v: COMMITMENT }) }),
+ },
+ }))
+ : new Uint8Array(0);
+ return (global as any).createDsmBridgeSuccessResponse(
+ new pb.IngressResponse({ result: { case: 'okBytes', value: answer } }).toBinary(),
+ );
+ },
+ };
+ return { methods, routerCalls };
+}
+
+describe('offline send: the radio is native’s', () => {
+ beforeEach(() => {
+ initializeEventBridge();
+ });
+
+ test('an offline send asks for the send and nothing else — no host request, no identity relay, no wait', async () => {
+ const seen = recordingBridge();
+
+ const pending = offlineSend({ to: encodeBase32Crockford(PEER), amount: '1', tokenId: 'ERA' } as any);
+ // The send reaches Rust at once: nothing is awaited on a timer first.
+ for (let i = 0; i < 10 && !seen.routerCalls.includes('wallet.sendOffline'); i++) {
+ await Promise.resolve();
+ }
+ expect(seen.routerCalls).toEqual(['wallet.sendOffline']);
+
+ // Rust announces the completed transfer; the send finishes on it.
+ const note = new pb.BilateralEventNotification({
+ eventType: pb.BilateralEventType.BILATERAL_EVENT_TRANSFER_COMPLETE,
+ commitmentHash: COMMITMENT,
+ counterpartyDeviceId: PEER,
+ status: 'completed',
+ });
+ emit('bilateral.event', new Uint8Array(note.toBinary()));
+ await expect(pending).resolves.toEqual(expect.objectContaining({ accepted: true }));
+ // Whatever the send started has reached the port by now.
+ await new Promise((r) => setTimeout(r, 0));
+
+ // Every request was a router call through the ingress boundary: no
+ // nativeHostRequest (advertise / scan) and no setBleIdentityForAdvertising.
+ expect(new Set(seen.methods)).toEqual(new Set(['nativeBoundaryIngress']));
+ });
+});
diff --git a/dsm_client/frontend/src/dsm/__tests__/protobufPayloads.test.ts b/dsm_client/frontend/src/dsm/__tests__/protobufPayloads.test.ts
index b1b2315ba..c6ad2ae6e 100644
--- a/dsm_client/frontend/src/dsm/__tests__/protobufPayloads.test.ts
+++ b/dsm_client/frontend/src/dsm/__tests__/protobufPayloads.test.ts
@@ -3,11 +3,9 @@
import {
createGenesisViaRouter,
rejectBilateralByCommitmentBridge,
- setBleIdentityForAdvertising,
} from "../WebViewBridge";
import {
BilateralPayload,
- BleIdentityPayload,
BridgeRpcRequest,
BridgeRpcResponse,
Envelope,
@@ -78,27 +76,6 @@ describe("protobuf-only bridge payloads", () => {
// No silicon / no random entropy: the mnemonic is the sole genesis root.
});
- test("setBleIdentityForAdvertising sends BleIdentityPayload", async () => {
- let seenMethod = "";
- let seenPayload: Uint8Array | undefined;
-
- setupBridge((req) => {
- seenMethod = req.method;
- seenPayload = req.payload.case === "bytes" ? req.payload.value.data : new Uint8Array(0);
- });
-
- const genesis = new Uint8Array(32).fill(0xaa);
- const deviceId = new Uint8Array(32).fill(0xbb);
- await setBleIdentityForAdvertising(genesis, deviceId);
-
- expect(seenMethod).toBe("setBleIdentityForAdvertising");
- expect(seenPayload).toBeInstanceOf(Uint8Array);
-
- const decoded = BleIdentityPayload.fromBinary(seenPayload as Uint8Array);
- expect(decoded.genesisHash).toEqual(genesis);
- expect(decoded.deviceId).toEqual(deviceId);
- });
-
test("rejectBilateralByCommitmentBridge sends BilateralPayload", async () => {
let seenMethod = "";
let seenPayload: BilateralPayload | undefined;
diff --git a/dsm_client/frontend/src/dsm/transactions.ts b/dsm_client/frontend/src/dsm/transactions.ts
index 83897f3ba..40294365c 100644
--- a/dsm_client/frontend/src/dsm/transactions.ts
+++ b/dsm_client/frontend/src/dsm/transactions.ts
@@ -11,15 +11,11 @@ import {
cancelBilateralByCommitmentBridge,
rejectBilateralByCommitmentBridge,
getPendingBilateralListStrictBridge,
- setBleIdentityForAdvertising,
- startBleAdvertisingViaRouter,
- startBleScanViaRouter,
readPeerRelationshipStatusBridge,
} from './WebViewBridge';
import { on as eventBridgeOn } from './EventBridge';
import { emitBilateralCommitted } from './events';
import { bridgeEvents } from '../bridge/bridgeEvents';
-import { getHeaders } from './identity';
import { normalizeBleAddress } from './resolution';
import logger from '../utils/logger';
@@ -183,8 +179,6 @@ export async function offlineSend(transfer: GenericTransaction): Promise {});
if (resolvePromise) resolvePromise(res);
};
@@ -273,24 +267,11 @@ export async function offlineSend(transfer: GenericTransaction): Promise setTimeout(r, 1500));
- } catch {
- // Best-effort — proceed with send even if BLE priming fails
- }
-
- // --- Delegate native authoring + BLE dispatch to wallet.sendOffline ---
+ // --- Native authoring + BLE dispatch: wallet.sendOffline ---
+ // The radio is native's: it advertises while the device has an identity,
+ // and the dispatch connects (scanning for the peer as it needs) itself.
+ // This used to set the advertised identity, start advertising and
+ // scanning, and sleep 1.5 s first, swallowing every failure.
const respBytes = await routerInvokeBin('wallet.sendOffline', new Uint8Array(argPack.toBinary()));
if (!respBytes || respBytes.length === 0) {
finish({ accepted: false, result: 'offlineSend: empty response from bridge' });
diff --git a/dsm_client/frontend/src/proto/dsm_app_pb.ts b/dsm_client/frontend/src/proto/dsm_app_pb.ts
index 729ce1beb..6f6c17319 100644
--- a/dsm_client/frontend/src/proto/dsm_app_pb.ts
+++ b/dsm_client/frontend/src/proto/dsm_app_pb.ts
@@ -976,26 +976,6 @@ export enum NativeHostRequestKind {
*/
HOST_CONTROL_QR_STOP_SCAN = 3,
- /**
- * @generated from enum value: NATIVE_HOST_REQUEST_KIND_HOST_CONTROL_BLE_SCAN_START = 4;
- */
- HOST_CONTROL_BLE_SCAN_START = 4,
-
- /**
- * @generated from enum value: NATIVE_HOST_REQUEST_KIND_HOST_CONTROL_BLE_SCAN_STOP = 5;
- */
- HOST_CONTROL_BLE_SCAN_STOP = 5,
-
- /**
- * @generated from enum value: NATIVE_HOST_REQUEST_KIND_HOST_CONTROL_BLE_ADVERTISE_START = 6;
- */
- HOST_CONTROL_BLE_ADVERTISE_START = 6,
-
- /**
- * @generated from enum value: NATIVE_HOST_REQUEST_KIND_HOST_CONTROL_BLE_ADVERTISE_STOP = 7;
- */
- HOST_CONTROL_BLE_ADVERTISE_STOP = 7,
-
/**
* @generated from enum value: NATIVE_HOST_REQUEST_KIND_HOST_CONTROL_NFC_READER_START = 8;
*/
@@ -1034,10 +1014,6 @@ proto3.util.setEnumType(NativeHostRequestKind, "dsm.NativeHostRequestKind", [
{ no: 1, name: "NATIVE_HOST_REQUEST_KIND_HOST_CONTROL_CAPABILITIES_GET" },
{ no: 2, name: "NATIVE_HOST_REQUEST_KIND_HOST_CONTROL_QR_START_SCAN" },
{ no: 3, name: "NATIVE_HOST_REQUEST_KIND_HOST_CONTROL_QR_STOP_SCAN" },
- { no: 4, name: "NATIVE_HOST_REQUEST_KIND_HOST_CONTROL_BLE_SCAN_START" },
- { no: 5, name: "NATIVE_HOST_REQUEST_KIND_HOST_CONTROL_BLE_SCAN_STOP" },
- { no: 6, name: "NATIVE_HOST_REQUEST_KIND_HOST_CONTROL_BLE_ADVERTISE_START" },
- { no: 7, name: "NATIVE_HOST_REQUEST_KIND_HOST_CONTROL_BLE_ADVERTISE_STOP" },
{ no: 8, name: "NATIVE_HOST_REQUEST_KIND_HOST_CONTROL_NFC_READER_START" },
{ no: 9, name: "NATIVE_HOST_REQUEST_KIND_HOST_CONTROL_NFC_READER_STOP" },
{ no: 10, name: "NATIVE_HOST_REQUEST_KIND_HOST_CONTROL_PERMISSIONS_REQUEST" },
@@ -1060,11 +1036,6 @@ export enum NativeHostEventKind {
*/
QR_SCAN_RESULT = 1,
- /**
- * @generated from enum value: NATIVE_HOST_EVENT_KIND_BLUETOOTH_PERMISSIONS = 2;
- */
- BLUETOOTH_PERMISSIONS = 2,
-
/**
* @generated from enum value: NATIVE_HOST_EVENT_KIND_BIOMETRIC_RESULT = 3;
*/
@@ -1089,7 +1060,6 @@ export enum NativeHostEventKind {
proto3.util.setEnumType(NativeHostEventKind, "dsm.NativeHostEventKind", [
{ no: 0, name: "NATIVE_HOST_EVENT_KIND_UNSPECIFIED" },
{ no: 1, name: "NATIVE_HOST_EVENT_KIND_QR_SCAN_RESULT" },
- { no: 2, name: "NATIVE_HOST_EVENT_KIND_BLUETOOTH_PERMISSIONS" },
{ no: 3, name: "NATIVE_HOST_EVENT_KIND_BIOMETRIC_RESULT" },
{ no: 4, name: "NATIVE_HOST_EVENT_KIND_NFC_TAG_READ" },
{ no: 5, name: "NATIVE_HOST_EVENT_KIND_NFC_TAG_WRITE" },
@@ -22964,12 +22934,6 @@ export class BridgeRpcRequest extends Message {
*/
value: BleAddressPayload;
case: "bleAddress";
- } | {
- /**
- * @generated from field: dsm.BleIdentityPayload ble_identity = 10;
- */
- value: BleIdentityPayload;
- case: "bleIdentity";
} | {
/**
* @generated from field: dsm.BilateralPayload bilateral = 11;
@@ -22994,7 +22958,6 @@ export class BridgeRpcRequest extends Message {
{ no: 6, name: "app_router", kind: "message", T: AppRouterPayload, oneof: "payload" },
{ no: 8, name: "ble_contact", kind: "message", T: BleContactPayload, oneof: "payload" },
{ no: 9, name: "ble_address", kind: "message", T: BleAddressPayload, oneof: "payload" },
- { no: 10, name: "ble_identity", kind: "message", T: BleIdentityPayload, oneof: "payload" },
{ no: 11, name: "bilateral", kind: "message", T: BilateralPayload, oneof: "payload" },
]);
@@ -23332,49 +23295,6 @@ export class BleAddressPayload extends Message {
}
}
-/**
- * @generated from message dsm.BleIdentityPayload
- */
-export class BleIdentityPayload extends Message {
- /**
- * @generated from field: bytes genesis_hash = 1;
- */
- genesisHash = new Uint8Array(0);
-
- /**
- * @generated from field: bytes device_id = 2;
- */
- deviceId = new Uint8Array(0);
-
- constructor(data?: PartialMessage) {
- super();
- proto3.util.initPartial(data, this);
- }
-
- static readonly runtime: typeof proto3 = proto3;
- static readonly typeName = "dsm.BleIdentityPayload";
- static readonly fields: FieldList = proto3.util.newFieldList(() => [
- { no: 1, name: "genesis_hash", kind: "scalar", T: 12 /* ScalarType.BYTES */ },
- { no: 2, name: "device_id", kind: "scalar", T: 12 /* ScalarType.BYTES */ },
- ]);
-
- static fromBinary(bytes: Uint8Array, options?: Partial): BleIdentityPayload {
- return new BleIdentityPayload().fromBinary(bytes, options);
- }
-
- static fromJson(jsonValue: JsonValue, options?: Partial): BleIdentityPayload {
- return new BleIdentityPayload().fromJson(jsonValue, options);
- }
-
- static fromJsonString(jsonString: string, options?: Partial): BleIdentityPayload {
- return new BleIdentityPayload().fromJsonString(jsonString, options);
- }
-
- static equals(a: BleIdentityPayload | PlainMessage | undefined, b: BleIdentityPayload | PlainMessage | undefined): boolean {
- return proto3.util.equals(BleIdentityPayload, a, b);
- }
-}
-
/**
* @generated from message dsm.BilateralPayload
*/
diff --git a/proto/dsm_app.proto b/proto/dsm_app.proto
index ae61e3858..48ac0e2f8 100644
--- a/proto/dsm_app.proto
+++ b/proto/dsm_app.proto
@@ -3689,9 +3689,13 @@ message BridgeRpcRequest {
AppRouterPayload app_router = 6;
BleContactPayload ble_contact = 8;
BleAddressPayload ble_address = 9;
- BleIdentityPayload ble_identity = 10;
BilateralPayload bilateral = 11;
}
+ // The frontend handed the device's own identity to the BLE layer to
+ // advertise. The identity is Rust's: the GATT server reads it from Rust
+ // when a peer asks for it.
+ reserved 10;
+ reserved "ble_identity";
}
message BridgeRpcResponse {
@@ -3729,11 +3733,6 @@ message BleAddressPayload {
bytes device_id = 1 [(dsm_fixed_len)=32];
}
-message BleIdentityPayload {
- bytes genesis_hash = 1 [(dsm_fixed_len)=32];
- bytes device_id = 2 [(dsm_fixed_len)=32];
-}
-
message BilateralPayload {
bytes commitment = 1 [(dsm_fixed_len)=32];
optional string reason = 2 [(dsm_max_len)=1024]; // for reject operations
@@ -3901,10 +3900,14 @@ enum NativeHostRequestKind {
NATIVE_HOST_REQUEST_KIND_HOST_CONTROL_CAPABILITIES_GET = 1;
NATIVE_HOST_REQUEST_KIND_HOST_CONTROL_QR_START_SCAN = 2;
NATIVE_HOST_REQUEST_KIND_HOST_CONTROL_QR_STOP_SCAN = 3;
- NATIVE_HOST_REQUEST_KIND_HOST_CONTROL_BLE_SCAN_START = 4;
- NATIVE_HOST_REQUEST_KIND_HOST_CONTROL_BLE_SCAN_STOP = 5;
- NATIVE_HOST_REQUEST_KIND_HOST_CONTROL_BLE_ADVERTISE_START = 6;
- NATIVE_HOST_REQUEST_KIND_HOST_CONTROL_BLE_ADVERTISE_STOP = 7;
+ // When the radio scans and advertises is native policy, not a frontend
+ // request: advertising follows the identity, scanning follows a send or a
+ // pairing that needs it.
+ reserved 4 to 7;
+ reserved "NATIVE_HOST_REQUEST_KIND_HOST_CONTROL_BLE_SCAN_START",
+ "NATIVE_HOST_REQUEST_KIND_HOST_CONTROL_BLE_SCAN_STOP",
+ "NATIVE_HOST_REQUEST_KIND_HOST_CONTROL_BLE_ADVERTISE_START",
+ "NATIVE_HOST_REQUEST_KIND_HOST_CONTROL_BLE_ADVERTISE_STOP";
NATIVE_HOST_REQUEST_KIND_HOST_CONTROL_NFC_READER_START = 8;
NATIVE_HOST_REQUEST_KIND_HOST_CONTROL_NFC_READER_STOP = 9;
NATIVE_HOST_REQUEST_KIND_HOST_CONTROL_PERMISSIONS_REQUEST = 10;
@@ -3942,7 +3945,10 @@ message NativeHostResponse {
enum NativeHostEventKind {
NATIVE_HOST_EVENT_KIND_UNSPECIFIED = 0;
NATIVE_HOST_EVENT_KIND_QR_SCAN_RESULT = 1;
- NATIVE_HOST_EVENT_KIND_BLUETOOTH_PERMISSIONS = 2;
+ // A Bluetooth permission result is native's to act on (it starts the radio
+ // the policy wants); the frontend had nothing listening for it.
+ reserved 2;
+ reserved "NATIVE_HOST_EVENT_KIND_BLUETOOTH_PERMISSIONS";
NATIVE_HOST_EVENT_KIND_BIOMETRIC_RESULT = 3;
NATIVE_HOST_EVENT_KIND_NFC_TAG_READ = 4;
NATIVE_HOST_EVENT_KIND_NFC_TAG_WRITE = 5;
diff --git a/specs/requirements/CONFORMANCE_GAPS.md b/specs/requirements/CONFORMANCE_GAPS.md
index d5d7a2356..9bcd3bd47 100644
--- a/specs/requirements/CONFORMANCE_GAPS.md
+++ b/specs/requirements/CONFORMANCE_GAPS.md
@@ -1030,6 +1030,7 @@ Owner request: integrate the frontend with the storage nodes properly, working f
| frontend · stores/contactsStore.ts (`mapContacts`), contexts/ContactsContext.tsx (`Contact`), components/screens/ContactsTabScreen.tsx, wallet/hooks/useWalletScreenData.ts | Two mappers read Rust's contact list into two shapes: the contacts store's `Contact` (`id`, `publicKey`, `isVerified`, the raw BLE address) for the contacts screen, and `mapContactList`'s `DomainContact` (the signing key, `genesisVerifiedOnline`, the normalised BLE address or the one the native side resolved this session, and the send-readiness Rust reports) for the send tab — so the wallet screen kept its own copy of the contacts, re-read from Rust beside the store's. | One shape, `DomainContact`, from the one mapper, in the contacts store; `Contact` and `mapContacts` are deleted; the contacts screen reads `deviceId`, `signingPublicKey` and `genesisVerifiedOnline`; the wallet screen reads the store's contacts and re-reads nothing. A manual refresh reloads both stores. |
| frontend · bridge/nativeBridgeAdapter.ts, bridge/bridgeEvents.ts, dsm/EventBridge.ts, dsm/identity.ts (`getIdentity`'s wake-up), dsm/events.ts (`DSM_WALLET_REFRESH_EVENT`), hooks/useWalletSync.ts, contexts/WalletContext.tsx, contexts/ContactsContext.tsx, components/common/LoadingSpinner.tsx | The adapter re-emitted seven DOM events on the bus that nothing ever dispatched (`dsm-history-updated`, `dsm-balances-updated`, `dsm-wallet-send-committed`, `dsm-contact-added`, `DSM_PORT_TX`, `DSM_PORT_RX`, `DSM_UI_TICK`), and `useWalletSync`, the contacts provider and the loading spinner subscribed to the bus events they would have produced — reloads and an activity indicator that could never fire. The native lifecycle topics reached the bus through DOM hops: `dsm-identity-ready` was dispatched on `document`, re-emitted by the adapter, and listened for by `getIdentity`'s early wake-up on `window`, where it never arrived; `dsm-wallet-refresh` and `dsm-env-config-error` likewise went DOM → adapter → bus. `session.state` and `bilateral.event` were also fanned out as DOM events with no listener. | The seven hops, their bus event types, `useWalletSync` (its one live subscription, `identity.ready`, is the wallet provider's own), the contacts provider's `contact.added` subscription and the spinner's activity effect are deleted. The event bridge emits `identity.ready`, `wallet.refresh` (`native`) and `env.config.error` on the bus directly; the adapter keeps only `visibilitychange`; `getIdentity` wakes on the bus event, which now reaches it; `DSM_WALLET_REFRESH_EVENT` and the two listener-less fan-outs are deleted. |
| frontend · hooks/useBridgeEvents.ts (`useBridgeEvent`), bridge/bridgeEvents.ts, contexts/UXContext.tsx, contexts/BleContext.tsx, dsm/EventBridge.ts | `useBridgeEvent` took any string, so subscriptions to events nothing can emit compiled: two toasts (`ble.permission.recovery.needed`, `ble.features.disabled`) and a `ble.features.enabled` handler — the last not even a bus event — which together drove a `bleFeaturesDisabled` flag that gated every BLE call and could never be set. Two BLE advertising events were emitted with no consumer left, and `nfc.writeStarted` had neither. | `useBridgeEvent`'s name is `keyof BridgeEventMap`: a subscription to a name the bus does not carry does not compile. The dead subscriptions, the flag and its gate, the two emits and the three event types are deleted — and with the gate, `BleContext` itself: a provider whose context nothing consumed since `useBle` went (its scan state and four no-op calls), mounted in `App` for nothing. `wallet.exitCompleted`, subscribed to in three places and emitted by nothing, is Bitcoin's exit flow and is not touched. |
+| Kotlin · service/BleBackgroundService.kt, ui/MainActivity.kt, bridge/NativeHostBridge.kt, bridge/ble/GattServerHost.kt, bridge/ble/BleCoordinator.kt, bridge/UnifiedBleBridge.kt, bridge/BridgeBleHandler.kt, bridge/BridgeEnvelopeCodec.kt, bridge/SinglePathWebViewBridge.kt, debug/PairingTestActivity.kt; `dsm_sdk` · bluetooth/pairing_orchestrator.rs `stop_ble_discovery`; `proto` · `BridgeRpcRequest.ble_identity`, `BleIdentityPayload`, `NativeHostRequestKind` 4–7, `NativeHostEventKind` 2; frontend · dsm/transactions.ts `offlineSend`, contexts/ContactsContext.tsx, EnhancedWalletScreen.tsx, dsm/WebViewBridge/ble.ts, dsm/NativeHostBridge.ts, dsm/EventBridge.ts | The frontend ran the radio. Before each offline send it set the identity the GATT server would serve, started advertising and scanning through host requests and slept 1.5 s, swallowing every failure, and restarted advertising when the send finished. The contacts provider set the identity and started advertising on identity readiness and on each learned BLE address — only once some contact already had an address. The wallet screen started advertising on mount and when shown, and stopped it when hidden and on unmount, so a device on any other screen, or with no BLE contact yet, could not be reached. Native never started advertising on a cold start: `onResume` ran before the identity loaded, and a permission grant was handed to the UI as the `BLUETOOTH_PERMISSIONS` host event. The pairing loop stopped advertising when it ended. The GATT identity characteristic served whatever the frontend had pushed last. A debug activity with start and stop advertising buttons shipped in the production manifest. | Advertising follows the identity, and native owns it: the BLE service advertises whenever the device has an identity, derived again on the service's own worker thread when the service starts, when it binds, when the activity resumes, once genesis or init has produced the identity, when a Bluetooth permission is granted and when the adapter turns on; it stops only with the service. The GATT server reads the identity characteristic from Rust at read time. The frontend sends no radio request: `offlineSend` is one `wallet.sendOffline` call, whose dispatch connects to the peer and scans as it needs; the screen and the contacts provider render and refresh. Deleted: the four BLE host controls (`NativeHostRequestKind` 4–7 reserved), `NativeHostEventKind.BLUETOOTH_PERMISSIONS` (2 reserved), `BridgeRpcRequest.ble_identity` (10 reserved) with `BleIdentityPayload` and the `setBleIdentityForAdvertising` RPC, `stopBlePairingAdvertise`, `BleCoordinator.setIdentityValue` and the caller-less `ensureBleReady`, the GATT host's pushed identity value, and `PairingTestActivity`. The pairing loop stops only its scan. The native lifecycle is compile-checked, not run: a device run is what shows advertising begins on a cold start, after genesis, on a permission grant and on adapter-on. |
Tests: `dsm_sdk::handlers::storage_routes::tests::storage_status_reports_the_pinned_set_and_each_members_own_answer` (the router's answer over real nodes on Postgres; then one member stops serving), `dsm_sdk::sdk::storage_node_sdk::tests::a_members_latest_bytecommit_is_its_own_or_there_is_none`, `dsm_sdk::storage::client_db::tests::a_database_that_does_not_exist_has_no_size`; frontend `dsm/__tests__/storage.test.ts` and `components/storage/__tests__/StorageNodePanels.test.tsx`. Mutation controls, each red on its named test: another member's ByteCommit accepted as this member's (`a_members_latest_bytecommit_is_its_own_or_there_is_none`); a missing database file reported as 0 bytes (`a_database_that_does_not_exist_has_no_size`); a member that did not answer reported as "no cycle" (`storage_status_reports_the_pinned_set_and_each_members_own_answer`); the frontend inventing an answer for a member that carries none (`a member that carries no answer is refused, never given one`); every member counted as answering (`shows the set and counts only the members that gave an answer`).
@@ -1069,11 +1070,14 @@ Tests for the event plumbing: frontend `tests/E2E.uiCoordination.test.tsx` · `R
Tests for typed subscriptions: none added — a type. Compile control: a `useBridgeEvent('nothing.emits', …)` added to a provider fails `tsc`, naming the file.
+Tests for the radio: frontend `dsm/__tests__/offlineSend.radio.test.ts` · `an offline send asks for the send and nothing else — no host request, no identity relay, no wait`; `components/screens/__tests__/EnhancedWalletScreen.events.test.tsx` · `the wallet screen makes no radio request as it mounts, hides, shows and unmounts`; `contexts/__tests__/ContactsContext.radio.test.tsx` · `identity readiness and a learned BLE address reach the contact list, not the radio`. Mutation controls, each red on its named test: the awaited scan start with its failure swallowed; the 1.5 s settle; the re-advertise when a send finishes; the screen's advertising lifecycle; advertising on identity readiness; the identity relay on a learned address. The native side has no unit test: its lifecycle is Android framework callbacks, and a seam to drive them would be a test path in the production service.
+
**Open**
-- frontend · dsm/EventBridge.ts: `dsm-biometric-result`, `bluetooth-permissions` and `ble-dev-automation` are still dispatched as window events with no listener in the frontend; whether device automation or the biometric flow reads them from outside the bundle is a device question, so they stay until a device run says.
+- frontend · dsm/EventBridge.ts: `dsm-biometric-result` and `ble-dev-automation` are still dispatched as window events with no listener in the frontend; whether device automation or the biometric flow reads them from outside the bundle is a device question, so they stay until a device run says. `bluetooth-permissions` went with its producer, the `BLUETOOTH_PERMISSIONS` host event.
-- frontend · `offlineSend` primes BLE advertising and scanning itself and sleeps 1.5 s before `wallet.sendOffline`, and ContactsContext's `ensureBleAdvertisingIfContacts` starts advertising on contact events: transport orchestration above Rust, with failures swallowed. The contacts mapper also falls back to BLE addresses the native side resolved this session (`dsm/resolution.ts`). Changing either needs a device run.
+- frontend · the BLE address of an offline send: `offlineSend` sends the address the contacts mapper chose, and the mapper falls back to addresses the native side resolved this session (`dsm/resolution.ts`); which address a transfer goes to is Rust's to resolve from the counterparty's device id. The radio priming this bullet also named is resolved above.
+- Kotlin · AndroidManifest.xml `PicoSelfTestActivity`: a bench self-test its own comment calls debug bring-up only, exported and launched on USB attach in the production manifest. The hardware track's.
- frontend · SofiScreen: orders a vault's token pair bytewise before sending it (§28) — a protocol rule applied above Rust. The SoFi track's screen.
- frontend · dsm/transactions.ts `schedulePostAcceptRefreshes`: after Accept, four re-reads of the wallet on a frame cadence (0/0.5/1/2 s) beside Rust's TRANSFER_COMPLETE announcement, kept because whether the announcement alone reaches the screen on a device is undecided; a device run decides, and the cadence goes if it does.
- frontend · services/recovery/nfcRecoveryService.ts `getNfcBackupStatus`: reads `recovery.status` as `key=value` text inside an `AppStateResponse` and fills a missing `capsule_count`/`last_capsule_index` with 0 — a text protocol on a DSM path. Recovery is a dependency boundary this round; the route and its reader change together when the recovery specification is in scope.
From 29f100320db84fef3e85eb32d4d3955fe16ed80e Mon Sep 17 00:00:00 2001
From: Cryptskii <47649969+cryptskii@users.noreply.github.com>
Date: Sat, 26 Sep 2026 11:33:43 -0400
Subject: [PATCH 02/11] fix(ble): the relationship-status read nothing called
is gone end to end
The GATT service carried a relationship-status characteristic answering any
connected peer with this device's send status for the contact at that
address, and a client read of it ran from the frontend's
readPeerRelationshipStatus through a bridge RPC and a blocking coordinator
call. Nothing called the frontend function, so the server served a value no
client read.
Deleted: the characteristic and its UUID, the client read with its event and
pending-read slot, the bridge RPC and its Kotlin arm, the JNI export and
BleRelationshipStatusCharValue. Also BleCoordinator.readPeerIdentity, which
answered true whatever happened beside a "for now" comment and had no
caller, and setSessionMode with BleSessionMode: nothing set the mode, so the
scanner's was always idle and read only by a log line.
A relationship's send status is where Rust already reports it, on the
contact list. Gate control: the bridge-name gate refuses the Kotlin arm left
behind, naming it.
---
.../wallet/bridge/SinglePathWebViewBridge.kt | 16 ----
.../java/com/dsm/wallet/bridge/Unified.kt | 7 --
.../com/dsm/wallet/bridge/UnifiedNativeApi.kt | 3 -
.../com/dsm/wallet/bridge/ble/BleConstants.kt | 1 -
.../dsm/wallet/bridge/ble/BleCoordinator.kt | 64 --------------
.../com/dsm/wallet/bridge/ble/BleScanner.kt | 7 +-
.../dsm/wallet/bridge/ble/BleSessionEvent.kt | 1 -
.../dsm/wallet/bridge/ble/BleSessionMode.kt | 12 ---
.../wallet/bridge/ble/GattClientSession.kt | 39 ---------
.../dsm/wallet/bridge/ble/GattServerHost.kt | 60 -------------
.../com/dsm/wallet/bridge/ble/PeerSession.kt | 3 -
.../dsm_sdk/src/jni/ble_events.rs | 87 -------------------
.../frontend/src/dsm/WebViewBridge/ble.ts | 15 +---
.../frontend/src/dsm/WebViewBridge/index.ts | 1 -
dsm_client/frontend/src/dsm/transactions.ts | 13 ---
dsm_client/frontend/src/proto/dsm_app_pb.ts | 46 ----------
proto/dsm_app.proto | 7 --
specs/requirements/CONFORMANCE_GAPS.md | 4 +
18 files changed, 8 insertions(+), 378 deletions(-)
delete mode 100644 dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/ble/BleSessionMode.kt
diff --git a/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/SinglePathWebViewBridge.kt b/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/SinglePathWebViewBridge.kt
index 1b1a24bc4..c0e52db3b 100644
--- a/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/SinglePathWebViewBridge.kt
+++ b/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/SinglePathWebViewBridge.kt
@@ -289,22 +289,6 @@ class SinglePathWebViewBridge(private val context: Context) {
UnifiedContactBridge.resolveBleAddressForDeviceIdBin(payload)
}
- "readPeerRelationshipStatus" -> {
- val bleAddress = payload.toString(Charsets.UTF_8).trim()
- if (bleAddress.isEmpty()) {
- ByteArray(0)
- } else {
- try {
- BleCoordinator.getInstance(inst.context)
- .readPeerRelationshipStatus(bleAddress)
- ?: ByteArray(0)
- } catch (t: Throwable) {
- Log.w(TAG, "readPeerRelationshipStatus failed for $bleAddress", t)
- ByteArray(0)
- }
- }
- }
-
// Diagnostics: append raw payload to persisted bridge log
"diagnosticsLog" -> {
BridgeLogger.logDiagnosticsPayload(payload)
diff --git a/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/Unified.kt b/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/Unified.kt
index 1d79ad41e..b7709a531 100644
--- a/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/Unified.kt
+++ b/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/Unified.kt
@@ -314,13 +314,6 @@ object Unified {
@Keep @JvmStatic fun encodeIdentityCharValue(genesisHash: ByteArray, deviceId: ByteArray): ByteArray =
UnifiedNativeApi.encodeIdentityCharValue(genesisHash, deviceId)
- /**
- * Encode the local relationship send-status protobuf for the connected BLE peer.
- * Rust owns the relationship-readiness logic; Kotlin relays the raw bytes.
- */
- @Keep @JvmStatic fun getRelationshipStatusCharValue(bleAddress: String): ByteArray =
- UnifiedNativeApi.getRelationshipStatusCharValue(bleAddress)
-
/**
* Process raw protobuf bytes read from the GATT identity characteristic.
* Rust decodes BleIdentityCharValue, dispatches identity events, and returns
diff --git a/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/UnifiedNativeApi.kt b/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/UnifiedNativeApi.kt
index 79eb49d11..488754ea1 100644
--- a/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/UnifiedNativeApi.kt
+++ b/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/UnifiedNativeApi.kt
@@ -127,9 +127,6 @@ internal object UnifiedNativeApi {
// Kotlin MUST NOT concatenate raw bytes — this is the canonical encoder.
@Keep @JvmStatic external fun encodeIdentityCharValue(genesisHash: ByteArray, deviceId: ByteArray): ByteArray
- // Encode the local relationship send-status protobuf for a connected BLE peer.
- @Keep @JvmStatic external fun getRelationshipStatusCharValue(bleAddress: String): ByteArray
-
// Process raw protobuf bytes read from GATT identity characteristic.
// Decodes BleIdentityCharValue, dispatches identity events, returns BleGattIdentityReadResult.
// Kotlin MUST NOT split or interpret the raw bytes.
diff --git a/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/ble/BleConstants.kt b/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/ble/BleConstants.kt
index 7bcadd27f..186e46736 100644
--- a/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/ble/BleConstants.kt
+++ b/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/ble/BleConstants.kt
@@ -15,7 +15,6 @@ object BleConstants {
val TX_REQUEST_UUID: UUID = UUID.fromString("8e7f0002-7c07-4f3f-9b32-7bf3ba6c2a01")
val TX_RESPONSE_UUID: UUID = UUID.fromString("8e7f0003-7c07-4f3f-9b32-7bf3ba6c2a01")
val IDENTITY_UUID: UUID = UUID.fromString("8e7f00ff-7c07-4f3f-9b32-7bf3ba6c2a01")
- val RELATIONSHIP_STATUS_UUID: UUID = UUID.fromString("8e7f00fc-7c07-4f3f-9b32-7bf3ba6c2a01")
val PAIRING_UUID: UUID = UUID.fromString("8e7f00fe-7c07-4f3f-9b32-7bf3ba6c2a01")
val PAIRING_ACK_UUID: UUID = UUID.fromString("8e7f00fd-7c07-4f3f-9b32-7bf3ba6c2a01")
diff --git a/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/ble/BleCoordinator.kt b/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/ble/BleCoordinator.kt
index bb9fd4939..2ba08570e 100644
--- a/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/ble/BleCoordinator.kt
+++ b/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/ble/BleCoordinator.kt
@@ -12,7 +12,6 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
-import kotlinx.coroutines.withTimeoutOrNull
/**
* Public BLE Coordinator facade.
@@ -129,7 +128,6 @@ class BleCoordinator private constructor(private val context: Context) : BleScan
companion object {
/** Max time to wait for GATT connection readiness (connect + discover + MTU). */
private const val CONNECT_READY_TIMEOUT_MS = 12_000L
- private const val RELATIONSHIP_STATUS_READ_TIMEOUT_MS = 4_000L
private const val MAX_PENDING_PAIRING_CONFIRMS = 8
private var instance: BleCoordinator? = null
@@ -307,63 +305,6 @@ class BleCoordinator private constructor(private val context: Context) : BleScan
fun isAdvertising(): Boolean = advertiser.isAdvertising()
- /**
- * Set the current session mode.
- */
- fun setSessionMode(mode: BleSessionMode) {
- runOperation(BleOpLane.LIFECYCLE) {
- scanner.setSessionMode(mode)
- // Update session mode logic here if needed for other components
- }
- }
-
- /**
- * Read peer identity information.
- */
- fun readPeerIdentity(deviceAddress: String): Boolean {
- return runOperationBool(BleOpLane.PAIRING) {
- val session = getOrCreateSession(deviceAddress)
- session.readIdentity()
- // For now, just start the operation - result will be handled asynchronously
- true
- }
- }
-
- fun readPeerRelationshipStatus(deviceAddress: String): ByteArray? = runBlocking {
- val connected = withTimeoutOrNull(CONNECT_READY_TIMEOUT_MS + 2_000L) {
- connectToDevice(deviceAddress).await()
- } ?: false
- if (!connected) {
- return@runBlocking null
- }
-
- val deferred = CompletableDeferred()
- val started = runOperationBool(BleOpLane.PAIRING) {
- val resolved = resolveSession(deviceAddress)
- val peer = resolved?.first ?: peers[deviceAddress]
- val session = peer?.gattClientSession
- if (peer == null || session == null || !peer.isConnected) {
- deferred.complete(null)
- return@runOperationBool false
- }
- peer.relationshipStatusReadResult?.cancel()
- peer.relationshipStatusReadResult = deferred
- if (!session.readRelationshipStatus()) {
- peer.relationshipStatusReadResult = null
- deferred.complete(null)
- return@runOperationBool false
- }
- true
- }
- if (!started) {
- return@runBlocking null
- }
-
- withTimeoutOrNull(RELATIONSHIP_STATUS_READ_TIMEOUT_MS) {
- deferred.await()
- }
- }
-
/**
* Ensure GATT server is started.
*/
@@ -631,7 +572,6 @@ class BleCoordinator private constructor(private val context: Context) : BleScan
is BleSessionEvent.TransactionWriteCompleted,
is BleSessionEvent.ResponseReceived -> BleOpLane.TRANSFER
is BleSessionEvent.IdentityReadCompleted,
- is BleSessionEvent.RelationshipStatusReadCompleted,
is BleSessionEvent.MtuNegotiated,
is BleSessionEvent.PairingAckReceived,
is BleSessionEvent.PairingConfirmWritten -> BleOpLane.PAIRING
@@ -878,10 +818,6 @@ class BleCoordinator private constructor(private val context: Context) : BleScan
resumePairingScan(event.deviceAddress, "identity_read_failed")
}
}
- is BleSessionEvent.RelationshipStatusReadCompleted -> {
- peer.relationshipStatusReadResult?.complete(event.data)
- peer.relationshipStatusReadResult = null
- }
is BleSessionEvent.TransactionWriteCompleted -> {
val currentTx = peer.currentTransaction
if (currentTx != null) {
diff --git a/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/ble/BleScanner.kt b/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/ble/BleScanner.kt
index b117faa27..c07ae7550 100644
--- a/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/ble/BleScanner.kt
+++ b/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/ble/BleScanner.kt
@@ -28,7 +28,6 @@ class BleScanner(private val context: Context) {
private val scanning = AtomicBoolean(false)
private var bluetoothLeScanner: BluetoothLeScanner? = null
- private var currentSessionMode: BleSessionMode = BleSessionMode.IDLE
private var callback: Callback? = null
fun setCallback(callback: Callback) {
@@ -74,10 +73,6 @@ class BleScanner(private val context: Context) {
}
}
- fun setSessionMode(mode: BleSessionMode) {
- currentSessionMode = mode
- }
-
/**
* Start BLE scanning.
*
@@ -127,7 +122,7 @@ class BleScanner(private val context: Context) {
bluetoothLeScanner?.startScan(filters, settings, scanCallback)
scanning.set(true)
val modeLabel = if (lowLatency) "LOW_LATENCY" else "BALANCED"
- Log.i("BleScanner", "BLE scan started ($modeLabel), mode: $currentSessionMode")
+ Log.i("BleScanner", "BLE scan started ($modeLabel)")
true
} catch (t: Throwable) {
Log.e("BleScanner", "Failed to start scan", t)
diff --git a/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/ble/BleSessionEvent.kt b/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/ble/BleSessionEvent.kt
index ca1af2b4c..e81b4d06b 100644
--- a/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/ble/BleSessionEvent.kt
+++ b/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/ble/BleSessionEvent.kt
@@ -14,7 +14,6 @@ sealed class BleSessionEvent {
data class MtuNegotiated(override val deviceAddress: String, val mtu: Int) : BleSessionEvent()
data class ServiceDiscoveryCompleted(override val deviceAddress: String, val success: Boolean) : BleSessionEvent()
data class IdentityReadCompleted(override val deviceAddress: String, val data: ByteArray?) : BleSessionEvent()
- data class RelationshipStatusReadCompleted(override val deviceAddress: String, val data: ByteArray?) : BleSessionEvent()
data class TransactionWriteCompleted(override val deviceAddress: String, val success: Boolean) : BleSessionEvent()
data class ResponseReceived(override val deviceAddress: String, val data: ByteArray) : BleSessionEvent()
/** Advertiser confirmed it processed our identity — bilateral pairing can complete. */
diff --git a/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/ble/BleSessionMode.kt b/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/ble/BleSessionMode.kt
deleted file mode 100644
index b8d0a1ae5..000000000
--- a/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/ble/BleSessionMode.kt
+++ /dev/null
@@ -1,12 +0,0 @@
-// SPDX-License-Identifier: MIT OR Apache-2.0
-
-package com.dsm.wallet.bridge.ble
-
-/**
- * BLE session modes for coordinating scanning and advertising behavior.
- */
-enum class BleSessionMode {
- IDLE,
- AWAITING_PEER_FOR_CONTACT,
- AWAITING_PEER_FOR_TRANSFER
-}
\ No newline at end of file
diff --git a/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/ble/GattClientSession.kt b/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/ble/GattClientSession.kt
index cf7c00602..4f88dae8d 100644
--- a/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/ble/GattClientSession.kt
+++ b/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/ble/GattClientSession.kt
@@ -67,7 +67,6 @@ class GattClientSession(
private var requestCharacteristic: BluetoothGattCharacteristic? = null
private var responseCharacteristic: BluetoothGattCharacteristic? = null
private var identityCharacteristic: BluetoothGattCharacteristic? = null
- private var relationshipStatusCharacteristic: BluetoothGattCharacteristic? = null
private var pairingCharacteristic: BluetoothGattCharacteristic? = null
private var pairingAckCharacteristic: BluetoothGattCharacteristic? = null
@@ -317,7 +316,6 @@ class GattClientSession(
requestCharacteristic = service.getCharacteristic(BleConstants.TX_REQUEST_UUID)
responseCharacteristic = service.getCharacteristic(BleConstants.TX_RESPONSE_UUID)
identityCharacteristic = service.getCharacteristic(BleConstants.IDENTITY_UUID)
- relationshipStatusCharacteristic = service.getCharacteristic(BleConstants.RELATIONSHIP_STATUS_UUID)
pairingCharacteristic = service.getCharacteristic(BleConstants.PAIRING_UUID)
pairingAckCharacteristic = service.getCharacteristic(BleConstants.PAIRING_ACK_UUID)
@@ -441,14 +439,6 @@ class GattClientSession(
emitEvent(BleSessionEvent.ErrorOccurred(deviceAddress, BleErrorCategory.CHARACTERISTIC_READ_FAILED, "identity_read", status))
}
}
- BleConstants.RELATIONSHIP_STATUS_UUID -> {
- if (status == BluetoothGatt.GATT_SUCCESS) {
- emitEvent(BleSessionEvent.RelationshipStatusReadCompleted(deviceAddress, characteristic.value))
- } else {
- emitEvent(BleSessionEvent.RelationshipStatusReadCompleted(deviceAddress, null))
- emitEvent(BleSessionEvent.ErrorOccurred(deviceAddress, BleErrorCategory.CHARACTERISTIC_READ_FAILED, "relationship_status_read", status))
- }
- }
}
// GATT op completed — drain next queued op.
drainNextGattOp()
@@ -650,7 +640,6 @@ class GattClientSession(
requestCharacteristic = null
responseCharacteristic = null
identityCharacteristic = null
- relationshipStatusCharacteristic = null
pairingCharacteristic = null
pairingAckCharacteristic = null
txResponseSubscribed = false
@@ -842,34 +831,6 @@ class GattClientSession(
}
}
- /**
- * Initiate relationship-status read operation.
- * Result is communicated via RelationshipStatusReadCompleted event.
- */
- fun readRelationshipStatus(): Boolean {
- val char = relationshipStatusCharacteristic
- if (char == null) {
- diagnostics.recordError(BleErrorCategory.CHARACTERISTIC_READ_FAILED, "relationship_status_no_char")
- emitEvent(BleSessionEvent.ErrorOccurred(deviceAddress, BleErrorCategory.CHARACTERISTIC_READ_FAILED, "relationship_status_no_char"))
- return false
- }
-
- return enqueueGattOp {
- try {
- bluetoothGatt?.readCharacteristic(char) == true
- } catch (e: SecurityException) {
- Log.e("GattClientSession", "Security exception reading relationship-status characteristic for $deviceAddress", e)
- BleCoordinator.getInstance(context).let { coordinator ->
- coordinator.permissionsGate.recordPermissionFailure()
- coordinator.callback?.onBlePermissionError("Bluetooth connection permission required")
- }
- diagnostics.recordError(BleErrorCategory.PERMISSION_DENIED, "relationship_status_read")
- emitEvent(BleSessionEvent.ErrorOccurred(deviceAddress, BleErrorCategory.PERMISSION_DENIED, "relationship_status_read"))
- false
- }
- }
- }
-
/**
* Send transaction data.
* Result is communicated via TransactionWriteCompleted event.
diff --git a/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/ble/GattServerHost.kt b/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/ble/GattServerHost.kt
index ea83af733..a42b8d587 100644
--- a/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/ble/GattServerHost.kt
+++ b/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/ble/GattServerHost.kt
@@ -157,9 +157,6 @@ class GattServerHost(private val context: Context) {
BleConstants.IDENTITY_UUID -> {
handleIdentityRead(device, requestId, offset)
}
- BleConstants.RELATIONSHIP_STATUS_UUID -> {
- handleRelationshipStatusRead(device, requestId, offset)
- }
else -> {
try {
gattServer.get()?.sendResponse(device, requestId, BluetoothGatt.GATT_FAILURE, 0, null)
@@ -649,13 +646,6 @@ class GattServerHost(private val context: Context) {
)
service.addCharacteristic(identityChar)
- val relationshipStatusChar = BluetoothGattCharacteristic(
- BleConstants.RELATIONSHIP_STATUS_UUID,
- BluetoothGattCharacteristic.PROPERTY_READ,
- BluetoothGattCharacteristic.PERMISSION_READ
- )
- service.addCharacteristic(relationshipStatusChar)
-
// TX Request characteristic (write-only)
val txRequestChar = BluetoothGattCharacteristic(
BleConstants.TX_REQUEST_UUID,
@@ -788,56 +778,6 @@ class GattServerHost(private val context: Context) {
null
}
- private fun handleRelationshipStatusRead(device: BluetoothDevice, requestId: Int, offset: Int) {
- val value = try {
- com.dsm.wallet.bridge.Unified.getRelationshipStatusCharValue(device.address)
- } catch (t: Throwable) {
- Log.w("GattServerHost", "Relationship-status read failed for ${device.address}", t)
- ByteArray(0)
- }
-
- if (value.isEmpty()) {
- try {
- gattServer.get()?.sendResponse(device, requestId, BluetoothGatt.GATT_FAILURE, 0, null)
- } catch (e: SecurityException) {
- Log.e("GattServerHost", "Security exception sending response to ${device.address}", e)
- BleCoordinator.getInstance(context).let { coordinator ->
- coordinator.permissionsGate.recordPermissionFailure()
- coordinator.callback?.onBlePermissionError("Bluetooth connection permission required")
- }
- }
- return
- }
- if (offset >= value.size) {
- try {
- gattServer.get()?.sendResponse(device, requestId, BluetoothGatt.GATT_INVALID_OFFSET, 0, null)
- } catch (e: SecurityException) {
- Log.e("GattServerHost", "Security exception sending response to ${device.address}", e)
- BleCoordinator.getInstance(context).let { coordinator ->
- coordinator.permissionsGate.recordPermissionFailure()
- coordinator.callback?.onBlePermissionError("Bluetooth connection permission required")
- }
- }
- return
- }
-
- val chunk = if (offset + BleConstants.MTU_SIZE > value.size) {
- value.copyOfRange(offset, value.size)
- } else {
- value.copyOfRange(offset, offset + BleConstants.MTU_SIZE)
- }
-
- try {
- gattServer.get()?.sendResponse(device, requestId, BluetoothGatt.GATT_SUCCESS, offset, chunk)
- } catch (e: SecurityException) {
- Log.e("GattServerHost", "Security exception sending response to ${device.address}", e)
- BleCoordinator.getInstance(context).let { coordinator ->
- coordinator.permissionsGate.recordPermissionFailure()
- coordinator.callback?.onBlePermissionError("Bluetooth connection permission required")
- }
- }
- }
-
private fun handleTxWrite(
device: BluetoothDevice,
requestId: Int,
diff --git a/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/ble/PeerSession.kt b/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/ble/PeerSession.kt
index 10d9e4b84..3bed6180b 100644
--- a/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/ble/PeerSession.kt
+++ b/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/ble/PeerSession.kt
@@ -51,7 +51,6 @@ data class PeerSession(
var currentTransaction: BleOutboxItem? = null,
var identityExchangeInProgress: Boolean = false,
var pairingInProgress: Boolean = false,
- @Transient var relationshipStatusReadResult: CompletableDeferred? = null,
// ── Connection lifecycle (was pendingConnectionAddresses + polling loop) ─
// When non-null, a connect is in flight. Completed by handleSessionEvent
@@ -108,8 +107,6 @@ data class PeerSession(
currentTransaction = null
identityExchangeInProgress = false
pairingInProgress = false
- relationshipStatusReadResult?.cancel()
- relationshipStatusReadResult = null
connectResult?.complete(false)
connectResult = null
pendingPairingConfirm = null
diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/jni/ble_events.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/jni/ble_events.rs
index 48d683abd..c12a2b720 100644
--- a/dsm_client/deterministic_state_machine/dsm_sdk/src/jni/ble_events.rs
+++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/jni/ble_events.rs
@@ -17,17 +17,6 @@ use std::collections::HashMap;
use std::sync::Mutex;
use once_cell::sync::Lazy;
-fn pb_send_status_from_router_status(
- status: dsm::types::proto::RelationshipSendStatus,
-) -> pb::RelationshipSendStatus {
- pb::RelationshipSendStatus {
- send_ready: status.send_ready,
- send_check_state: status.send_check_state,
- send_block_reason: status.send_block_reason,
- send_block_message: status.send_block_message,
- }
-}
-
/// Convert raw JNIEnv pointer to safe wrapper.
/// Returns None on failure instead of aborting the process.
#[inline]
@@ -1056,82 +1045,6 @@ pub extern "system" fn Java_com_dsm_wallet_bridge_UnifiedNativeApi_encodeIdentit
)
}
-/// Encode the local relationship send-status as a protobuf GATT characteristic value.
-#[no_mangle]
-pub extern "system" fn Java_com_dsm_wallet_bridge_UnifiedNativeApi_getRelationshipStatusCharValue(
- env: jni::sys::JNIEnv,
- _clazz: jni::sys::jclass,
- ble_address_jstr: jni::sys::jstring,
-) -> jni::sys::jbyteArray {
- crate::jni::bridge_utils::jni_catch_unwind_jbytearray(
- "getRelationshipStatusCharValue",
- std::panic::AssertUnwindSafe(|| {
- let mut env = match unsafe { env_from(env) } {
- Some(e) => e,
- None => return std::ptr::null_mut(),
- };
- let address_jstring = unsafe { jstr_from(ble_address_jstr) };
- let ble_address: String = match env.get_string(&address_jstring) {
- Ok(s) => s.into(),
- Err(e) => {
- log::error!(
- "getRelationshipStatusCharValue: JNI address extraction failed: {e}"
- );
- return empty(&mut env);
- }
- };
-
- let contact = match crate::storage::client_db::get_contact_by_ble_address(&ble_address)
- {
- Ok(Some(contact)) => contact,
- Ok(None) => {
- log::warn!(
- "getRelationshipStatusCharValue: no contact mapped to BLE address {}",
- ble_address
- );
- return empty(&mut env);
- }
- Err(e) => {
- log::error!(
- "getRelationshipStatusCharValue: failed to load contact for {}: {}",
- ble_address,
- e
- );
- return empty(&mut env);
- }
- };
-
- if contact.device_id.len() != 32 {
- log::error!(
- "getRelationshipStatusCharValue: contact device_id has invalid length {}",
- contact.device_id.len()
- );
- return empty(&mut env);
- }
-
- let send_status =
- crate::handlers::relationship_status::derive_local_send_status_for_contact(
- &contact,
- );
- let char_value = pb::BleRelationshipStatusCharValue {
- counterparty_device_id: contact.device_id.clone(),
- send_status: Some(pb_send_status_from_router_status(send_status)),
- };
-
- let encoded = char_value.encode_to_vec();
- match env.byte_array_from_slice(&encoded) {
- Ok(arr) => arr.into_raw(),
- Err(e) => {
- log::error!(
- "getRelationshipStatusCharValue: JNI byte_array_from_slice failed: {e}"
- );
- empty(&mut env)
- }
- }
- }),
- )
-}
-
/// Process raw protobuf bytes read from the GATT identity characteristic.
///
/// This is the canonical path for handling identity reads on the client (scanner) side.
diff --git a/dsm_client/frontend/src/dsm/WebViewBridge/ble.ts b/dsm_client/frontend/src/dsm/WebViewBridge/ble.ts
index f19e76ddc..d7053fa48 100644
--- a/dsm_client/frontend/src/dsm/WebViewBridge/ble.ts
+++ b/dsm_client/frontend/src/dsm/WebViewBridge/ble.ts
@@ -1,9 +1,8 @@
// SPDX-License-Identifier: Apache-2.0
-// BLE-related bridge calls the screens make: permissions, Bluetooth settings,
-// the pairing loop, and the peer relationship read. When the radio advertises
-// and scans is native policy; nothing here starts or stops it.
+// BLE-related bridge calls the screens make: permissions, Bluetooth settings
+// and the pairing loop. When the radio advertises and scans is native policy;
+// nothing here starts or stops it.
-import { bridgeGate } from "../BridgeGate";
import { callBin } from "./transportCore";
import { log } from "./log";
@@ -45,11 +44,3 @@ export async function resolveBleAddressForDeviceIdBridge(
const s = new TextDecoder().decode(resp).trim();
return s || undefined;
}
-
-export async function readPeerRelationshipStatusBridge(bleAddress: string): Promise {
- const normalized = String(bleAddress ?? "").trim();
- if (!normalized) return new Uint8Array(0);
- return bridgeGate.enqueue(() =>
- callBin("readPeerRelationshipStatus", new TextEncoder().encode(normalized))
- );
-}
diff --git a/dsm_client/frontend/src/dsm/WebViewBridge/index.ts b/dsm_client/frontend/src/dsm/WebViewBridge/index.ts
index 488aac243..009c1d216 100644
--- a/dsm_client/frontend/src/dsm/WebViewBridge/index.ts
+++ b/dsm_client/frontend/src/dsm/WebViewBridge/index.ts
@@ -36,7 +36,6 @@ export const {
export const {
openBluetoothSettings,
- readPeerRelationshipStatusBridge,
requestBlePermissions,
resolveBleAddressForDeviceIdBridge,
startPairingAll,
diff --git a/dsm_client/frontend/src/dsm/transactions.ts b/dsm_client/frontend/src/dsm/transactions.ts
index 40294365c..931a32f54 100644
--- a/dsm_client/frontend/src/dsm/transactions.ts
+++ b/dsm_client/frontend/src/dsm/transactions.ts
@@ -11,7 +11,6 @@ import {
cancelBilateralByCommitmentBridge,
rejectBilateralByCommitmentBridge,
getPendingBilateralListStrictBridge,
- readPeerRelationshipStatusBridge,
} from './WebViewBridge';
import { on as eventBridgeOn } from './EventBridge';
import { emitBilateralCommitted } from './events';
@@ -53,18 +52,6 @@ function schedulePostAcceptRefreshes(): void {
requestAnimationFrame(tick);
}
-export async function readPeerRelationshipStatus(
- bleAddress: string,
-): Promise {
- const normalized = normalizeBleAddress(bleAddress);
- if (!normalized) return null;
- const bytes = await readPeerRelationshipStatusBridge(normalized);
- if (!(bytes instanceof Uint8Array) || bytes.length === 0) {
- return null;
- }
- return pb.BleRelationshipStatusCharValue.fromBinary(bytes);
-}
-
export async function sendOnlineTransferSmart(
alias: string,
amount: string | number | bigint,
diff --git a/dsm_client/frontend/src/proto/dsm_app_pb.ts b/dsm_client/frontend/src/proto/dsm_app_pb.ts
index 6f6c17319..16135c1c3 100644
--- a/dsm_client/frontend/src/proto/dsm_app_pb.ts
+++ b/dsm_client/frontend/src/proto/dsm_app_pb.ts
@@ -13196,52 +13196,6 @@ export class BleGattIdentityReadResult extends Message {
- /**
- * @generated from field: bytes counterparty_device_id = 1;
- */
- counterpartyDeviceId = new Uint8Array(0);
-
- /**
- * @generated from field: dsm.RelationshipSendStatus send_status = 2;
- */
- sendStatus?: RelationshipSendStatus;
-
- constructor(data?: PartialMessage) {
- super();
- proto3.util.initPartial(data, this);
- }
-
- static readonly runtime: typeof proto3 = proto3;
- static readonly typeName = "dsm.BleRelationshipStatusCharValue";
- static readonly fields: FieldList = proto3.util.newFieldList(() => [
- { no: 1, name: "counterparty_device_id", kind: "scalar", T: 12 /* ScalarType.BYTES */ },
- { no: 2, name: "send_status", kind: "message", T: RelationshipSendStatus },
- ]);
-
- static fromBinary(bytes: Uint8Array, options?: Partial): BleRelationshipStatusCharValue {
- return new BleRelationshipStatusCharValue().fromBinary(bytes, options);
- }
-
- static fromJson(jsonValue: JsonValue, options?: Partial): BleRelationshipStatusCharValue {
- return new BleRelationshipStatusCharValue().fromJson(jsonValue, options);
- }
-
- static fromJsonString(jsonString: string, options?: Partial): BleRelationshipStatusCharValue {
- return new BleRelationshipStatusCharValue().fromJsonString(jsonString, options);
- }
-
- static equals(a: BleRelationshipStatusCharValue | PlainMessage | undefined, b: BleRelationshipStatusCharValue | PlainMessage | undefined): boolean {
- return proto3.util.equals(BleRelationshipStatusCharValue, a, b);
- }
-}
-
/**
* Response from processIncomingBleData JNI call.
* Rust decides internally whether to route as chunk or Envelope v3, runs any
diff --git a/proto/dsm_app.proto b/proto/dsm_app.proto
index 48ac0e2f8..64d626157 100644
--- a/proto/dsm_app.proto
+++ b/proto/dsm_app.proto
@@ -2055,13 +2055,6 @@ message BleGattIdentityReadResult {
bytes peer_genesis_hash = 5 [(dsm_fixed_len)=32]; // Peer's genesisHash
}
-// Protobuf value carried on the BLE relationship-status GATT characteristic.
-// The advertiser computes the status in Rust for the connected peer relationship.
-message BleRelationshipStatusCharValue {
- bytes counterparty_device_id = 1 [(dsm_fixed_len)=32];
- RelationshipSendStatus send_status = 2;
-}
-
// Response from processIncomingBleData JNI call.
// Rust decides internally whether to route as chunk or Envelope v3, runs any
// frame-type detection, and returns pre-chunked follow-up bytes for Kotlin to
diff --git a/specs/requirements/CONFORMANCE_GAPS.md b/specs/requirements/CONFORMANCE_GAPS.md
index 9bcd3bd47..30b27d047 100644
--- a/specs/requirements/CONFORMANCE_GAPS.md
+++ b/specs/requirements/CONFORMANCE_GAPS.md
@@ -1031,6 +1031,7 @@ Owner request: integrate the frontend with the storage nodes properly, working f
| frontend · bridge/nativeBridgeAdapter.ts, bridge/bridgeEvents.ts, dsm/EventBridge.ts, dsm/identity.ts (`getIdentity`'s wake-up), dsm/events.ts (`DSM_WALLET_REFRESH_EVENT`), hooks/useWalletSync.ts, contexts/WalletContext.tsx, contexts/ContactsContext.tsx, components/common/LoadingSpinner.tsx | The adapter re-emitted seven DOM events on the bus that nothing ever dispatched (`dsm-history-updated`, `dsm-balances-updated`, `dsm-wallet-send-committed`, `dsm-contact-added`, `DSM_PORT_TX`, `DSM_PORT_RX`, `DSM_UI_TICK`), and `useWalletSync`, the contacts provider and the loading spinner subscribed to the bus events they would have produced — reloads and an activity indicator that could never fire. The native lifecycle topics reached the bus through DOM hops: `dsm-identity-ready` was dispatched on `document`, re-emitted by the adapter, and listened for by `getIdentity`'s early wake-up on `window`, where it never arrived; `dsm-wallet-refresh` and `dsm-env-config-error` likewise went DOM → adapter → bus. `session.state` and `bilateral.event` were also fanned out as DOM events with no listener. | The seven hops, their bus event types, `useWalletSync` (its one live subscription, `identity.ready`, is the wallet provider's own), the contacts provider's `contact.added` subscription and the spinner's activity effect are deleted. The event bridge emits `identity.ready`, `wallet.refresh` (`native`) and `env.config.error` on the bus directly; the adapter keeps only `visibilitychange`; `getIdentity` wakes on the bus event, which now reaches it; `DSM_WALLET_REFRESH_EVENT` and the two listener-less fan-outs are deleted. |
| frontend · hooks/useBridgeEvents.ts (`useBridgeEvent`), bridge/bridgeEvents.ts, contexts/UXContext.tsx, contexts/BleContext.tsx, dsm/EventBridge.ts | `useBridgeEvent` took any string, so subscriptions to events nothing can emit compiled: two toasts (`ble.permission.recovery.needed`, `ble.features.disabled`) and a `ble.features.enabled` handler — the last not even a bus event — which together drove a `bleFeaturesDisabled` flag that gated every BLE call and could never be set. Two BLE advertising events were emitted with no consumer left, and `nfc.writeStarted` had neither. | `useBridgeEvent`'s name is `keyof BridgeEventMap`: a subscription to a name the bus does not carry does not compile. The dead subscriptions, the flag and its gate, the two emits and the three event types are deleted — and with the gate, `BleContext` itself: a provider whose context nothing consumed since `useBle` went (its scan state and four no-op calls), mounted in `App` for nothing. `wallet.exitCompleted`, subscribed to in three places and emitted by nothing, is Bitcoin's exit flow and is not touched. |
| Kotlin · service/BleBackgroundService.kt, ui/MainActivity.kt, bridge/NativeHostBridge.kt, bridge/ble/GattServerHost.kt, bridge/ble/BleCoordinator.kt, bridge/UnifiedBleBridge.kt, bridge/BridgeBleHandler.kt, bridge/BridgeEnvelopeCodec.kt, bridge/SinglePathWebViewBridge.kt, debug/PairingTestActivity.kt; `dsm_sdk` · bluetooth/pairing_orchestrator.rs `stop_ble_discovery`; `proto` · `BridgeRpcRequest.ble_identity`, `BleIdentityPayload`, `NativeHostRequestKind` 4–7, `NativeHostEventKind` 2; frontend · dsm/transactions.ts `offlineSend`, contexts/ContactsContext.tsx, EnhancedWalletScreen.tsx, dsm/WebViewBridge/ble.ts, dsm/NativeHostBridge.ts, dsm/EventBridge.ts | The frontend ran the radio. Before each offline send it set the identity the GATT server would serve, started advertising and scanning through host requests and slept 1.5 s, swallowing every failure, and restarted advertising when the send finished. The contacts provider set the identity and started advertising on identity readiness and on each learned BLE address — only once some contact already had an address. The wallet screen started advertising on mount and when shown, and stopped it when hidden and on unmount, so a device on any other screen, or with no BLE contact yet, could not be reached. Native never started advertising on a cold start: `onResume` ran before the identity loaded, and a permission grant was handed to the UI as the `BLUETOOTH_PERMISSIONS` host event. The pairing loop stopped advertising when it ended. The GATT identity characteristic served whatever the frontend had pushed last. A debug activity with start and stop advertising buttons shipped in the production manifest. | Advertising follows the identity, and native owns it: the BLE service advertises whenever the device has an identity, derived again on the service's own worker thread when the service starts, when it binds, when the activity resumes, once genesis or init has produced the identity, when a Bluetooth permission is granted and when the adapter turns on; it stops only with the service. The GATT server reads the identity characteristic from Rust at read time. The frontend sends no radio request: `offlineSend` is one `wallet.sendOffline` call, whose dispatch connects to the peer and scans as it needs; the screen and the contacts provider render and refresh. Deleted: the four BLE host controls (`NativeHostRequestKind` 4–7 reserved), `NativeHostEventKind.BLUETOOTH_PERMISSIONS` (2 reserved), `BridgeRpcRequest.ble_identity` (10 reserved) with `BleIdentityPayload` and the `setBleIdentityForAdvertising` RPC, `stopBlePairingAdvertise`, `BleCoordinator.setIdentityValue` and the caller-less `ensureBleReady`, the GATT host's pushed identity value, and `PairingTestActivity`. The pairing loop stops only its scan. The native lifecycle is compile-checked, not run: a device run is what shows advertising begins on a cold start, after genesis, on a permission grant and on adapter-on. |
+| Kotlin · bridge/ble/GattServerHost.kt, GattClientSession.kt, BleCoordinator.kt, PeerSession.kt, BleSessionEvent.kt, BleConstants.kt, BleScanner.kt, BleSessionMode.kt, bridge/SinglePathWebViewBridge.kt, Unified.kt, UnifiedNativeApi.kt; `dsm_sdk` · jni/ble_events.rs `getRelationshipStatusCharValue`; `proto` · `BleRelationshipStatusCharValue`; frontend · dsm/transactions.ts `readPeerRelationshipStatus`, dsm/WebViewBridge/ble.ts | A relationship-status characteristic on the GATT service answered any connected peer with this device's send status for the contact at that address, and a client read of it ran from the frontend's `readPeerRelationshipStatus` through a bridge RPC and a blocking coordinator call. Nothing called the frontend function, so the server served a value no client read. `BleCoordinator.readPeerIdentity` answered true whatever happened, beside a comment saying "for now", and had no caller; `setSessionMode` had none either, so the scanner's session mode was always idle and read only by a log line. | Deleted end to end: the characteristic and its UUID, the client read with its event and pending-read slot, the bridge RPC, the JNI export and the message; `readPeerIdentity`; `setSessionMode` with `BleSessionMode`. A relationship's send status is where Rust already reports it, on the contact list. |
Tests: `dsm_sdk::handlers::storage_routes::tests::storage_status_reports_the_pinned_set_and_each_members_own_answer` (the router's answer over real nodes on Postgres; then one member stops serving), `dsm_sdk::sdk::storage_node_sdk::tests::a_members_latest_bytecommit_is_its_own_or_there_is_none`, `dsm_sdk::storage::client_db::tests::a_database_that_does_not_exist_has_no_size`; frontend `dsm/__tests__/storage.test.ts` and `components/storage/__tests__/StorageNodePanels.test.tsx`. Mutation controls, each red on its named test: another member's ByteCommit accepted as this member's (`a_members_latest_bytecommit_is_its_own_or_there_is_none`); a missing database file reported as 0 bytes (`a_database_that_does_not_exist_has_no_size`); a member that did not answer reported as "no cycle" (`storage_status_reports_the_pinned_set_and_each_members_own_answer`); the frontend inventing an answer for a member that carries none (`a member that carries no answer is refused, never given one`); every member counted as answering (`shows the set and counts only the members that gave an answer`).
@@ -1072,12 +1073,15 @@ Tests for typed subscriptions: none added — a type. Compile control: a `useBri
Tests for the radio: frontend `dsm/__tests__/offlineSend.radio.test.ts` · `an offline send asks for the send and nothing else — no host request, no identity relay, no wait`; `components/screens/__tests__/EnhancedWalletScreen.events.test.tsx` · `the wallet screen makes no radio request as it mounts, hides, shows and unmounts`; `contexts/__tests__/ContactsContext.radio.test.tsx` · `identity readiness and a learned BLE address reach the contact list, not the radio`. Mutation controls, each red on its named test: the awaited scan start with its failure swallowed; the 1.5 s settle; the re-advertise when a send finishes; the screen's advertising lifecycle; advertising on identity readiness; the identity relay on a learned address. The native side has no unit test: its lifecycle is Android framework callbacks, and a seam to drive them would be a test path in the production service.
+Tests for the dead BLE surface: none added — deletions; the Kotlin main, androidTest and unit-test compiles, the Android check of the SDK, the frontend suite and every gate ran on the result. Gate control: `ci/bridge_rpc_names.py` refuses the Kotlin `readPeerRelationshipStatus` arm left behind once the frontend no longer sends it, naming it.
+
**Open**
- frontend · dsm/EventBridge.ts: `dsm-biometric-result` and `ble-dev-automation` are still dispatched as window events with no listener in the frontend; whether device automation or the biometric flow reads them from outside the bundle is a device question, so they stay until a device run says. `bluetooth-permissions` went with its producer, the `BLUETOOTH_PERMISSIONS` host event.
- frontend · the BLE address of an offline send: `offlineSend` sends the address the contacts mapper chose, and the mapper falls back to addresses the native side resolved this session (`dsm/resolution.ts`); which address a transfer goes to is Rust's to resolve from the counterparty's device id. The radio priming this bullet also named is resolved above.
- Kotlin · AndroidManifest.xml `PicoSelfTestActivity`: a bench self-test its own comment calls debug bring-up only, exported and launched on USB attach in the production manifest. The hardware track's.
+- `proto` · `BilateralReconciliationRequest`, `BilateralReconciliationResponse`, `BleFrameType` 10–11, with their arms in `dsm_sdk` bluetooth/frame_classify.rs and wire/mod.rs: reconciliation was deleted (a fork is a Tripwire violation, not reconcilable) and its frames outlived it; the request's `include_peer_status` and `ble_address` name the peer status read deleted above. A backend wire pass.
- frontend · SofiScreen: orders a vault's token pair bytewise before sending it (§28) — a protocol rule applied above Rust. The SoFi track's screen.
- frontend · dsm/transactions.ts `schedulePostAcceptRefreshes`: after Accept, four re-reads of the wallet on a frame cadence (0/0.5/1/2 s) beside Rust's TRANSFER_COMPLETE announcement, kept because whether the announcement alone reaches the screen on a device is undecided; a device run decides, and the cadence goes if it does.
- frontend · services/recovery/nfcRecoveryService.ts `getNfcBackupStatus`: reads `recovery.status` as `key=value` text inside an `AppStateResponse` and fills a missing `capsule_count`/`last_capsule_index` with 0 — a text protocol on a DSM path. Recovery is a dependency boundary this round; the route and its reader change together when the recovery specification is in scope.
From 92848b5b62f4cd5637986954e6627a7f5b5b066e Mon Sep 17 00:00:00 2001
From: Cryptskii <47649969+cryptskii@users.noreply.github.com>
Date: Sat, 26 Sep 2026 11:58:37 -0400
Subject: [PATCH 03/11] fix(ble): an offline send carries the user's intent;
the SDK decides where it goes
The frontend chose where an offline send went. The send form resolved the
recipient's BLE address (the contact's, else a cache of its own, else a
bridge round trip to a native map) and refused a contact it found none for
before Rust was asked. offlineSend sent the address inside a
BilateralPrepareRequest, the device-to-device prepare, whose intent fields
(11-14) existed only for this route and whose ble_address (6) no receiver
read; the route also took a caller-authored operation in place of authoring
one. The contacts mapper filled a missing address from the frontend's cache
and dropped one that was not MAC-shaped. The prepare builder looked an
address up only to fill field 6, and when the contact had none wrote the
session's address into the contact, the mark that ends the pairing loop,
outside the pairing confirm.
wallet.sendOffline takes OfflineTransferRequest: counterparty, token, amount
and memo, as the user gave them. The SDK resolves the address
(bluetooth::peer_address): the one the contact holds, else the one its
identity, checked against the contact's genesis, was seen at this session;
a phone it has not met is refused, saying so. It authors the operation from
the intent. BilateralPrepareRequest 6 and 11-14 are reserved. The session
map moved out of jni::state into a host-compiled, tested module.
Deleted: the frontend resolver module with its cache and normalizer, the
resolveBleAddressForDeviceId RPC with its Kotlin arm, wrapper and JNI
export, recordPeerIdentity (unverified, and nothing called it) and
resolve_ble_address (nothing called it).
Tests: the resolver's order, the route refusing then passing on the
contact's address, the request's exact bytes, the form naming no address,
the mapper carrying Rust's address as-is. Six mutation controls, each red
on its named test.
---
.../wallet/bridge/SinglePathWebViewBridge.kt | 10 -
.../java/com/dsm/wallet/bridge/Unified.kt | 27 +--
.../dsm/wallet/bridge/UnifiedContactBridge.kt | 4 -
.../com/dsm/wallet/bridge/UnifiedNativeApi.kt | 2 -
.../bridge/SinglePathWebViewBridgeFuzzTest.kt | 3 +-
.../src/bluetooth/bilateral_ble_handler.rs | 68 ------
.../src/bluetooth/bilateral_envelope.rs | 10 -
.../dsm_sdk/src/bluetooth/mod.rs | 1 +
.../dsm_sdk/src/bluetooth/peer_address.rs | 152 ++++++++++++++
.../dsm_sdk/src/handlers/wallet_routes.rs | 195 +++++++++++-------
.../dsm_sdk/src/jni/ble_events.rs | 13 +-
.../dsm_sdk/src/jni/helpers.rs | 2 +-
.../dsm_sdk/src/jni/state.rs | 61 +-----
.../src/jni/unified_protobuf_bridge.rs | 114 +---------
.../EnhancedWalletScreen.events.test.tsx | 6 +-
.../src/components/screens/wallet/SendTab.tsx | 8 +-
.../wallet/__tests__/SendTab.offline.test.tsx | 16 +-
.../src/components/tour/practiceMode.ts | 1 -
.../src/domain/__tests__/mappers.test.ts | 27 +--
dsm_client/frontend/src/domain/mappers.ts | 31 +--
.../frontend/src/dsm/WebViewBridge/ble.ts | 10 -
.../frontend/src/dsm/WebViewBridge/index.ts | 1 -
.../dsm/__tests__/bleIdentityResolver.test.ts | 95 ---------
.../blePairingRequestNormalization.test.ts | 42 ----
.../src/dsm/__tests__/offlineSend.test.ts | 42 +---
...offlineTransfer_consistency_bridge.test.ts | 24 +--
.../src/dsm/__tests__/resolution.test.ts | 126 -----------
dsm_client/frontend/src/dsm/index.ts | 8 +-
dsm_client/frontend/src/dsm/resolution.ts | 135 ------------
dsm_client/frontend/src/dsm/transactions.ts | 26 +--
dsm_client/frontend/src/dsm/types.ts | 1 -
dsm_client/frontend/src/proto/dsm_app_pb.ts | 106 ++++++----
.../frontend/src/stores/contactsStore.ts | 6 +-
.../src/tests/E2E.offlineBleExchange.test.ts | 2 +-
.../src/tests/E2E.transferProof.test.ts | 84 +++-----
proto/dsm_app.proto | 26 ++-
specs/requirements/CONFORMANCE_GAPS.md | 5 +-
37 files changed, 474 insertions(+), 1016 deletions(-)
create mode 100644 dsm_client/deterministic_state_machine/dsm_sdk/src/bluetooth/peer_address.rs
delete mode 100644 dsm_client/frontend/src/dsm/__tests__/bleIdentityResolver.test.ts
delete mode 100644 dsm_client/frontend/src/dsm/__tests__/blePairingRequestNormalization.test.ts
delete mode 100644 dsm_client/frontend/src/dsm/__tests__/resolution.test.ts
delete mode 100644 dsm_client/frontend/src/dsm/resolution.ts
diff --git a/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/SinglePathWebViewBridge.kt b/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/SinglePathWebViewBridge.kt
index c0e52db3b..c78d4c809 100644
--- a/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/SinglePathWebViewBridge.kt
+++ b/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/SinglePathWebViewBridge.kt
@@ -279,16 +279,6 @@ class SinglePathWebViewBridge(private val context: Context) {
}
}
- // strict wallet history (JNI). Returns FramedEnvelopeV3 bytes or empty on error.
- // genesis_envelope bytes (prefs-only). Used for cold-start rehydration.
- // Returns empty if not present.
- // Resolve BLE address from native mapping (bytes-only).
- // Payload: 32-byte device_id. Response: UTF-8 address bytes or empty.
- "resolveBleAddressForDeviceId" -> {
- if (payload.size != 32) return ByteArray(0)
- UnifiedContactBridge.resolveBleAddressForDeviceIdBin(payload)
- }
-
// Diagnostics: append raw payload to persisted bridge log
"diagnosticsLog" -> {
BridgeLogger.logDiagnosticsPayload(payload)
diff --git a/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/Unified.kt b/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/Unified.kt
index b7709a531..49ec9b503 100644
--- a/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/Unified.kt
+++ b/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/Unified.kt
@@ -27,14 +27,13 @@ import androidx.annotation.Keep
// - All crypto (SPHINCS+, ML-KEM-768, DBRW) handled in Rust beneath.
//
// DOMAIN GROUPS:
-// Identity: recordPeerIdentity
// Protocol: processEnvelopeV3, processEnvelopeV3WithAddress
// Shared boundary: dispatchStartup, dispatchIngress
// Bilateral: acceptBilateralByCommitment, ...
// BLE: initBleCoordinator, processBleChunk, chunkEnvelopeForBle, ...
// Contacts: removeContact, hasContactForDeviceId
//
-// Full method list: See UnifiedNativeApi.kt for all 87+ external declarations.
+// Full method list: UnifiedNativeApi.kt holds every external declaration.
// ============================================================================
/**
@@ -45,23 +44,6 @@ import androidx.annotation.Keep
*/
object Unified {
- /**
- * Called when a peer's identity (genesis hash + device ID) is read from BLE GATT.
- * This should be bridged to Rust/JS as needed.
- */
- @Keep
- @JvmStatic
- fun recordPeerIdentity(address: String, identity: ByteArray) {
- UnifiedNativeApi.recordPeerIdentity(address, identity)
- }
-
- @Keep
- @JvmStatic
- fun onPeerIdentityReceived(address: String, identity: ByteArray) {
- // Forward to native layer to maintain device_id -> BLE address mapping (no hex at app layer)
- recordPeerIdentity(address, identity)
- }
-
init {
// Load the native library with JNI exports.
// The `Unified_*` JNI surface is implemented in the Rust SDK shared library.
@@ -470,13 +452,6 @@ object Unified {
@Keep @JvmStatic fun onAppBackgrounded(): Boolean =
try { UnifiedNativeApi.onAppBackgrounded() } catch (_: Throwable) { false }
@Keep @JvmStatic fun getGenesisHashBin(): ByteArray = UnifiedNativeApi.getGenesisHashBin()
- /**
- * Get the current BLE MAC address for a device_id by searching identity cache.
- * @param deviceId Raw 32-byte device ID
- * @return UTF-8 BLE MAC address bytes or empty array if not found/connected
- */
- @Keep @JvmStatic fun resolveBleAddressForDeviceIdBin(deviceId: ByteArray): ByteArray =
- UnifiedNativeApi.resolveBleAddressForDeviceIdBin(deviceId)
/**
* Resolve the persisted peer identity for a BLE address.
* Returns 64 bytes ordered as [device_id(32)][genesis_hash(32)], or empty if unknown.
diff --git a/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/UnifiedContactBridge.kt b/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/UnifiedContactBridge.kt
index 9a03306ba..43c1b75af 100644
--- a/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/UnifiedContactBridge.kt
+++ b/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/UnifiedContactBridge.kt
@@ -4,10 +4,6 @@ package com.dsm.wallet.bridge
internal object UnifiedContactBridge {
- fun resolveBleAddressForDeviceIdBin(deviceId: ByteArray): ByteArray {
- return try { Unified.resolveBleAddressForDeviceIdBin(deviceId) } catch (_: Throwable) { ByteArray(0) }
- }
-
fun resolvePeerIdentityForBleAddressBin(address: String): ByteArray {
return try { Unified.resolvePeerIdentityForBleAddressBin(address) } catch (_: Throwable) { ByteArray(0) }
}
diff --git a/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/UnifiedNativeApi.kt b/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/UnifiedNativeApi.kt
index 488754ea1..ca93f4668 100644
--- a/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/UnifiedNativeApi.kt
+++ b/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/UnifiedNativeApi.kt
@@ -47,7 +47,6 @@ internal object UnifiedNativeApi {
}
}
- @Keep @JvmStatic external fun recordPeerIdentity(address: String, identity: ByteArray)
@Keep @JvmStatic external fun initSdk(baseDir: String): Boolean
@Keep @JvmStatic external fun initSdkV3(baseDir: String): ByteArray
@Keep @JvmStatic external fun initStorageBaseDir(path: ByteArray)
@@ -110,7 +109,6 @@ internal object UnifiedNativeApi {
*/
@Keep @JvmStatic external fun onAppBackgrounded(): Boolean
@Keep @JvmStatic external fun getGenesisHashBin(): ByteArray
- @Keep @JvmStatic external fun resolveBleAddressForDeviceIdBin(deviceId: ByteArray): ByteArray
@Keep @JvmStatic external fun resolvePeerIdentityForBleAddressBin(address: String): ByteArray
@Keep @JvmStatic external fun isRejectEnvelope(envelopeBytes: ByteArray): ByteArray
@Keep @JvmStatic external fun isErrorEnvelope(envelopeBytes: ByteArray): Int
diff --git a/dsm_client/android/app/src/test/java/com/dsm/wallet/bridge/SinglePathWebViewBridgeFuzzTest.kt b/dsm_client/android/app/src/test/java/com/dsm/wallet/bridge/SinglePathWebViewBridgeFuzzTest.kt
index 8c6d93bbf..52c836d62 100644
--- a/dsm_client/android/app/src/test/java/com/dsm/wallet/bridge/SinglePathWebViewBridgeFuzzTest.kt
+++ b/dsm_client/android/app/src/test/java/com/dsm/wallet/bridge/SinglePathWebViewBridgeFuzzTest.kt
@@ -33,7 +33,6 @@ class SinglePathWebViewBridgeFuzzTest {
"setPreference",
"nativeBoundaryIngress",
"nativeHostRequest",
- "resolveBleAddressForDeviceId",
"initiateBleContactPairing",
"getTransportHeadersV3Bin",
"acceptBilateralByCommitment",
@@ -152,7 +151,7 @@ class SinglePathWebViewBridgeFuzzTest {
payloads.add(byteArrayOf(0x00, 0x00, 0x00, 0x05, 0x41, 0x42, 0x43)) // methodLen=5 but truncated
payloads.add(byteArrayOf(0xFF.toByte(), 0xFF.toByte(), 0xFF.toByte(), 0xFF.toByte())) // huge method length
- // For resolveBleAddressForDeviceId: wrong sizes
+ // Ids of the wrong size
payloads.add(ByteArray(31)) // 31 bytes instead of 32
payloads.add(ByteArray(33)) // 33 bytes instead of 32
payloads.add(ByteArray(32) { 0x00 }) // 32 zero bytes
diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/bluetooth/bilateral_ble_handler.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/bluetooth/bilateral_ble_handler.rs
index 5af571b1a..cbabc136f 100644
--- a/dsm_client/deterministic_state_machine/dsm_sdk/src/bluetooth/bilateral_ble_handler.rs
+++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/bluetooth/bilateral_ble_handler.rs
@@ -16,9 +16,6 @@ use log::{debug, info, warn, error};
use prost::Message;
use tokio::sync::RwLock;
-#[cfg(all(target_os = "android", feature = "jni"))]
-use crate::jni::state::DEVICE_ID_TO_ADDR;
-
// Re-export types from bilateral_session so existing import paths still work.
pub use super::bilateral_session::{
BilateralBleSession, BilateralEventCallback, BilateralPhase, BilateralSettlementDelegate,
@@ -842,7 +839,6 @@ impl BilateralBleHandler {
}
drop(door);
- // Build prepare request with BLE address lookup
let expected_counterparty_state_hash = {
let m = self.bilateral_tx_manager.read().await;
m.get_chain_tip_for(&counterparty_device_id)
@@ -853,52 +849,6 @@ impl BilateralBleHandler {
})?
};
- // Look up BLE address from contact or in-memory map
- let ble_address = {
- let m = self.bilateral_tx_manager.read().await;
- if let Some(contact) = m.get_contact(&counterparty_device_id) {
- if let Some(addr) = &contact.ble_address {
- addr.clone()
- } else {
- // Contact exists but no BLE address persisted
- // Check in-memory map and persist if found
- #[cfg(all(target_os = "android", feature = "jni"))]
- {
- if let Ok(map) = DEVICE_ID_TO_ADDR.try_lock() {
- if let Some(addr) = map.get(&counterparty_device_id) {
- // Persist it to the contact (transport state only).
- if let Err(e) = crate::storage::client_db::update_contact_ble_status(
- &counterparty_device_id,
- None,
- Some(addr),
- ) {
- warn!(
- "[BLE_HANDLER] BLE address {} not persisted for the contact: {}",
- addr, e
- );
- }
- addr.clone()
- } else {
- warn!("[BLE_HANDLER] No BLE address found for counterparty device (contact exists but no address persisted or in map)");
- String::new()
- }
- } else {
- warn!("[BLE_HANDLER] DEVICE_ID_TO_ADDR lock contended, no BLE address found for counterparty device");
- String::new()
- }
- }
- #[cfg(not(all(target_os = "android", feature = "jni")))]
- {
- warn!("[BLE_HANDLER] No BLE address found for counterparty device (contact exists but no address persisted)");
- String::new()
- }
- }
- } else {
- warn!("[BLE_HANDLER] No contact found for counterparty device");
- String::new()
- }
- };
-
// Get sender's signing public key for inclusion in prepare request
let sender_signing_public_key = {
let m = self.bilateral_tx_manager.read().await;
@@ -914,20 +864,12 @@ impl BilateralBleHandler {
expected_counterparty_state_hash: Some(generated::Hash32 {
v: expected_counterparty_state_hash.to_vec(),
}),
- ble_address,
// Include sender identity for relationship establishment
sender_signing_public_key,
sender_device_id: self.device_id.to_vec(),
sender_genesis_hash: Some(generated::Hash32 {
v: local_genesis_hash.to_vec(),
}),
- // transfer_amount and token_id_hint are UI-only hints; protocol
- // correctness is carried entirely by operation_data. The transport
- // layer does not extract token-specific fields from the Operation.
- transfer_amount: 0,
- token_id_hint: String::new(),
- memo_hint: String::new(),
- transfer_amount_display: String::new(),
sender_kyber_public_key,
sender_kyber_binding_sig,
// σ_A over the commitment: the receiver puts to its user only a
@@ -4496,14 +4438,9 @@ mod tests {
operation_data: online_tier_transfer(device_id).to_bytes(),
expected_genesis_hash: None,
expected_counterparty_state_hash: None,
- ble_address: String::new(),
sender_signing_public_key: vec![0; 64],
sender_device_id: sender.to_vec(),
sender_genesis_hash: None,
- transfer_amount: 0,
- token_id_hint: String::new(),
- memo_hint: String::new(),
- transfer_amount_display: String::new(),
sender_kyber_public_key: vec![],
sender_kyber_binding_sig: vec![],
sender_signature: vec![],
@@ -4608,16 +4545,11 @@ mod tests {
expected_counterparty_state_hash: Some(generated::Hash32 {
v: cached_tip.to_vec(),
}),
- ble_address: String::new(),
sender_signing_public_key: sender_keys.public_key().to_vec(),
sender_device_id: sender.to_vec(),
sender_genesis_hash: Some(generated::Hash32 {
v: sender_genesis.to_vec(),
}),
- transfer_amount: 0,
- token_id_hint: String::new(),
- memo_hint: String::new(),
- transfer_amount_display: String::new(),
sender_kyber_public_key: kyber_pk,
sender_kyber_binding_sig: binding_sig,
sender_signature: sender_keys
diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/bluetooth/bilateral_envelope.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/bluetooth/bilateral_envelope.rs
index 6b64160da..7d0899e67 100644
--- a/dsm_client/deterministic_state_machine/dsm_sdk/src/bluetooth/bilateral_envelope.rs
+++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/bluetooth/bilateral_envelope.rs
@@ -247,14 +247,9 @@ mod tests {
operation_data: vec![2; 16],
expected_genesis_hash: None,
expected_counterparty_state_hash: None,
- ble_address: String::new(),
sender_signing_public_key: vec![0; 64],
sender_device_id: vec![0; 32],
sender_genesis_hash: None,
- transfer_amount: 0,
- token_id_hint: String::new(),
- memo_hint: String::new(),
- transfer_amount_display: String::new(),
sender_signature: vec![],
sender_kyber_public_key: vec![],
sender_kyber_binding_sig: vec![],
@@ -305,14 +300,9 @@ mod tests {
operation_data: vec![2; 16],
expected_genesis_hash: None,
expected_counterparty_state_hash: None,
- ble_address: String::new(),
sender_signing_public_key: vec![0; 64],
sender_device_id: vec![0; 32],
sender_genesis_hash: None,
- transfer_amount: 0,
- token_id_hint: String::new(),
- memo_hint: String::new(),
- transfer_amount_display: String::new(),
sender_signature: vec![],
sender_kyber_public_key: vec![],
sender_kyber_binding_sig: vec![],
diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/bluetooth/mod.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/bluetooth/mod.rs
index e9a2e98aa..5e42dcf89 100644
--- a/dsm_client/deterministic_state_machine/dsm_sdk/src/bluetooth/mod.rs
+++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/bluetooth/mod.rs
@@ -17,6 +17,7 @@ pub mod frame_classify;
#[cfg(test)]
mod offline_step_tests;
pub mod pairing_orchestrator;
+pub mod peer_address;
// Re-export bilateral transaction components
pub use bilateral_ble_handler::{
diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/bluetooth/peer_address.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/bluetooth/peer_address.rs
new file mode 100644
index 000000000..311044f75
--- /dev/null
+++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/bluetooth/peer_address.rs
@@ -0,0 +1,152 @@
+// SPDX-License-Identifier: MIT OR Apache-2.0
+
+//! Where a contact's phone is over BLE, for an offline send to it.
+//!
+//! Two sources, in this order. The address the contact holds: persisted once
+//! pairing confirmed it, and again whenever the contact's identity is seen at
+//! a new one. Else the address its identity was seen at this session, before
+//! pairing persisted one. Only a contact's identity, checked against the
+//! contact's genesis, records an address here. BLE addresses rotate; the
+//! native dispatch matches an address to the phone's current one by the
+//! identity it was seen with.
+
+use std::collections::HashMap;
+use std::sync::Mutex;
+
+use dsm::types::error::DsmError;
+use once_cell::sync::Lazy;
+
+/// The addresses contacts' identities were seen at this session.
+static SEEN_THIS_SESSION: Lazy>> =
+ Lazy::new(|| Mutex::new(HashMap::new()));
+
+/// Record that the identity of the contact `device_id` was seen at `address`
+/// this session. An empty address records nothing.
+pub fn record_sighting(device_id: &[u8; 32], address: &str) {
+ if address.is_empty() {
+ return;
+ }
+ let mut seen = SEEN_THIS_SESSION
+ .lock()
+ .unwrap_or_else(|poisoned| poisoned.into_inner());
+ let previous = seen.insert(*device_id, address.to_string());
+ if previous.as_deref() != Some(address) {
+ log::info!(
+ "[peer_address] {:02x}{:02x}... seen at {} (previously {:?})",
+ device_id[0],
+ device_id[1],
+ address,
+ previous
+ );
+ }
+}
+
+fn seen_this_session(device_id: &[u8; 32]) -> Option {
+ SEEN_THIS_SESSION
+ .lock()
+ .unwrap_or_else(|poisoned| poisoned.into_inner())
+ .get(device_id)
+ .cloned()
+}
+
+/// Where an offline send to `device_id` goes: the address its contact holds,
+/// else the one its identity was seen at this session. `None` when the phones
+/// have not met over BLE.
+pub fn counterparty_address(device_id: &[u8; 32]) -> Result