diff --git a/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/ble/BleAdvertiser.kt b/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/ble/BleAdvertiser.kt index eb68565ac..ec6b24af4 100644 --- a/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/ble/BleAdvertiser.kt +++ b/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/ble/BleAdvertiser.kt @@ -23,7 +23,13 @@ import java.util.concurrent.atomic.AtomicReference */ class BleAdvertiser(private val context: Context) { + /** The stack's answers about the advertising set, as they arrive. */ interface Callback { + /** The stack confirmed the set: it is on the air. */ + fun onAdvertisingStarted() + /** The stack confirmed a requested stop: it is off the air. */ + fun onAdvertisingStopped() + /** The stack refused the set it was asked to start. */ fun onAdvertisingFailed(errorCode: Int) } @@ -64,6 +70,7 @@ class BleAdvertiser(private val context: Context) { currentAdvertisingSet.set(advertisingSet) state.set(2) // STARTED Log.i(TAG, "Advertising set started (txPower=$txPower, id=$currentId)") + callback?.onAdvertisingStarted() } else { currentAdvertisingSet.set(null) state.set(0) // IDLE @@ -82,6 +89,7 @@ class BleAdvertiser(private val context: Context) { currentAdvertisingSet.set(null) state.set(0) // IDLE Log.i(TAG, "Advertising set stopped (id=$currentId)") + callback?.onAdvertisingStopped() } override fun onAdvertisingDataSet(advertisingSet: AdvertisingSet?, status: Int) { @@ -233,6 +241,20 @@ class BleAdvertiser(private val context: Context) { fun isAdvertising(): Boolean = state.get() == 2 + /** + * Bluetooth is going off: the stack ends every advertising set with the radio, + * and a callback for it may never come. Nothing is advertising and nothing is + * in flight after this, so the next start requests a new set. Returns whether a + * confirmed set was on the air (started, or stopping from started). + */ + fun radioOff(): Boolean { + val previous = state.getAndSet(0) // IDLE + currentAdvertisingSet.set(null) + bluetoothLeAdvertiser = null + if (previous != 0) Log.i(TAG, "Bluetooth off: advertising state $previous cleared") + return previous == 2 || previous == 3 + } + companion object { private const val TAG = "BleAdvertiser" } 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 2ba08570e..d2eb84117 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 @@ -2,8 +2,13 @@ package com.dsm.wallet.bridge.ble +import android.bluetooth.BluetoothAdapter import android.bluetooth.BluetoothDevice +import android.content.BroadcastReceiver import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.os.Build import android.util.Log import com.dsm.wallet.bridge.BleOutboxRepository import com.dsm.wallet.bridge.UnifiedContactBridge @@ -53,7 +58,10 @@ class BleCoordinator private constructor(private val context: Context) : BleScan if (scanner.isScanning()) { Log.i("BleCoordinator", "Scan downshift: LOW_LATENCY → BALANCED after ${BleConstants.SCAN_LOW_LATENCY_DURATION_MS}ms") scanner.stopScanning() - scanner.startScanning(lowLatency = false) + if (!scanner.startScanning(lowLatency = false)) { + Log.w("BleCoordinator", "Scan downshift: the balanced scan was refused; scanning ended") + radioEvents.scanStopped() + } } } @@ -79,6 +87,31 @@ class BleCoordinator private constructor(private val context: Context) : BleScan private var gattServer = GattServerHost(context) private var outbox = BleOutbox(context, BleOutboxRepository(context)) private var diagnostics = BleDiagnostics() + private var radioEvents: BleRadioEvents = UnifiedRadioEvents + + // The advertiser's word, relayed: started and stopped are reported when the + // stack confirms them, never when they are only requested. + private val advertiserCallback = object : BleAdvertiser.Callback { + override fun onAdvertisingStarted() = radioEvents.advertisingStarted() + override fun onAdvertisingStopped() = radioEvents.advertisingStopped() + override fun onAdvertisingFailed(errorCode: Int) { + Log.e("BleCoordinator", "The stack refused the advertising set: errorCode=$errorCode") + diagnostics.recordError(BleErrorCategory.HARDWARE_UNAVAILABLE, "advertise_failed_code_$errorCode") + } + } + + // Bluetooth going off ends every advertising set, scan and GATT server + // registration with the radio. Registered for the life of the process (the + // coordinator is a process singleton), so the state is cleared whether or not + // the BLE service is running when it happens. + private val adapterStateReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + if (intent?.action != BluetoothAdapter.ACTION_STATE_CHANGED) return + when (intent.getIntExtra(BluetoothAdapter.EXTRA_STATE, BluetoothAdapter.ERROR)) { + BluetoothAdapter.STATE_TURNING_OFF, BluetoothAdapter.STATE_OFF -> onRadioOff() + } + } + } // PairingMachine deleted — pairing state is Rust-authoritative via PairingOrchestrator. // Use Unified.isBleAddressPaired(address) to query pairing status. @@ -98,8 +131,11 @@ class BleCoordinator private constructor(private val context: Context) : BleScan gattServer.peerLookup = { address -> peers.getOrPut(address) { PeerSession(address) } } gattServer.peerEntries = { peers.values } + advertiser.setCallback(advertiserCallback) + // Initialize components permissionsGate.initialize() + registerAdapterStateReceiver() } // Secondary constructor for tests allowing dependency injection @@ -110,7 +146,8 @@ class BleCoordinator private constructor(private val context: Context) : BleScan gattServer: GattServerHost, scanner: BleScanner = BleScanner(context), outbox: BleOutbox = BleOutbox(context, BleOutboxRepository(context)), - diagnostics: BleDiagnostics = BleDiagnostics() + diagnostics: BleDiagnostics = BleDiagnostics(), + radioEvents: BleRadioEvents = UnifiedRadioEvents, ) : this(context) { this.permissionsGate = permissionsGate this.advertiser = advertiser @@ -118,8 +155,10 @@ class BleCoordinator private constructor(private val context: Context) : BleScan this.scanner = scanner this.outbox = outbox this.diagnostics = diagnostics - // Re-wire scanner callback after replacing the scanner instance + this.radioEvents = radioEvents + // Re-wire scanner and advertiser callbacks after replacing the instances this.scanner.setCallback(this) + this.advertiser.setCallback(advertiserCallback) // Re-wire peer lookup after replacing the gattServer instance this.gattServer.peerLookup = { address -> peers.getOrPut(address) { PeerSession(address) } } this.gattServer.peerEntries = { peers.values } @@ -171,14 +210,18 @@ class BleCoordinator private constructor(private val context: Context) : BleScan } /** - * Start advertising this device for pairing/discovery. + * Start advertising this appliance for pairing/discovery. + * + * True when an advertising set is on the air or requested from the stack; + * false when the advertiser refused. The started event is reported when the + * stack confirms the set, through the advertiser's callback. */ fun startAdvertising(): Boolean { return runOperationBool(BleOpLane.LIFECYCLE) { if (!permissionsGate.hasAdvertisePermission()) { diagnostics.recordError(BleErrorCategory.PERMISSION_DENIED, "advertising") permissionsGate.recordPermissionFailure() - com.dsm.wallet.bridge.UnifiedNativeApi.createBlePermissionDeniedEnvelope("advertise").let { if (it.isNotEmpty()) com.dsm.wallet.bridge.BleEventRelay.dispatchEnvelope(it) } + radioEvents.permissionDenied("advertise") return@runOperationBool false } @@ -191,20 +234,19 @@ class BleCoordinator private constructor(private val context: Context) : BleScan Log.w("BleCoordinator", "startAdvertising aborted: GATT server not ready") return@runOperationBool false } - advertiser.startAdvertising() - com.dsm.wallet.bridge.Unified.onAdvertisingStarted() - true + val requested = advertiser.startAdvertising() + if (!requested) Log.w("BleCoordinator", "startAdvertising: the advertiser refused") + requested } } /** - * Stop advertising. + * Stop advertising. The advertiser's answer; the stopped event is reported + * when the stack confirms the stop, through the advertiser's callback. */ fun stopAdvertising(): Boolean { return runOperationBool(BleOpLane.LIFECYCLE) { advertiser.stopAdvertising() - com.dsm.wallet.bridge.Unified.onAdvertisingStopped() - true // Always succeeds } } @@ -216,7 +258,7 @@ class BleCoordinator private constructor(private val context: Context) : BleScan if (!permissionsGate.hasScanPermission()) { diagnostics.recordError(BleErrorCategory.PERMISSION_DENIED, "scanning") permissionsGate.recordPermissionFailure() - com.dsm.wallet.bridge.UnifiedNativeApi.createBlePermissionDeniedEnvelope("scan").let { if (it.isNotEmpty()) com.dsm.wallet.bridge.BleEventRelay.dispatchEnvelope(it) } + radioEvents.permissionDenied("scan") return@runOperationBool false } @@ -276,28 +318,75 @@ class BleCoordinator private constructor(private val context: Context) : BleScan } } - // Record timestamp BEFORE starting + // Record the attempt BEFORE starting: the platform's limit counts attempts scanStartTimestamps.add(now) - scanner.startScanning() + if (!scanner.startScanning()) { + Log.w("BleCoordinator", "startScanning: the scanner refused") + return@runOperationBool false + } // Schedule downshift from LOW_LATENCY → BALANCED after 12s scanDownshiftHandler.removeCallbacks(scanDownshiftRunnable) scanDownshiftHandler.postDelayed(scanDownshiftRunnable, BleConstants.SCAN_LOW_LATENCY_DURATION_MS) - com.dsm.wallet.bridge.Unified.onScanStarted() + radioEvents.scanStarted() true } } /** - * Stop scanning. + * Stop scanning. The scanner's answer; the stopped event is reported only + * for a scan that was running and stopped. */ fun stopScanning(): Boolean { return runOperationBool(BleOpLane.LIFECYCLE) { lastScanStopTimestamp = System.currentTimeMillis() scanDownshiftHandler.removeCallbacks(scanDownshiftRunnable) - scanner.stopScanning() - com.dsm.wallet.bridge.Unified.onScanStopped() - true // Always succeeds + val wasScanning = scanner.isScanning() + val stopped = scanner.stopScanning() + if (wasScanning && stopped) radioEvents.scanStopped() + stopped + } + } + + /** + * Bluetooth is going off: clear what the radio ended with it, so the next + * start (the STATE_ON refresh) opens a new GATT server and requests new + * advertising and scans instead of trusting state from before. Runs on the + * lifecycle lane, in order with every start and stop. + */ + internal fun onRadioOff() { + runOperation(BleOpLane.LIFECYCLE) { + scanDownshiftHandler.removeCallbacks(scanDownshiftRunnable) + val wasScanning = scanner.radioOff() + val wasAdvertising = advertiser.radioOff() + gattServer.stop() + // Every link ended with the radio. The server that would have reported + // its clients' disconnects is closed, so no disconnect will clear them: + // clear each peer's links here, keeping what outlives a link. + var links = 0 + for ((address, peer) in peers) { + if (peer.gattClientSession != null || peer.isServerClient || peer.connectionPending) links++ + peer.clearClientState() + peer.clearServerState() + if (peer.isEmpty) peers.remove(address) + } + Log.i("BleCoordinator", "Bluetooth off: radio state cleared (scanning=$wasScanning advertising=$wasAdvertising links=$links)") + if (wasScanning) radioEvents.scanStopped() + if (wasAdvertising) radioEvents.advertisingStopped() + } + } + + private fun registerAdapterStateReceiver() { + val appContext = context.applicationContext + val filter = IntentFilter(BluetoothAdapter.ACTION_STATE_CHANGED) + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + appContext.registerReceiver(adapterStateReceiver, filter, Context.RECEIVER_NOT_EXPORTED) + } else { + appContext.registerReceiver(adapterStateReceiver, filter) + } + } catch (t: Throwable) { + Log.e("BleCoordinator", "Bluetooth state receiver not registered: ${t.message}") } } @@ -453,7 +542,7 @@ class BleCoordinator private constructor(private val context: Context) : BleScan */ fun cleanup() { runOperation(BleOpLane.LIFECYCLE) { - scanner.stopScanning() + if (scanner.isScanning() && scanner.stopScanning()) radioEvents.scanStopped() advertiser.stopAdvertising() gattServer.stop() peers.values.forEach { it.gattClientSession?.disconnect() } @@ -482,9 +571,8 @@ class BleCoordinator private constructor(private val context: Context) : BleScan // Stop the active scan before connectGatt(). Android BLE guidance and // field experience both point to scan/connect overlap as a reliability hit, // especially on Samsung/Qualcomm stacks where callbacks can stall. - if (scanner.isScanning()) { - scanner.stopScanning() - com.dsm.wallet.bridge.Unified.onScanStopped() + if (scanner.isScanning() && scanner.stopScanning()) { + radioEvents.scanStopped() } val session = getOrCreateSession(address) // Mark connection in-flight via a sentinel deferred so connectionPending returns true. @@ -507,6 +595,7 @@ class BleCoordinator private constructor(private val context: Context) : BleScan override fun onScanFailed(errorCode: Int) { Log.e("BleCoordinator", "BLE scan failed: errorCode=$errorCode") diagnostics.recordError(BleErrorCategory.HARDWARE_UNAVAILABLE, "scan_failed_code_$errorCode") + radioEvents.scanStopped() com.dsm.wallet.bridge.UnifiedBleEvents.onConnectionFailed("", "scan_failed_code_$errorCode") } @@ -563,7 +652,7 @@ class BleCoordinator private constructor(private val context: Context) : BleScan val started = scanner.startScanning() Log.i("BleCoordinator", "Pairing scan resume for $deviceAddress: reason=$reason started=$started") if (started) { - com.dsm.wallet.bridge.Unified.onScanStarted() + radioEvents.scanStarted() } } @@ -1163,10 +1252,8 @@ class BleCoordinator private constructor(private val context: Context) : BleScan // most reliable route. Prime it before scanning. gattServer.ensureStarted() if (!advertiser.isAdvertising()) { - try { - advertiser.startAdvertising() - Log.i("BleCoordinator", "connectToDevice($address): started advertising for reverse path") - } catch (_: Throwable) { /* best-effort */ } + val requested = advertiser.startAdvertising() + Log.i("BleCoordinator", "connectToDevice($address): advertising for the reverse path requested=$requested") } true } diff --git a/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/ble/BleRadioEvents.kt b/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/ble/BleRadioEvents.kt new file mode 100644 index 000000000..2c592552b --- /dev/null +++ b/dsm_client/android/app/src/main/java/com/dsm/wallet/bridge/ble/BleRadioEvents.kt @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + +package com.dsm.wallet.bridge.ble + +/** + * What [BleCoordinator] reports about the radio. Each call states something the + * radio itself answered: a start the stack confirmed, a stop, a scan that ended, + * a permission the system refused. The coordinator derives every call from the + * advertiser's and scanner's own results and callbacks; nothing here decides + * anything. + */ +internal interface BleRadioEvents { + fun advertisingStarted() + fun advertisingStopped() + fun scanStarted() + fun scanStopped() + fun permissionDenied(operation: String) +} + +/** The production sink: each event goes to Rust, which frames it for the WebView. */ +internal object UnifiedRadioEvents : BleRadioEvents { + override fun advertisingStarted() = com.dsm.wallet.bridge.Unified.onAdvertisingStarted() + override fun advertisingStopped() = com.dsm.wallet.bridge.Unified.onAdvertisingStopped() + override fun scanStarted() = com.dsm.wallet.bridge.Unified.onScanStarted() + override fun scanStopped() = com.dsm.wallet.bridge.Unified.onScanStopped() + override fun permissionDenied(operation: String) { + val envelope = com.dsm.wallet.bridge.UnifiedNativeApi.createBlePermissionDeniedEnvelope(operation) + if (envelope.isNotEmpty()) com.dsm.wallet.bridge.BleEventRelay.dispatchEnvelope(envelope) + } +} 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 c07ae7550..19db677af 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 @@ -150,5 +150,17 @@ class BleScanner(private val context: Context) { fun isScanning(): Boolean = scanning.get() + /** + * Bluetooth is going off: the stack ends the scan with the radio. Nothing is + * scanning after this, so the next start issues a new scan. Returns whether a + * scan was running. + */ + fun radioOff(): Boolean { + val wasScanning = scanning.getAndSet(false) + bluetoothLeScanner = null + if (wasScanning) Log.i("BleScanner", "Bluetooth off: scan state cleared") + return wasScanning + } + private fun getBluetoothAdapter() = BlePermissionsGate(context).getBluetoothAdapter() } 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 ee4844c21..1375e588e 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 @@ -123,7 +123,13 @@ class GattServerHost(private val context: Context) { } override fun onServiceAdded(status: Int, service: BluetoothGattService?) { - serviceRegistrationInProgress.set(false) + // Only the registration in flight is answered here. After stop() (or + // Bluetooth off) forgot it, a late answer belongs to a closed server and + // must not mark the next server's service as registered. + if (!serviceRegistrationInProgress.compareAndSet(true, false)) { + Log.w("GattServerHost", "onServiceAdded for no registration in flight (status=$status) ignored") + return + } val success = status == BluetoothGatt.GATT_SUCCESS if (success) { servicesReady.set(true) @@ -338,14 +344,22 @@ class GattServerHost(private val context: Context) { fun isReady(): Boolean = gattServer.get() != null && servicesReady.get() + /** + * Close the server and forget its registration, so the next [ensureStarted] + * opens a new server and registers the service again. Also what Bluetooth going + * off requires: the stack drops the server registration with the radio. + */ fun stop() { try { gattServer.get()?.close() - } catch (e: SecurityException) { - Log.e("GattServerHost", "Security exception closing GATT server", e) + } catch (t: Throwable) { + Log.e("GattServerHost", "Exception closing GATT server", t) } gattServer.set(null) servicesReady.set(false) + serviceRegistrationInProgress.set(false) + serviceReadyDeferred?.complete(false) + serviceReadyDeferred = null pendingTxWriteBuffers.clear() pendingPairingWriteBuffers.clear() Log.i("GattServerHost", "GATT server stopped") 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 a8febd909..63af48cf9 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 @@ -142,6 +142,16 @@ class MainActivity : AppCompatActivity(), NfcAdapter.ReaderCallback { } } } + // Bluetooth turning on or off changes a fact Rust decides pairing from. + private val bluetoothStateReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + if (intent?.action != android.bluetooth.BluetoothAdapter.ACTION_STATE_CHANGED) return + when (intent.getIntExtra(android.bluetooth.BluetoothAdapter.EXTRA_STATE, android.bluetooth.BluetoothAdapter.ERROR)) { + android.bluetooth.BluetoothAdapter.STATE_ON, + android.bluetooth.BluetoothAdapter.STATE_OFF -> publishSessionState("bluetoothState") + } + } + } private var bleServiceBound = false private val bleServiceConnection = object : android.content.ServiceConnection { override fun onServiceConnected(name: ComponentName?, service: IBinder?) { @@ -790,6 +800,28 @@ class MainActivity : AppCompatActivity(), NfcAdapter.ReaderCallback { updateBatterySnapshotFromIntent(stickyIntent) } + private fun registerBluetoothStateReceiver() { + val filter = IntentFilter(android.bluetooth.BluetoothAdapter.ACTION_STATE_CHANGED) + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + registerReceiver(bluetoothStateReceiver, filter, Context.RECEIVER_NOT_EXPORTED) + } else { + registerReceiver(bluetoothStateReceiver, filter) + } + } catch (t: Throwable) { + Log.w(tag, "registerBluetoothStateReceiver: failed", t) + } + } + + private fun unregisterBluetoothStateReceiver() { + try { + unregisterReceiver(bluetoothStateReceiver) + } catch (_: IllegalArgumentException) { + } catch (t: Throwable) { + Log.w(tag, "unregisterBluetoothStateReceiver: failed", t) + } + } + private fun unregisterBatteryReceiver() { try { unregisterReceiver(batteryChangedReceiver) @@ -1342,6 +1374,7 @@ class MainActivity : AppCompatActivity(), NfcAdapter.ReaderCallback { setContentView(rootContainer) setupWebView(webView) registerBatteryReceiver() + registerBluetoothStateReceiver() initDsmAndSignalReady() @@ -1507,6 +1540,7 @@ class MainActivity : AppCompatActivity(), NfcAdapter.ReaderCallback { activeInstance = null } unregisterBatteryReceiver() + unregisterBluetoothStateReceiver() super.onDestroy() com.dsm.wallet.EventPoller.stop() } diff --git a/dsm_client/android/app/src/test/java/com/dsm/wallet/bridge/ble/BleCoordinatorRadioTest.kt b/dsm_client/android/app/src/test/java/com/dsm/wallet/bridge/ble/BleCoordinatorRadioTest.kt new file mode 100644 index 000000000..eb032b39f --- /dev/null +++ b/dsm_client/android/app/src/test/java/com/dsm/wallet/bridge/ble/BleCoordinatorRadioTest.kt @@ -0,0 +1,190 @@ +package com.dsm.wallet.bridge.ble + +import android.bluetooth.BluetoothAdapter +import android.bluetooth.BluetoothDevice +import android.content.Context +import android.content.Intent +import android.os.Looper +import androidx.test.core.app.ApplicationProvider +import java.util.concurrent.CopyOnWriteArrayList +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.atLeastOnce +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyBlocking +import org.mockito.kotlin.whenever +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config + +/** + * The coordinator reports only what the radio answered, and Bluetooth going off + * clears what the radio ended so the next start really starts. + * + * The advertiser, scanner and GATT server stand in for the framework's answers; + * the events are recorded where production relays them to Rust. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [33]) +class BleCoordinatorRadioTest { + + private class RecordedEvents : BleRadioEvents { + val events = CopyOnWriteArrayList() + override fun advertisingStarted() { events += "advertisingStarted" } + override fun advertisingStopped() { events += "advertisingStopped" } + override fun scanStarted() { events += "scanStarted" } + override fun scanStopped() { events += "scanStopped" } + override fun permissionDenied(operation: String) { events += "permissionDenied:$operation" } + } + + private lateinit var context: Context + private lateinit var advertiser: BleAdvertiser + private lateinit var scanner: BleScanner + private lateinit var gattServer: GattServerHost + private lateinit var recorded: RecordedEvents + private lateinit var coordinator: BleCoordinator + + @Before + fun setUp() { + context = ApplicationProvider.getApplicationContext() + advertiser = mock() + scanner = mock() + gattServer = mock { onBlocking { ensureStarted() } doReturn true } + recorded = RecordedEvents() + coordinator = BleCoordinator( + context = context, + permissionsGate = mock { + on { hasAdvertisePermission() } doReturn true + on { hasScanPermission() } doReturn true + }, + advertiser = advertiser, + gattServer = gattServer, + scanner = scanner, + outbox = mock(), + diagnostics = BleDiagnostics(), + radioEvents = recorded, + ) + } + + private fun advertiserCallback(): BleAdvertiser.Callback { + val captor = argumentCaptor() + verify(advertiser, atLeastOnce()).setCallback(captor.capture()) + return captor.lastValue + } + + @Test + fun an_advertising_start_the_advertiser_refuses_is_refused_and_reports_nothing() { + whenever(advertiser.isAdvertising()).thenReturn(false) + whenever(advertiser.startAdvertising()).thenReturn(false) + + assertFalse(coordinator.startAdvertising()) + assertEquals(emptyList(), recorded.events) + } + + @Test + fun advertising_is_reported_started_only_when_the_stack_confirms_the_set() { + whenever(advertiser.isAdvertising()).thenReturn(false) + whenever(advertiser.startAdvertising()).thenReturn(true) + + assertTrue("a requested set is an accepted start", coordinator.startAdvertising()) + assertEquals("nothing is on the air until the stack says so", emptyList(), recorded.events) + + advertiserCallback().onAdvertisingStarted() + assertEquals(listOf("advertisingStarted"), recorded.events) + + advertiserCallback().onAdvertisingStopped() + assertEquals(listOf("advertisingStarted", "advertisingStopped"), recorded.events) + } + + @Test + fun a_scan_the_scanner_refuses_is_refused_and_reports_nothing() { + whenever(scanner.isScanning()).thenReturn(false) + whenever(scanner.startScanning(any())).thenReturn(false) + + assertFalse(coordinator.startScanning()) + assertEquals(emptyList(), recorded.events) + } + + @Test + fun a_scan_the_scanner_starts_is_reported_started_and_its_stop_reported_once() { + var running = false + whenever(scanner.isScanning()).thenAnswer { running } + whenever(scanner.startScanning(any())).thenAnswer { running = true; true } + whenever(scanner.stopScanning()).thenAnswer { running = false; true } + + assertTrue(coordinator.startScanning()) + assertEquals(listOf("scanStarted"), recorded.events) + + assertTrue(coordinator.stopScanning()) + assertTrue("stopping a stopped scan", coordinator.stopScanning()) + assertEquals(listOf("scanStarted", "scanStopped"), recorded.events) + } + + @Test + fun a_scan_the_stack_fails_after_starting_is_reported_stopped() { + coordinator.onScanFailed(2) + assertEquals(listOf("scanStopped"), recorded.events) + } + + @Test + fun bluetooth_going_off_clears_the_radio_state_and_the_next_start_starts_again() { + var onAir = true + var scanning = true + whenever(advertiser.isAdvertising()).thenAnswer { onAir } + whenever(advertiser.radioOff()).thenAnswer { val was = onAir; onAir = false; was } + whenever(advertiser.startAdvertising()).thenReturn(true) + whenever(scanner.isScanning()).thenAnswer { scanning } + whenever(scanner.radioOff()).thenAnswer { val was = scanning; scanning = false; was } + // A peer connected to our server and subscribed, and a peer we are connected to. + val serverClient = PeerSession("11:22:33:44:55:66").apply { + serverDevice = mock() + subscribedCccds[BleConstants.TX_RESPONSE_UUID] = true + } + val clientSession = mock() + val connected = PeerSession("77:88:99:AA:BB:CC").apply { + gattClientSession = clientSession + isConnected = true + } + coordinator.peers[serverClient.address] = serverClient + coordinator.peers[connected.address] = connected + + context.sendBroadcast( + Intent(BluetoothAdapter.ACTION_STATE_CHANGED) + .putExtra(BluetoothAdapter.EXTRA_STATE, BluetoothAdapter.STATE_OFF) + ) + shadowOf(Looper.getMainLooper()).idle() + + // The clear runs on the lifecycle lane, so a start issued after it runs after it. + assertTrue(coordinator.startAdvertising()) + verify(scanner).radioOff() + verify(advertiser).radioOff() + verify(gattServer).stop() + verifyBlocking(gattServer) { ensureStarted() } + verify(advertiser).startAdvertising() + assertEquals(listOf("scanStopped", "advertisingStopped"), recorded.events) + // No link outlives the radio: neither peer is left looking reachable. + assertFalse(serverClient.isServerClient) + assertFalse(serverClient.isSubscribedTo(BleConstants.TX_RESPONSE_UUID)) + assertFalse(connected.hasActiveClientSession) + verify(clientSession).closeQuietly() + assertTrue("peers with nothing left are dropped", coordinator.peers.isEmpty()) + } + + @Test + fun without_bluetooth_going_off_a_set_on_the_air_is_not_requested_again() { + whenever(advertiser.isAdvertising()).thenReturn(true) + + assertTrue(coordinator.startAdvertising()) + verify(advertiser, never()).startAdvertising() + verify(advertiser, never()).radioOff() + } +} diff --git a/dsm_client/android/app/src/test/java/com/dsm/wallet/bridge/ble/BleRadioComponentsTest.kt b/dsm_client/android/app/src/test/java/com/dsm/wallet/bridge/ble/BleRadioComponentsTest.kt new file mode 100644 index 000000000..4e60718b2 --- /dev/null +++ b/dsm_client/android/app/src/test/java/com/dsm/wallet/bridge/ble/BleRadioComponentsTest.kt @@ -0,0 +1,153 @@ +package com.dsm.wallet.bridge.ble + +import android.Manifest +import android.app.Application +import android.bluetooth.BluetoothAdapter +import android.bluetooth.BluetoothManager +import android.bluetooth.le.AdvertisingSet +import android.bluetooth.le.AdvertisingSetCallback +import android.bluetooth.le.BluetoothLeAdvertiser +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import java.util.concurrent.CopyOnWriteArrayList +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.mock +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config + +/** + * The advertiser and scanner forget what Bluetooth going off ended, and report + * the stack's word. The framework's answers are simulated at the framework + * boundary: a stand-in BluetoothLeAdvertiser whose callbacks the test delivers, + * and Robolectric's scanner. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [33]) +class BleRadioComponentsTest { + + private lateinit var app: Application + private lateinit var adapter: BluetoothAdapter + + @Before + fun setUp() { + app = ApplicationProvider.getApplicationContext() + shadowOf(app).grantPermissions( + Manifest.permission.BLUETOOTH_ADVERTISE, + Manifest.permission.BLUETOOTH_CONNECT, + Manifest.permission.BLUETOOTH_SCAN, + ) + adapter = (app.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager).adapter + shadowOf(adapter).setEnabled(true) + } + + private class RecordedAdvertising : BleAdvertiser.Callback { + val events = CopyOnWriteArrayList() + override fun onAdvertisingStarted() { events += "started" } + override fun onAdvertisingStopped() { events += "stopped" } + override fun onAdvertisingFailed(errorCode: Int) { events += "failed:$errorCode" } + } + + private fun startedSetCallback(framework: BluetoothLeAdvertiser, requests: Int): AdvertisingSetCallback { + val captor = argumentCaptor() + verify(framework, times(requests)).startAdvertisingSet( + any(), any(), any(), anyOrNull(), anyOrNull(), captor.capture() + ) + return captor.lastValue + } + + @Test + fun the_advertiser_reports_started_only_on_the_stacks_confirmation_and_a_refusal_as_failed() { + val framework = mock() + shadowOf(adapter).setBluetoothLeAdvertiser(framework) + val advertiser = BleAdvertiser(app) + val recorded = RecordedAdvertising() + advertiser.setCallback(recorded) + + assertTrue(advertiser.startAdvertising()) + assertFalse("requested is not on the air", advertiser.isAdvertising()) + assertEquals(emptyList(), recorded.events) + + startedSetCallback(framework, 1).onAdvertisingSetStarted(mock(), 0, AdvertisingSetCallback.ADVERTISE_SUCCESS) + assertTrue(advertiser.isAdvertising()) + assertEquals(listOf("started"), recorded.events) + + assertTrue(advertiser.stopAdvertising()) + startedSetCallback(framework, 1).onAdvertisingSetStopped(mock()) + assertFalse(advertiser.isAdvertising()) + assertEquals(listOf("started", "stopped"), recorded.events) + + assertTrue(advertiser.startAdvertising()) + startedSetCallback(framework, 2).onAdvertisingSetStarted(null, 0, AdvertisingSetCallback.ADVERTISE_FAILED_INTERNAL_ERROR) + assertFalse(advertiser.isAdvertising()) + assertEquals(listOf("started", "stopped", "failed:${AdvertisingSetCallback.ADVERTISE_FAILED_INTERNAL_ERROR}"), recorded.events) + } + + @Test + fun bluetooth_off_ends_the_advertising_set_and_the_next_start_requests_a_new_one() { + val framework = mock() + shadowOf(adapter).setBluetoothLeAdvertiser(framework) + val advertiser = BleAdvertiser(app) + assertFalse("nothing was on the air", advertiser.radioOff()) + + assertTrue(advertiser.startAdvertising()) + assertFalse("a set the stack never confirmed was not on the air", advertiser.radioOff()) + + assertTrue(advertiser.startAdvertising()) + val confirmed = startedSetCallback(framework, 2) + confirmed.onAdvertisingSetStarted(mock(), 0, AdvertisingSetCallback.ADVERTISE_SUCCESS) + assertTrue(advertiser.isAdvertising()) + + assertTrue("a confirmed set was on the air", advertiser.radioOff()) + assertFalse(advertiser.isAdvertising()) + // A late answer about the ended set changes nothing. + confirmed.onAdvertisingSetStarted(mock(), 0, AdvertisingSetCallback.ADVERTISE_SUCCESS) + assertFalse(advertiser.isAdvertising()) + + assertTrue(advertiser.startAdvertising()) + startedSetCallback(framework, 3) + } + + @Test + fun bluetooth_off_during_a_stop_frees_the_advertiser_to_start_again() { + val framework = mock() + shadowOf(adapter).setBluetoothLeAdvertiser(framework) + val advertiser = BleAdvertiser(app) + assertTrue(advertiser.startAdvertising()) + startedSetCallback(framework, 1).onAdvertisingSetStarted(mock(), 0, AdvertisingSetCallback.ADVERTISE_SUCCESS) + assertTrue(advertiser.stopAdvertising()) + // Bluetooth goes off before the stack confirms the stop: without the reset the + // advertiser waits for a callback that never comes and refuses every start. + assertTrue("stopping from on the air counts as on the air", advertiser.radioOff()) + + assertTrue(advertiser.startAdvertising()) + startedSetCallback(framework, 2) + } + + @Test + fun bluetooth_off_ends_the_scan_and_the_next_start_issues_a_new_one() { + val scanner = BleScanner(app) + val framework = shadowOf(adapter.bluetoothLeScanner) + + assertTrue(scanner.startScanning()) + assertTrue(scanner.isScanning()) + assertEquals(1, framework.scanCallbacks.size) + + assertTrue("a scan was running", scanner.radioOff()) + assertFalse(scanner.isScanning()) + assertFalse("nothing to clear twice", scanner.radioOff()) + + assertTrue(scanner.startScanning()) + assertTrue(scanner.isScanning()) + } +} diff --git a/specs/requirements/CONFORMANCE_GAPS.md b/specs/requirements/CONFORMANCE_GAPS.md index 9119fc35c..c7acbf65c 100644 --- a/specs/requirements/CONFORMANCE_GAPS.md +++ b/specs/requirements/CONFORMANCE_GAPS.md @@ -1037,6 +1037,7 @@ Owner request: integrate the frontend with the storage nodes properly, working f | frontend · dsm/transactions.ts `offlineSend` | A BLE transaction error frame ended the send in flight as failed. Kotlin raises that frame for any failed connection to any address — a failed identity read, a failed GATT connect, a failed connection-state call — so a pairing attempt with another appliance could fail a send, and a lost link, which is liveness, was reported as a failed transfer while the step stayed open. | The listener is deleted: the send ends on Rust's word, its events or its pending list, and a screen that stops waiting reports the step open. | | `dsm_sdk` · bluetooth/pairing_orchestrator.rs (`handle_pairing_confirm`, `finalize_scanner_pairing_by_address`, `handle_identity_observed`, `handle_peer_disconnected`), handlers/contacts_routes.rs `contacts.list`, sdk/contact_sdk.rs; `proto` · `ContactAddResponse.pairing`, `ContactPairingPhase`; frontend · components/screens/ContactsTabScreen.tsx, dsm/contacts.ts, domain/mappers.ts | Both pairing completions marked the session Complete, and told the screen the contact was paired, when storing its address failed. The loop never revisits a Complete session, so such a contact stayed unpaired until the process restarted — among them an appliance paired before it was added as a contact, whose store fails for want of the contact. The loop reported only "scanning", so the contacts screen inferred progress from raw radio events about any appliance: "Peer Found" on any DSM advertisement, and "Paired!" when an appliance's identity was read, before pairing had completed. A dropped link set the session's state twice. | A session completes only once the address is stored; a failed store fails the session, and the loop retries it. The loop reports each transition, and the contact list states each contact's phase from the loop's sessions — paired, idle, searching, connected or retrying (`ContactPairingPhase`; the frontend refuses a phase the wire does not name). The screen's line renders those phases and no longer listens to raw radio events; a pairing event re-reads the list. The orchestrator's database tests set their own storage directory: they passed only when an earlier test had set one. | | Kotlin · androidTest `AndroidLayerProofTest`; `ci/bridge_rpc_names.py` | #1012 deleted the bridge arms nothing in the frontend sent — `getDeviceIdBin`, `getGenesisHashBin`, `getSigningPublicKeyBin`, `getPersistedGenesisEnvelope`, `getWalletHistoryStrict` — and the instrumented proof still called them: nine of its tests got the unknown-method answer, and the managed-device job was red on `main` from that merge on (green at #1011). No gate read androidTest, so nothing named the cause. The deleted arms were the suite's only route to the JNI identity exports `Unified.getDeviceIdBin`/`getGenesisHashBin`, which BLE still reads (the GATT identity characteristic, the advertising gate); no test ever compared them with the transport headers the frontend reads. `claimFaucet` read the device id through a deleted arm and returned before claiming; before #1012 it sent a zero `schema_hash` and swallowed the claim's failure. | The proof reads what the frontend reads: the device id and genesis hash decoded from the transport headers, history through the `wallet.history` route, framing and concurrency over `getTransportHeadersV3Bin`; t50 also requires every thread to read the same device id. t22 calls the JNI identity exports directly and requires them to equal the headers' device id and genesis hash. t32 and t34 go with their arms, and nothing reads either value through the bridge; t42 goes, since it fetched `getDeviceIdBin` and never compared it (its one assertion was the headers' size). A routed call that Rust refuses fails with Rust's reason. The emulator tests that called `claimFaucet` read without it; the real-hardware test sends `faucet.claim` as the frontend does and requires a release. The gate now reads androidTest: every bridge name the instrumented suite sends — through the request encoders or a method field encoded by hand — must be one Kotlin handles, and the unknown-method probe (`UNHANDLED_METHOD`) must be readable, sent, and one Kotlin does not handle. | +| Kotlin · bridge/ble/BleCoordinator.kt (`startAdvertising`, `stopAdvertising`, `startScanning`, `stopScanning`, `onScanFailed`, the adapter-state receiver, `onRadioOff`), BleAdvertiser.kt, BleScanner.kt, GattServerHost.kt (`stop`, `onServiceAdded`), BleRadioEvents.kt (new), ui/MainActivity.kt (`bluetoothStateReceiver`) | `BleCoordinator.startAdvertising` and `startScanning` discarded the advertiser's and scanner's answers, then sent the started event and returned true — success reported without the radio's word; the advertiser's confirmation and failure callbacks were wired to nobody. Nothing handled Bluetooth going off: the advertiser kept STARTED, the scanner its scanning flag and the GATT server its registration, so the STATE_ON refresh (#1021's "advertising restarts on adapter-on") found everything running and started nothing; a stop in flight when the radio went off left the advertiser refusing every later start. Nothing republished the session facts on a Bluetooth toggle, so pairing did not follow it. | Advertising is reported started when the stack confirms the set and stopped when it confirms the stop; a refused start returns false and reports nothing; a scan is reported started only when it started, stopped only when a running scan stopped, and stopped when the stack fails it afterwards. The coordinator listens for the adapter for the life of the process and, on TURNING_OFF/OFF, clears the advertiser, the scanner, the GATT server's registration and every peer's links on the lifecycle lane, so the next start opens a new server and requests a new set. `GattServerHost.stop` forgets a registration in flight and a late `onServiceAdded` for a closed server is ignored. MainActivity publishes the session facts when Bluetooth turns on or off. Radio events go through an injected sink (production relays to Rust); success is derived only from the radio. | 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`). @@ -1090,8 +1091,11 @@ Tests for pairing status: `dsm_sdk::bluetooth::pairing_orchestrator::tests::a_co Tests for the instrumented proof: `AndroidLayerProofTest` on the managed Pixel 6 API 34 emulator, locally and in CI's `Android Instrumented Tests (managed device)`: 40 tests, 0 failures (`@RealHardware` excluded, as in CI). Controls, each run and restored: t22 comparing the device id with the genesis hash — red ("arrays first differed at element [0]"); t40 run on the emulator — red with Rust's reason ("faucet.claim refused: … member `instrumented-node-1` has a register_incarnation that is not Base32-Crockford"). Gate controls: `main`'s test under `main`'s gate passes (the blind spot); under the new gate it is refused, naming the five deleted names at every call site; a hand-encoded method field naming a method Kotlin lacks, a typed probe bound to a handled method, a probe bound in a form the gate cannot read, a t60 that sends a literal instead of the probe, and no probe at all are each refused. +Tests for the radio's word: `BleCoordinatorRadioTest` (7: a refused advertising start and a refused scan report nothing; advertising is reported started only on the stack's confirmation; a scan's stop is reported once; a scan the stack fails is reported stopped; an `ACTION_STATE_CHANGED` STATE_OFF broadcast clears the advertiser, scanner, GATT server and every peer's links, and the next start requests again; without it a set on the air is not requested again) and `BleRadioComponentsTest` (4, the framework simulated at its boundary: the advertiser reports started only on confirmation and a refusal as failed; Bluetooth off ends the set, a late answer changes nothing, a stop in flight no longer wedges it; Bluetooth off ends the scan). Android unit suite 263/0. Mutation controls, each red on its named test: the coordinator ignoring the advertiser, ignoring the scanner, the receiver ignoring STATE_OFF, the reset keeping the advertiser's state, the GATT server, or the peers' links, a failed scan not reported stopped, every stop reported stopped, `BleAdvertiser.radioOff` keeping its state or always answering on-air, the advertiser not reporting the stack's start, `BleScanner.radioOff` keeping the scan. Device run (Samsung A16, rig 8XK): startup reports `onAdvertisingStarted` after `Advertising set started`; Bluetooth off clears the set and GATT server and reports `onAdvertisingStopped` once, the stack's late stop is ignored as stale; Bluetooth on opens a new server, registers the service and starts a new set (`id=2`) — before the fix nothing started; `publishSessionState: reason=bluetoothState` on both. The peer-link clearing landed after that run and is unit-tested only. `GattServerHost`'s registration reset is exercised only on the device (Robolectric's `addService` answers false), and its stale-`onServiceAdded` branch is not exercised anywhere. + **Open** +- Kotlin · bridge/ble (pre-existing, found by the radio review, not changed): the `PairingConfirmWritten` handler calls the blocking `stopScanning()` from inside a PAIRING-lane dispatcher op, so the dispatcher waits on itself for up to 15 s; `resumePairingScan` restarts scans on GATT disconnect regardless of foreground, so pairing does not fully follow the session; a stop while a set is requested drops the stack's later confirmation as stale and leaves an untracked set on the air; the downshift runnable runs on the main looper outside the dispatcher and can restart a scan the lifecycle lane just stopped; after Bluetooth on the scanner-role pairing scan waits for the loop's 90 s stale cycle; `onScanFailed` sends a transaction-error envelope without the 0x03 frame, which the frontend cannot decode; the `BlePermissionsGate` adapter receiver drives nothing and is not re-registered after `cleanup`; a START_STICKY restart of `BleBackgroundService` never sets the storage base dir. - Kotlin · androidTest `AndroidLayerProofTest.t40_fullFrame_identityCheckAndBalanceFetch` (`@RealHardware`): `ensureGenesis` installs the loopback test config, so `faucet.claim` is refused by the storage-set catalog on any device and the test cannot pass. It needs the suite to install a config that names the network's pinned set; no client config for the live fleet is cut. - 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.