From 84309087c09ad0a9cc1fd2d9386adc0a84a82125 Mon Sep 17 00:00:00 2001 From: "sentry[bot]" <39604003+sentry[bot]@users.noreply.github.com> Date: Wed, 7 Oct 2026 23:20:47 +0000 Subject: [PATCH 1/2] fix(android-ble): prevent ANR by moving blocking Bluetooth calls off main thread --- .../app/rns/host/ble/client/BleGattClient.kt | 31 +++++++++---------- .../app/rns/host/ble/client/BleScanner.kt | 7 +++-- .../app/rns/host/ble/server/BleAdvertiser.kt | 9 +++--- .../app/rns/host/ble/server/BleGattServer.kt | 17 +++++----- .../app/rns/host/ble/util/BleDispatchers.kt | 22 +++++++++++++ 5 files changed, 55 insertions(+), 31 deletions(-) create mode 100644 rns-host/src/main/kotlin/network/columba/app/rns/host/ble/util/BleDispatchers.kt diff --git a/rns-host/src/main/kotlin/network/columba/app/rns/host/ble/client/BleGattClient.kt b/rns-host/src/main/kotlin/network/columba/app/rns/host/ble/client/BleGattClient.kt index 66bddf189..010f0e8db 100644 --- a/rns-host/src/main/kotlin/network/columba/app/rns/host/ble/client/BleGattClient.kt +++ b/rns-host/src/main/kotlin/network/columba/app/rns/host/ble/client/BleGattClient.kt @@ -13,11 +13,10 @@ import android.bluetooth.BluetoothProfile import android.content.Context import android.content.pm.PackageManager import android.os.Build -import android.os.Handler -import android.os.Looper import android.util.Log import androidx.core.content.ContextCompat import network.columba.app.rns.host.ble.model.BleConstants +import network.columba.app.rns.host.ble.util.BleDispatchers import network.columba.app.rns.host.ble.util.BleOperationQueue import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -206,7 +205,7 @@ class BleGattClient( * @return Result with Unit on success, exception on failure */ suspend fun connect(address: String): Result = - withContext(Dispatchers.Main) { + withContext(BleDispatchers.ble) { try { // Check if already connected connectionsMutex.withLock { @@ -297,14 +296,14 @@ class BleGattClient( } val connData = - withContext(Dispatchers.Main) { + withContext(BleDispatchers.ble) { connectionsMutex.withLock { connections.remove(address) } } if (connData != null) { - withContext(Dispatchers.Main) { + withContext(BleDispatchers.ble) { connData.connectionJob?.cancel() safeGattTeardown(connData.gatt, "manual disconnect $address") } @@ -446,7 +445,7 @@ class BleGattClient( } // Request high connection priority for better stability and throughput - withContext(Dispatchers.Main) { + withContext(BleDispatchers.ble) { try { val priorityResult = gatt.requestConnectionPriority( @@ -461,11 +460,11 @@ class BleGattClient( // Small delay to let BLE stack apply parameters delay(100) - // Discover services (post to main thread for older Android versions) - withContext(Dispatchers.Main) { - Handler(Looper.getMainLooper()).post { - gatt.discoverServices() - } + // Discover services off the main thread: on Android 13+ discoverServices() + // is a synchronous binder call that can block for seconds when the + // Bluetooth stack is congested, which caused Background ANRs. + withContext(BleDispatchers.ble) { + gatt.discoverServices() } } @@ -523,7 +522,7 @@ class BleGattClient( connectionsMutex.withLock { connections.remove(address) } - withContext(Dispatchers.Main) { + withContext(BleDispatchers.ble) { safeGattTeardown(gatt, "service discovery failed status=$status") } @@ -544,7 +543,7 @@ class BleGattClient( connectionsMutex.withLock { connections.remove(address) } - withContext(Dispatchers.Main) { + withContext(BleDispatchers.ble) { safeGattTeardown(gatt, "Reticulum service not found") } @@ -568,7 +567,7 @@ class BleGattClient( connectionsMutex.withLock { connections.remove(address) } - withContext(Dispatchers.Main) { + withContext(BleDispatchers.ble) { safeGattTeardown(gatt, "required characteristics not found") } @@ -853,7 +852,7 @@ class BleGattClient( } if (connData != null) { - withContext(Dispatchers.Main) { + withContext(BleDispatchers.ble) { safeGattTeardown(connData.gatt, "failed to enable notifications") } } @@ -1081,7 +1080,7 @@ class BleGattClient( val connData = connectionsMutex.withLock { connections.remove(address) } if (connData != null) { - withContext(Dispatchers.Main) { + withContext(BleDispatchers.ble) { safeGattTeardown(connData.gatt, "connection timeout") } onConnectionFailed?.invoke(address, "Connection timeout") diff --git a/rns-host/src/main/kotlin/network/columba/app/rns/host/ble/client/BleScanner.kt b/rns-host/src/main/kotlin/network/columba/app/rns/host/ble/client/BleScanner.kt index c793a6d5f..80142c013 100644 --- a/rns-host/src/main/kotlin/network/columba/app/rns/host/ble/client/BleScanner.kt +++ b/rns-host/src/main/kotlin/network/columba/app/rns/host/ble/client/BleScanner.kt @@ -17,6 +17,7 @@ import androidx.core.content.ContextCompat import network.columba.app.rns.host.ble.model.BleConstants import network.columba.app.rns.host.ble.model.BleDevice import network.columba.app.rns.host.ble.model.BlePowerSettings +import network.columba.app.rns.host.ble.util.BleDispatchers import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -144,7 +145,7 @@ class BleScanner( * @return Result indicating success or failure */ suspend fun startScanning(minRssi: Int = BleConstants.MIN_RSSI_DBM): Result { - return withContext(Dispatchers.Main) { + return withContext(BleDispatchers.ble) { try { // Check if already scanning if (_isScanning.value) { @@ -206,7 +207,7 @@ class BleScanner( scanJob?.cancel() scanJob = null - withContext(Dispatchers.Main) { + withContext(BleDispatchers.ble) { if (_isScanning.value && bluetoothLeScanner != null) { bluetoothLeScanner.stopScan(scanCallback) _isScanning.value = false @@ -248,7 +249,7 @@ class BleScanner( * Perform a single scan. */ private suspend fun performScan(minRssi: Int) { - withContext(Dispatchers.Main) { + withContext(BleDispatchers.ble) { try { val scanner = bluetoothLeScanner ?: return@withContext diff --git a/rns-host/src/main/kotlin/network/columba/app/rns/host/ble/server/BleAdvertiser.kt b/rns-host/src/main/kotlin/network/columba/app/rns/host/ble/server/BleAdvertiser.kt index dd882bdf2..536d07000 100644 --- a/rns-host/src/main/kotlin/network/columba/app/rns/host/ble/server/BleAdvertiser.kt +++ b/rns-host/src/main/kotlin/network/columba/app/rns/host/ble/server/BleAdvertiser.kt @@ -15,6 +15,7 @@ import android.util.Log import androidx.core.content.ContextCompat import network.columba.app.rns.host.ble.model.BleConstants import network.columba.app.rns.host.ble.model.BlePowerSettings +import network.columba.app.rns.host.ble.util.BleDispatchers import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -142,7 +143,7 @@ class BleAdvertiser( * @return Result indicating success or failure */ suspend fun startAdvertising(deviceName: String = BleConstants.DEFAULT_DEVICE_NAME_PREFIX): Result = - withContext(Dispatchers.Main) { + withContext(BleDispatchers.ble) { try { // Check if already advertising if (_isAdvertising.value) { @@ -243,7 +244,7 @@ class BleAdvertiser( // Stop refresh job first stopRefreshJob() - withContext(Dispatchers.Main) { + withContext(BleDispatchers.ble) { if (_isAdvertising.value && bluetoothLeAdvertiser != null) { bluetoothLeAdvertiser.stopAdvertising(advertiseCallback) _isAdvertising.value = false @@ -366,7 +367,7 @@ class BleAdvertiser( isRefreshing = true try { // Stop current advertising - withContext(Dispatchers.Main) { + withContext(BleDispatchers.ble) { try { bluetoothLeAdvertiser?.stopAdvertising(advertiseCallback) } catch (e: Exception) { @@ -377,7 +378,7 @@ class BleAdvertiser( delay(100) // Brief delay for cleanup // Restart advertising - withContext(Dispatchers.Main) { + withContext(BleDispatchers.ble) { startAdvertisingInternal() } Log.d(TAG, "Advertising refreshed successfully") diff --git a/rns-host/src/main/kotlin/network/columba/app/rns/host/ble/server/BleGattServer.kt b/rns-host/src/main/kotlin/network/columba/app/rns/host/ble/server/BleGattServer.kt index c369bc4a9..b442c0000 100644 --- a/rns-host/src/main/kotlin/network/columba/app/rns/host/ble/server/BleGattServer.kt +++ b/rns-host/src/main/kotlin/network/columba/app/rns/host/ble/server/BleGattServer.kt @@ -18,6 +18,7 @@ import android.os.Build import android.util.Log import androidx.core.content.ContextCompat import network.columba.app.rns.host.ble.model.BleConstants +import network.columba.app.rns.host.ble.util.BleDispatchers import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -262,7 +263,7 @@ class BleGattServer( * @return Result indicating success or failure */ suspend fun open(): Result = - withContext(Dispatchers.Main) { + withContext(BleDispatchers.ble) { try { // Check if already open if (_isServerOpen.value) { @@ -358,7 +359,7 @@ class BleGattServer( * Close the GATT server. */ suspend fun close() = - withContext(Dispatchers.Main) { + withContext(BleDispatchers.ble) { try { // Clear connected centrals centralsMutex.withLock { @@ -431,7 +432,7 @@ class BleGattServer( IllegalStateException("TX characteristic not available"), ) - return withContext(Dispatchers.Main) { + return withContext(BleDispatchers.ble) { try { if (!hasConnectPermission()) { return@withContext Result.failure( @@ -492,7 +493,7 @@ class BleGattServer( * We must manually clean up state and fire the disconnect callback. */ suspend fun disconnectCentral(address: String) = - withContext(Dispatchers.Main) { + withContext(BleDispatchers.ble) { try { if (!hasConnectPermission()) { Log.w(TAG, "Cannot disconnect central, missing permission") @@ -670,7 +671,7 @@ class BleGattServer( requestId: Int, offset: Int, characteristic: BluetoothGattCharacteristic, - ) = withContext(Dispatchers.Main) { + ) = withContext(BleDispatchers.ble) { try { if (!hasConnectPermission()) { return@withContext @@ -735,7 +736,7 @@ class BleGattServer( responseNeeded: Boolean, offset: Int, value: ByteArray, - ) = withContext(Dispatchers.Main) { + ) = withContext(BleDispatchers.ble) { try { if (!hasConnectPermission()) { return@withContext @@ -827,7 +828,7 @@ class BleGattServer( requestId: Int, offset: Int, descriptor: BluetoothGattDescriptor, - ) = withContext(Dispatchers.Main) { + ) = withContext(BleDispatchers.ble) { try { if (!hasConnectPermission()) { return@withContext @@ -869,7 +870,7 @@ class BleGattServer( responseNeeded: Boolean, offset: Int, value: ByteArray, - ) = withContext(Dispatchers.Main) { + ) = withContext(BleDispatchers.ble) { try { if (!hasConnectPermission()) { return@withContext diff --git a/rns-host/src/main/kotlin/network/columba/app/rns/host/ble/util/BleDispatchers.kt b/rns-host/src/main/kotlin/network/columba/app/rns/host/ble/util/BleDispatchers.kt new file mode 100644 index 000000000..228713c8c --- /dev/null +++ b/rns-host/src/main/kotlin/network/columba/app/rns/host/ble/util/BleDispatchers.kt @@ -0,0 +1,22 @@ +package network.columba.app.rns.host.ble.util + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers + +/** + * Dispatchers for Android Bluetooth framework calls. + * + * Since Android 13, many framework Bluetooth APIs (`BluetoothLeScanner.startScan`/`stopScan`, + * `BluetoothGatt.discoverServices`/`requestConnectionPriority`/`disconnect`, + * `BluetoothLeAdvertiser.startAdvertising`, `BluetoothGattServer.sendResponse`, ...) are + * synchronous binder calls that block the caller via `SynchronousResultReceiver` until the + * Bluetooth process replies. When the Bluetooth stack is congested (e.g. heavy RNode GATT + * writes) these calls can block for seconds, so they must NEVER run on the main thread or the + * process gets a (Background) ANR. + * + * [ble] is a single-threaded view of [Dispatchers.IO]: it keeps the serialized, one-at-a-time + * semantics the code previously got from [Dispatchers.Main], without blocking the main looper. + */ +object BleDispatchers { + val ble: CoroutineDispatcher = Dispatchers.IO.limitedParallelism(1) +} From c6595850fcff40f532e7d84e5fde883d9ad3af7d Mon Sep 17 00:00:00 2001 From: "sentry[bot]" <39604003+sentry[bot]@users.noreply.github.com> Date: Wed, 7 Oct 2026 23:25:55 +0000 Subject: [PATCH 2/2] fix(ble): move blocking Bluetooth calls off main thread --- .../app/rns/host/ble/client/BleGattClient.kt | 23 +++++++++++-------- .../app/rns/host/ble/client/BleScanner.kt | 9 +++++--- .../app/rns/host/ble/server/BleAdvertiser.kt | 11 +++++---- .../app/rns/host/ble/server/BleGattServer.kt | 19 ++++++++------- .../host/ble/ErrorHandlingIntegrationTest.kt | 12 +++++----- .../ble/client/BleGattClientTeardownTest.kt | 1 + 6 files changed, 44 insertions(+), 31 deletions(-) diff --git a/rns-host/src/main/kotlin/network/columba/app/rns/host/ble/client/BleGattClient.kt b/rns-host/src/main/kotlin/network/columba/app/rns/host/ble/client/BleGattClient.kt index 010f0e8db..c2011a8e3 100644 --- a/rns-host/src/main/kotlin/network/columba/app/rns/host/ble/client/BleGattClient.kt +++ b/rns-host/src/main/kotlin/network/columba/app/rns/host/ble/client/BleGattClient.kt @@ -18,6 +18,7 @@ import androidx.core.content.ContextCompat import network.columba.app.rns.host.ble.model.BleConstants import network.columba.app.rns.host.ble.util.BleDispatchers import network.columba.app.rns.host.ble.util.BleOperationQueue +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -55,6 +56,7 @@ import kotlinx.coroutines.withContext * @property bluetoothAdapter Bluetooth adapter * @property operationQueue Operation queue for serial GATT operations * @property scope Coroutine scope + * @property bleDispatcher Dispatcher for blocking Bluetooth framework calls (never Main) */ @SuppressLint("MissingPermission") class BleGattClient( @@ -62,6 +64,7 @@ class BleGattClient( private val bluetoothAdapter: BluetoothAdapter, private val operationQueue: BleOperationQueue, private val scope: CoroutineScope = CoroutineScope(Dispatchers.Default + SupervisorJob()), + private val bleDispatcher: CoroutineDispatcher = BleDispatchers.ble, ) { companion object { private const val TAG = "Columba:BLE:K:Client" @@ -205,7 +208,7 @@ class BleGattClient( * @return Result with Unit on success, exception on failure */ suspend fun connect(address: String): Result = - withContext(BleDispatchers.ble) { + withContext(bleDispatcher) { try { // Check if already connected connectionsMutex.withLock { @@ -296,14 +299,14 @@ class BleGattClient( } val connData = - withContext(BleDispatchers.ble) { + withContext(bleDispatcher) { connectionsMutex.withLock { connections.remove(address) } } if (connData != null) { - withContext(BleDispatchers.ble) { + withContext(bleDispatcher) { connData.connectionJob?.cancel() safeGattTeardown(connData.gatt, "manual disconnect $address") } @@ -445,7 +448,7 @@ class BleGattClient( } // Request high connection priority for better stability and throughput - withContext(BleDispatchers.ble) { + withContext(bleDispatcher) { try { val priorityResult = gatt.requestConnectionPriority( @@ -463,7 +466,7 @@ class BleGattClient( // Discover services off the main thread: on Android 13+ discoverServices() // is a synchronous binder call that can block for seconds when the // Bluetooth stack is congested, which caused Background ANRs. - withContext(BleDispatchers.ble) { + withContext(bleDispatcher) { gatt.discoverServices() } } @@ -522,7 +525,7 @@ class BleGattClient( connectionsMutex.withLock { connections.remove(address) } - withContext(BleDispatchers.ble) { + withContext(bleDispatcher) { safeGattTeardown(gatt, "service discovery failed status=$status") } @@ -543,7 +546,7 @@ class BleGattClient( connectionsMutex.withLock { connections.remove(address) } - withContext(BleDispatchers.ble) { + withContext(bleDispatcher) { safeGattTeardown(gatt, "Reticulum service not found") } @@ -567,7 +570,7 @@ class BleGattClient( connectionsMutex.withLock { connections.remove(address) } - withContext(BleDispatchers.ble) { + withContext(bleDispatcher) { safeGattTeardown(gatt, "required characteristics not found") } @@ -852,7 +855,7 @@ class BleGattClient( } if (connData != null) { - withContext(BleDispatchers.ble) { + withContext(bleDispatcher) { safeGattTeardown(connData.gatt, "failed to enable notifications") } } @@ -1080,7 +1083,7 @@ class BleGattClient( val connData = connectionsMutex.withLock { connections.remove(address) } if (connData != null) { - withContext(BleDispatchers.ble) { + withContext(bleDispatcher) { safeGattTeardown(connData.gatt, "connection timeout") } onConnectionFailed?.invoke(address, "Connection timeout") diff --git a/rns-host/src/main/kotlin/network/columba/app/rns/host/ble/client/BleScanner.kt b/rns-host/src/main/kotlin/network/columba/app/rns/host/ble/client/BleScanner.kt index 80142c013..720b90f0d 100644 --- a/rns-host/src/main/kotlin/network/columba/app/rns/host/ble/client/BleScanner.kt +++ b/rns-host/src/main/kotlin/network/columba/app/rns/host/ble/client/BleScanner.kt @@ -18,6 +18,7 @@ import network.columba.app.rns.host.ble.model.BleConstants import network.columba.app.rns.host.ble.model.BleDevice import network.columba.app.rns.host.ble.model.BlePowerSettings import network.columba.app.rns.host.ble.util.BleDispatchers +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -48,6 +49,7 @@ import kotlinx.coroutines.withContext * @property context Application context * @property bluetoothAdapter Bluetooth adapter instance * @property scope Coroutine scope for async operations + * @property bleDispatcher Dispatcher for blocking Bluetooth framework calls (never Main) */ @SuppressLint("MissingPermission") @Suppress("TooManyFunctions") // Cohesive BLE scanner — splitting would be artificial @@ -55,6 +57,7 @@ class BleScanner( private val context: Context, private val bluetoothAdapter: BluetoothAdapter, private val scope: CoroutineScope = CoroutineScope(Dispatchers.Default + SupervisorJob()), + private val bleDispatcher: CoroutineDispatcher = BleDispatchers.ble, ) { companion object { private const val TAG = "Columba:BLE:K:Scan" @@ -145,7 +148,7 @@ class BleScanner( * @return Result indicating success or failure */ suspend fun startScanning(minRssi: Int = BleConstants.MIN_RSSI_DBM): Result { - return withContext(BleDispatchers.ble) { + return withContext(bleDispatcher) { try { // Check if already scanning if (_isScanning.value) { @@ -207,7 +210,7 @@ class BleScanner( scanJob?.cancel() scanJob = null - withContext(BleDispatchers.ble) { + withContext(bleDispatcher) { if (_isScanning.value && bluetoothLeScanner != null) { bluetoothLeScanner.stopScan(scanCallback) _isScanning.value = false @@ -249,7 +252,7 @@ class BleScanner( * Perform a single scan. */ private suspend fun performScan(minRssi: Int) { - withContext(BleDispatchers.ble) { + withContext(bleDispatcher) { try { val scanner = bluetoothLeScanner ?: return@withContext diff --git a/rns-host/src/main/kotlin/network/columba/app/rns/host/ble/server/BleAdvertiser.kt b/rns-host/src/main/kotlin/network/columba/app/rns/host/ble/server/BleAdvertiser.kt index 536d07000..2185ed41b 100644 --- a/rns-host/src/main/kotlin/network/columba/app/rns/host/ble/server/BleAdvertiser.kt +++ b/rns-host/src/main/kotlin/network/columba/app/rns/host/ble/server/BleAdvertiser.kt @@ -16,6 +16,7 @@ import androidx.core.content.ContextCompat import network.columba.app.rns.host.ble.model.BleConstants import network.columba.app.rns.host.ble.model.BlePowerSettings import network.columba.app.rns.host.ble.util.BleDispatchers +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -40,12 +41,14 @@ import kotlinx.coroutines.withContext * @property context Application context * @property bluetoothAdapter Bluetooth adapter * @property scope Coroutine scope for async operations + * @property bleDispatcher Dispatcher for blocking Bluetooth framework calls (never Main) */ @SuppressLint("MissingPermission") class BleAdvertiser( private val context: Context, private val bluetoothAdapter: BluetoothAdapter, private val scope: CoroutineScope = CoroutineScope(Dispatchers.Default + SupervisorJob()), + private val bleDispatcher: CoroutineDispatcher = BleDispatchers.ble, ) { companion object { private const val TAG = "Columba:BLE:K:Adv" @@ -143,7 +146,7 @@ class BleAdvertiser( * @return Result indicating success or failure */ suspend fun startAdvertising(deviceName: String = BleConstants.DEFAULT_DEVICE_NAME_PREFIX): Result = - withContext(BleDispatchers.ble) { + withContext(bleDispatcher) { try { // Check if already advertising if (_isAdvertising.value) { @@ -244,7 +247,7 @@ class BleAdvertiser( // Stop refresh job first stopRefreshJob() - withContext(BleDispatchers.ble) { + withContext(bleDispatcher) { if (_isAdvertising.value && bluetoothLeAdvertiser != null) { bluetoothLeAdvertiser.stopAdvertising(advertiseCallback) _isAdvertising.value = false @@ -367,7 +370,7 @@ class BleAdvertiser( isRefreshing = true try { // Stop current advertising - withContext(BleDispatchers.ble) { + withContext(bleDispatcher) { try { bluetoothLeAdvertiser?.stopAdvertising(advertiseCallback) } catch (e: Exception) { @@ -378,7 +381,7 @@ class BleAdvertiser( delay(100) // Brief delay for cleanup // Restart advertising - withContext(BleDispatchers.ble) { + withContext(bleDispatcher) { startAdvertisingInternal() } Log.d(TAG, "Advertising refreshed successfully") diff --git a/rns-host/src/main/kotlin/network/columba/app/rns/host/ble/server/BleGattServer.kt b/rns-host/src/main/kotlin/network/columba/app/rns/host/ble/server/BleGattServer.kt index b442c0000..5473fd28a 100644 --- a/rns-host/src/main/kotlin/network/columba/app/rns/host/ble/server/BleGattServer.kt +++ b/rns-host/src/main/kotlin/network/columba/app/rns/host/ble/server/BleGattServer.kt @@ -20,6 +20,7 @@ import androidx.core.content.ContextCompat import network.columba.app.rns.host.ble.model.BleConstants import network.columba.app.rns.host.ble.util.BleDispatchers import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -72,12 +73,14 @@ internal fun acceptsRxWrite( * @property context Application context * @property bluetoothManager Bluetooth manager * @property scope Coroutine scope for async operations + * @property bleDispatcher Dispatcher for blocking Bluetooth framework calls (never Main) */ @SuppressLint("MissingPermission") class BleGattServer( private val context: Context, private val bluetoothManager: BluetoothManager, private val scope: CoroutineScope = CoroutineScope(Dispatchers.Default + SupervisorJob()), + private val bleDispatcher: CoroutineDispatcher = BleDispatchers.ble, ) { companion object { private const val TAG = "Columba:BLE:K:Server" @@ -263,7 +266,7 @@ class BleGattServer( * @return Result indicating success or failure */ suspend fun open(): Result = - withContext(BleDispatchers.ble) { + withContext(bleDispatcher) { try { // Check if already open if (_isServerOpen.value) { @@ -359,7 +362,7 @@ class BleGattServer( * Close the GATT server. */ suspend fun close() = - withContext(BleDispatchers.ble) { + withContext(bleDispatcher) { try { // Clear connected centrals centralsMutex.withLock { @@ -432,7 +435,7 @@ class BleGattServer( IllegalStateException("TX characteristic not available"), ) - return withContext(BleDispatchers.ble) { + return withContext(bleDispatcher) { try { if (!hasConnectPermission()) { return@withContext Result.failure( @@ -493,7 +496,7 @@ class BleGattServer( * We must manually clean up state and fire the disconnect callback. */ suspend fun disconnectCentral(address: String) = - withContext(BleDispatchers.ble) { + withContext(bleDispatcher) { try { if (!hasConnectPermission()) { Log.w(TAG, "Cannot disconnect central, missing permission") @@ -671,7 +674,7 @@ class BleGattServer( requestId: Int, offset: Int, characteristic: BluetoothGattCharacteristic, - ) = withContext(BleDispatchers.ble) { + ) = withContext(bleDispatcher) { try { if (!hasConnectPermission()) { return@withContext @@ -736,7 +739,7 @@ class BleGattServer( responseNeeded: Boolean, offset: Int, value: ByteArray, - ) = withContext(BleDispatchers.ble) { + ) = withContext(bleDispatcher) { try { if (!hasConnectPermission()) { return@withContext @@ -828,7 +831,7 @@ class BleGattServer( requestId: Int, offset: Int, descriptor: BluetoothGattDescriptor, - ) = withContext(BleDispatchers.ble) { + ) = withContext(bleDispatcher) { try { if (!hasConnectPermission()) { return@withContext @@ -870,7 +873,7 @@ class BleGattServer( responseNeeded: Boolean, offset: Int, value: ByteArray, - ) = withContext(BleDispatchers.ble) { + ) = withContext(bleDispatcher) { try { if (!hasConnectPermission()) { return@withContext diff --git a/rns-host/src/test/kotlin/network/columba/app/rns/host/ble/ErrorHandlingIntegrationTest.kt b/rns-host/src/test/kotlin/network/columba/app/rns/host/ble/ErrorHandlingIntegrationTest.kt index 5db558279..247050f57 100644 --- a/rns-host/src/test/kotlin/network/columba/app/rns/host/ble/ErrorHandlingIntegrationTest.kt +++ b/rns-host/src/test/kotlin/network/columba/app/rns/host/ble/ErrorHandlingIntegrationTest.kt @@ -190,7 +190,7 @@ class ErrorHandlingIntegrationTest { fun `P0-3 Connection failure is propagated to UI callback`() = runTest { // Given: GATT client with connection failure callback - val client = BleGattClient(mockContext, mockBluetoothAdapter, BleOperationQueue()) + val client = BleGattClient(mockContext, mockBluetoothAdapter, BleOperationQueue(), bleDispatcher = testDispatcher) val failureLatch = CountDownLatch(1) var failedAddress: String? = null @@ -227,7 +227,7 @@ class ErrorHandlingIntegrationTest { fun `P1-1 Service discovery failure forces connection cleanup`() = runTest { // Given: GATT client with mocked GATT - val client = BleGattClient(mockContext, mockBluetoothAdapter, BleOperationQueue()) + val client = BleGattClient(mockContext, mockBluetoothAdapter, BleOperationQueue(), bleDispatcher = testDispatcher) val testAddress = "AA:BB:CC:DD:EE:FF" var connectionFailedCalled = false @@ -270,7 +270,7 @@ class ErrorHandlingIntegrationTest { fun `P1-2 Notification enable failure forces connection cleanup`() = runTest { // Given: GATT client - val client = BleGattClient(mockContext, mockBluetoothAdapter, BleOperationQueue()) + val client = BleGattClient(mockContext, mockBluetoothAdapter, BleOperationQueue(), bleDispatcher = testDispatcher) val testAddress = "AA:BB:CC:DD:EE:FF" val failureLatch = CountDownLatch(1) @@ -314,7 +314,7 @@ class ErrorHandlingIntegrationTest { fun `P1-3 Permission exception provides user-friendly message`() = runTest { // Given: GATT server that will fail with SecurityException - val server = BleGattServer(mockContext, mockBluetoothManager) + val server = BleGattServer(mockContext, mockBluetoothManager, bleDispatcher = testDispatcher) every { mockBluetoothManager.openGattServer(any(), any()) } throws SecurityException("Permission denied") @@ -401,7 +401,7 @@ class ErrorHandlingIntegrationTest { fun `concurrent connection failures are handled safely`() = runTest { // Given: GATT client and multiple concurrent connection attempts - val client = BleGattClient(mockContext, mockBluetoothAdapter, BleOperationQueue()) + val client = BleGattClient(mockContext, mockBluetoothAdapter, BleOperationQueue(), bleDispatcher = testDispatcher) val failureCount = CountDownLatch(3) client.onConnectionFailed = { _, _ -> @@ -465,7 +465,7 @@ class ErrorHandlingIntegrationTest { fun `null GATT during disconnect is handled gracefully`() = runTest { // Given: GATT client - val client = BleGattClient(mockContext, mockBluetoothAdapter, BleOperationQueue()) + val client = BleGattClient(mockContext, mockBluetoothAdapter, BleOperationQueue(), bleDispatcher = testDispatcher) val testAddress = "AA:BB:CC:DD:EE:FF" // When: Disconnect from address that was never connected diff --git a/rns-host/src/test/kotlin/network/columba/app/rns/host/ble/client/BleGattClientTeardownTest.kt b/rns-host/src/test/kotlin/network/columba/app/rns/host/ble/client/BleGattClientTeardownTest.kt index 1b934829c..9f95da50a 100644 --- a/rns-host/src/test/kotlin/network/columba/app/rns/host/ble/client/BleGattClientTeardownTest.kt +++ b/rns-host/src/test/kotlin/network/columba/app/rns/host/ble/client/BleGattClientTeardownTest.kt @@ -60,6 +60,7 @@ class BleGattClientTeardownTest { context = mockk(), bluetoothAdapter = mockk(), operationQueue = mockk(), + bleDispatcher = mainDispatcher, ) val connectionDataClass = BleGattClient::class.java.declaredClasses.single { it.simpleName == "ConnectionData" }