From 96d4027f741f58f5f40a9e0a4175da8a595874b6 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Fri, 20 Feb 2026 18:02:00 -0600 Subject: [PATCH] feat(ble): Add support for `FromRadioSync` characteristic (#4609) Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com> --- .../geeksville/mesh/MeshUtilApplication.kt | 4 + .../repository/radio/NordicBleInterface.kt | 71 +++++++--- .../radio/NordicBleInterfaceRetryTest.kt | 18 +-- .../radio/NordicBleInterfaceTest.kt | 121 +++++++++++++++--- .../org/meshtastic/core/ble/BleConnection.kt | 49 +++++-- .../core/ble/BluetoothBroadcastReceiver.kt | 48 ------- .../core/ble/BluetoothRepository.kt | 3 +- .../core/ble/MeshtasticBleConstants.kt | 2 + .../org/meshtastic/core/ble/BleRetryTest.kt | 85 ++++++++++++ .../org/meshtastic/core/ble/BleScannerTest.kt | 103 +++++++++++++++ 10 files changed, 396 insertions(+), 108 deletions(-) delete mode 100644 core/ble/src/main/kotlin/org/meshtastic/core/ble/BluetoothBroadcastReceiver.kt create mode 100644 core/ble/src/test/kotlin/org/meshtastic/core/ble/BleRetryTest.kt create mode 100644 core/ble/src/test/kotlin/org/meshtastic/core/ble/BleScannerTest.kt diff --git a/app/src/main/java/com/geeksville/mesh/MeshUtilApplication.kt b/app/src/main/java/com/geeksville/mesh/MeshUtilApplication.kt index 13a136129..8ddb77899 100644 --- a/app/src/main/java/com/geeksville/mesh/MeshUtilApplication.kt +++ b/app/src/main/java/com/geeksville/mesh/MeshUtilApplication.kt @@ -33,6 +33,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.cancel import kotlinx.coroutines.launch +import no.nordicsemi.kotlin.ble.core.android.AndroidEnvironment import org.meshtastic.core.database.DatabaseManager import org.meshtastic.core.prefs.mesh.MeshPrefs import org.meshtastic.core.prefs.meshlog.MeshLogPrefs @@ -71,6 +72,7 @@ open class MeshUtilApplication : // Shutdown managers (useful for Robolectric tests) val entryPoint = EntryPointAccessors.fromApplication(this, AppEntryPoint::class.java) entryPoint.databaseManager().close() + entryPoint.androidEnvironment().close() applicationScope.cancel() super.onTerminate() } @@ -99,6 +101,8 @@ interface AppEntryPoint { fun meshPrefs(): MeshPrefs fun meshLogPrefs(): MeshLogPrefs + + fun androidEnvironment(): AndroidEnvironment } fun logAssert(executeReliableWrite: Boolean) { diff --git a/app/src/main/java/com/geeksville/mesh/repository/radio/NordicBleInterface.kt b/app/src/main/java/com/geeksville/mesh/repository/radio/NordicBleInterface.kt index 8cff1e088..3ab5b5300 100644 --- a/app/src/main/java/com/geeksville/mesh/repository/radio/NordicBleInterface.kt +++ b/app/src/main/java/com/geeksville/mesh/repository/radio/NordicBleInterface.kt @@ -51,6 +51,7 @@ import org.meshtastic.core.ble.BleConnection import org.meshtastic.core.ble.BleError import org.meshtastic.core.ble.BleScanner import org.meshtastic.core.ble.MeshtasticBleConstants.FROMNUM_CHARACTERISTIC +import org.meshtastic.core.ble.MeshtasticBleConstants.FROMRADIOSYNC_CHARACTERISTIC import org.meshtastic.core.ble.MeshtasticBleConstants.FROMRADIO_CHARACTERISTIC import org.meshtastic.core.ble.MeshtasticBleConstants.LOGRADIO_CHARACTERISTIC import org.meshtastic.core.ble.MeshtasticBleConstants.SERVICE_UUID @@ -113,6 +114,7 @@ constructor( private var fromNumCharacteristic: RemoteCharacteristic? = null private var fromRadioCharacteristic: RemoteCharacteristic? = null private var logRadioCharacteristic: RemoteCharacteristic? = null + private var fromRadioSyncCharacteristic: RemoteCharacteristic? = null init { connect() @@ -257,13 +259,15 @@ constructor( try { val chars = bleConnection.discoverCharacteristics( - SERVICE_UUID, + serviceUuid = SERVICE_UUID, + requiredUuids = listOf( TORADIO_CHARACTERISTIC, FROMNUM_CHARACTERISTIC, FROMRADIO_CHARACTERISTIC, LOGRADIO_CHARACTERISTIC, ), + optionalUuids = listOf(FROMRADIOSYNC_CHARACTERISTIC), ) if (chars != null) { @@ -271,6 +275,7 @@ constructor( fromNumCharacteristic = chars[FROMNUM_CHARACTERISTIC] fromRadioCharacteristic = chars[FROMRADIO_CHARACTERISTIC] logRadioCharacteristic = chars[LOGRADIO_CHARACTERISTIC] + fromRadioSyncCharacteristic = chars[FROMRADIOSYNC_CHARACTERISTIC] Logger.d { "[$address] Characteristics discovered successfully" } setupNotifications() @@ -288,25 +293,48 @@ constructor( // --- Notification Setup --- + @Suppress("LongMethod") private suspend fun setupNotifications() { - val fromNumReady = CompletableDeferred() + val fromRadioReady = CompletableDeferred() val logRadioReady = CompletableDeferred() - fromNumCharacteristic - ?.subscribe { - Logger.d { "[$address] FromNum subscription active" } - fromNumReady.complete(Unit) - } - ?.onEach { notifyBytes -> - Logger.d { "[$address] FromNum Notification (${notifyBytes.size} bytes), draining queue" } - connectionScope.launch { drainPacketQueueAndDispatch() } - } - ?.catch { e -> - if (!fromNumReady.isCompleted) fromNumReady.completeExceptionally(e) - Logger.w(e) { "[$address] Error in fromNumCharacteristic subscription" } - service.onDisconnect(BleError.from(e)) - } - ?.launchIn(connectionScope) ?: fromNumReady.complete(Unit) + // 1. Prefer FromRadioSync (Indicate) if available + if (fromRadioSyncCharacteristic != null) { + Logger.i { "[$address] Using FromRadioSync for packet reception" } + fromRadioSyncCharacteristic + ?.subscribe { + Logger.d { "[$address] FromRadioSync subscription active" } + fromRadioReady.complete(Unit) + } + ?.onEach { payload -> + Logger.d { "[$address] FromRadioSync Indication (${payload.size} bytes)" } + dispatchPacket(payload) + } + ?.catch { e -> + if (!fromRadioReady.isCompleted) fromRadioReady.completeExceptionally(e) + Logger.w(e) { "[$address] Error in fromRadioSyncCharacteristic subscription" } + service.onDisconnect(BleError.from(e)) + } + ?.launchIn(connectionScope) ?: fromRadioReady.complete(Unit) + } else { + // 2. Fallback to legacy FromNum (Notify) + FromRadio (Read) + Logger.i { "[$address] Using legacy FromNum/FromRadio for packet reception" } + fromNumCharacteristic + ?.subscribe { + Logger.d { "[$address] FromNum subscription active" } + fromRadioReady.complete(Unit) + } + ?.onEach { notifyBytes -> + Logger.d { "[$address] FromNum Notification (${notifyBytes.size} bytes), draining queue" } + connectionScope.launch { drainPacketQueueAndDispatch() } + } + ?.catch { e -> + if (!fromRadioReady.isCompleted) fromRadioReady.completeExceptionally(e) + Logger.w(e) { "[$address] Error in fromNumCharacteristic subscription" } + service.onDisconnect(BleError.from(e)) + } + ?.launchIn(connectionScope) ?: fromRadioReady.complete(Unit) + } logRadioCharacteristic ?.subscribe { @@ -326,7 +354,7 @@ constructor( try { withTimeout(CONNECTION_TIMEOUT_MS) { - fromNumReady.await() + fromRadioReady.await() logRadioReady.await() } Logger.d { "[$address] All notifications successfully subscribed" } @@ -364,7 +392,11 @@ constructor( "to toRadioCharacteristic with $writeType - " + "${p.size} bytes (Total TX: $bytesSent bytes)" } - drainPacketQueueAndDispatch() + + // Only manually drain if we are using the legacy FromNum/FromRadio flow + if (fromRadioSyncCharacteristic == null) { + drainPacketQueueAndDispatch() + } } catch (e: InvalidAttributeException) { Logger.w(e) { "[$address] Attribute invalidated during write, clearing characteristics" } handleInvalidAttribute(e) @@ -415,5 +447,6 @@ constructor( fromNumCharacteristic = null fromRadioCharacteristic = null logRadioCharacteristic = null + fromRadioSyncCharacteristic = null } } diff --git a/app/src/test/java/com/geeksville/mesh/repository/radio/NordicBleInterfaceRetryTest.kt b/app/src/test/java/com/geeksville/mesh/repository/radio/NordicBleInterfaceRetryTest.kt index 87ff2753e..eb4ac385d 100644 --- a/app/src/test/java/com/geeksville/mesh/repository/radio/NordicBleInterfaceRetryTest.kt +++ b/app/src/test/java/com/geeksville/mesh/repository/radio/NordicBleInterfaceRetryTest.kt @@ -22,8 +22,8 @@ import io.mockk.clearMocks import io.mockk.mockk import io.mockk.verify import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.delay import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import no.nordicsemi.kotlin.ble.client.android.CentralManager import no.nordicsemi.kotlin.ble.client.android.mock.mock @@ -139,7 +139,7 @@ class NordicBleInterfaceRetryTest { } centralManager.simulatePeripherals(listOf(peripheralSpec)) - delay(100.milliseconds) + advanceUntilIdle() val nordicInterface = NordicBleInterface( @@ -150,7 +150,7 @@ class NordicBleInterfaceRetryTest { ) // Wait for connection and stable state - delay(2000.milliseconds) + advanceUntilIdle() verify(timeout = 5000) { service.onConnect() } // Clear initial discovery errors if any (sometimes mock emits empty list initially) @@ -160,8 +160,8 @@ class NordicBleInterfaceRetryTest { val dataToSend = byteArrayOf(0x01, 0x02, 0x03) nordicInterface.handleSendToRadio(dataToSend) - // Give it time to process retries (500ms delay per retry in code) - delay(1500.milliseconds) + // Give it time to process retries + advanceUntilIdle() assert(writeAttempts == 2) { "Should have attempted write twice, but was $writeAttempts" } assert(writtenValue != null) { "Value should have been eventually written" } @@ -244,7 +244,7 @@ class NordicBleInterfaceRetryTest { } centralManager.simulatePeripherals(listOf(peripheralSpec)) - delay(100.milliseconds) + advanceUntilIdle() val nordicInterface = NordicBleInterface( @@ -255,7 +255,7 @@ class NordicBleInterfaceRetryTest { ) // Wait for connection - delay(2000.milliseconds) + advanceUntilIdle() verify(timeout = 5000) { service.onConnect() } // Clear initial discovery errors @@ -264,8 +264,8 @@ class NordicBleInterfaceRetryTest { // Trigger write which will fail repeatedly nordicInterface.handleSendToRadio(byteArrayOf(0x01)) - // Wait for all 3 attempts + delays (500ms * 2) - delay(2500.milliseconds) + // Wait for all attempts + advanceUntilIdle() assert(writeAttempts == 3) { "Should have attempted write 3 times (initial + 2 retries), but was $writeAttempts" diff --git a/app/src/test/java/com/geeksville/mesh/repository/radio/NordicBleInterfaceTest.kt b/app/src/test/java/com/geeksville/mesh/repository/radio/NordicBleInterfaceTest.kt index 7523be29a..2974d3029 100644 --- a/app/src/test/java/com/geeksville/mesh/repository/radio/NordicBleInterfaceTest.kt +++ b/app/src/test/java/com/geeksville/mesh/repository/radio/NordicBleInterfaceTest.kt @@ -21,8 +21,8 @@ import co.touchlab.kermit.Severity import io.mockk.mockk import io.mockk.verify import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.delay import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import no.nordicsemi.kotlin.ble.client.android.CentralManager import no.nordicsemi.kotlin.ble.client.android.mock.mock @@ -42,6 +42,7 @@ import org.junit.Before import org.junit.Test import org.meshtastic.core.ble.BleError import org.meshtastic.core.ble.MeshtasticBleConstants.FROMNUM_CHARACTERISTIC +import org.meshtastic.core.ble.MeshtasticBleConstants.FROMRADIOSYNC_CHARACTERISTIC import org.meshtastic.core.ble.MeshtasticBleConstants.FROMRADIO_CHARACTERISTIC import org.meshtastic.core.ble.MeshtasticBleConstants.LOGRADIO_CHARACTERISTIC import org.meshtastic.core.ble.MeshtasticBleConstants.SERVICE_UUID @@ -146,7 +147,7 @@ class NordicBleInterfaceTest { centralManager.getBondedPeripherals().forEach { println("Found bonded peripheral: ${it.address}") } // Give it a moment to stabilize - delay(100.milliseconds) + advanceUntilIdle() // Create the interface println("Creating NordicBleInterface") @@ -160,7 +161,7 @@ class NordicBleInterfaceTest { // Wait for connection and discovery println("Waiting for connection...") - delay(2000.milliseconds) + advanceUntilIdle() println("Verifying onConnect...") verify(timeout = 5000) { service.onConnect() } @@ -173,13 +174,13 @@ class NordicBleInterfaceTest { otaPeripheral.simulateValueUpdate(fromNumHandle, byteArrayOf(0x01)) // Wait for drain to start - delay(500.milliseconds) + advanceUntilIdle() // Simulate a log radio notification val logData = "test log".toByteArray() otaPeripheral.simulateValueUpdate(logRadioHandle, logData) - delay(500.milliseconds) + advanceUntilIdle() // Explicitly stub handleFromRadio just in case relaxed mock fails io.mockk.every { service.handleFromRadio(any()) } returns Unit @@ -281,7 +282,7 @@ class NordicBleInterfaceTest { } centralManager.simulatePeripherals(listOf(peripheralSpec)) - delay(100.milliseconds) + advanceUntilIdle() val nordicInterface = NordicBleInterface( @@ -292,7 +293,7 @@ class NordicBleInterfaceTest { ) // Wait for connection - delay(1000.milliseconds) + advanceUntilIdle() verify(timeout = 2000) { service.onConnect() } // Test writing @@ -300,7 +301,7 @@ class NordicBleInterfaceTest { nordicInterface.handleSendToRadio(dataToSend) // Give it time to process - delay(500.milliseconds) + advanceUntilIdle() assert(writtenValue != null) { "Value should have been written" } assert(writtenValue!!.contentEquals(dataToSend)) { @@ -374,7 +375,7 @@ class NordicBleInterfaceTest { } centralManager.simulatePeripherals(listOf(peripheralSpec)) - delay(100.milliseconds) + advanceUntilIdle() val nordicInterface = NordicBleInterface( @@ -385,7 +386,7 @@ class NordicBleInterfaceTest { ) // Wait for connection - delay(1000.milliseconds) + advanceUntilIdle() verify(timeout = 2000) { service.onConnect() } // Find the connected peripheral from CentralManager to trigger disconnect @@ -395,7 +396,7 @@ class NordicBleInterfaceTest { connectedPeripheral.disconnect() // Wait for disconnect event propagation - delay(1000.milliseconds) + advanceUntilIdle() // Verify onDisconnect was called on the service // NordicBleInterface calls onDisconnect(BleError.Disconnected) @@ -465,7 +466,7 @@ class NordicBleInterfaceTest { } centralManager.simulatePeripherals(listOf(peripheralSpec)) - delay(100.milliseconds) + advanceUntilIdle() val nordicInterface = NordicBleInterface( @@ -476,7 +477,7 @@ class NordicBleInterfaceTest { ) // Wait for connection and eventual failure - delay(1000.milliseconds) + advanceUntilIdle() // Verify that discovery failed verify { service.onDisconnect(any()) } @@ -551,7 +552,7 @@ class NordicBleInterfaceTest { } centralManager.simulatePeripherals(listOf(peripheralSpec)) - delay(1000.milliseconds) + advanceUntilIdle() val nordicInterface = NordicBleInterface( @@ -562,7 +563,7 @@ class NordicBleInterfaceTest { ) // Wait for connection - delay(1000.milliseconds) + advanceUntilIdle() verify(timeout = 2000) { service.onConnect() } // Trigger write which will fail @@ -570,11 +571,99 @@ class NordicBleInterfaceTest { // Wait for error propagation (retries take time!) // 3 attempts with 500ms delay between them = ~1000ms+ - delay(2500.milliseconds) + advanceUntilIdle() // Verify onDisconnect was called with error verify { service.onDisconnect(any()) } nordicInterface.close() } + + @Test + fun `fromRadioSync flow prefers Indicate characteristic`() = runTest(testDispatcher) { + val mockEnvironment = MockAndroidEnvironment.Api31(isBluetoothEnabled = true) + val centralManager = CentralManager.mock(mockEnvironment, scope = backgroundScope) + val service = mockk(relaxed = true) + + var syncCharHandle: Int = -1 + val payload = byteArrayOf(0xDE.toByte(), 0xAD.toByte()) + + val eventHandler = + object : PeripheralSpecEventHandler { + override fun onConnectionRequest(preferredPhy: List) = + ConnectionResult.Accept + + override fun onReadRequest(characteristic: MockRemoteCharacteristic): ReadResponse = + ReadResponse.Success(byteArrayOf()) + } + + val peripheralSpec = + PeripheralSpec.simulatePeripheral(identifier = address, proximity = Proximity.IMMEDIATE) { + advertising( + parameters = LegacyAdvertisingSetParameters(connectable = true, interval = 100.milliseconds), + ) { + CompleteLocalName("Meshtastic_Sync") + } + connectable( + name = "Meshtastic_Sync", + isBonded = true, + eventHandler = eventHandler, + cachedServices = { + Service(uuid = SERVICE_UUID) { + Characteristic( + uuid = TORADIO_CHARACTERISTIC, + properties = setOf(CharacteristicProperty.WRITE), + permission = Permission.WRITE, + ) + Characteristic( + uuid = FROMNUM_CHARACTERISTIC, + properties = setOf(CharacteristicProperty.NOTIFY), + permission = Permission.READ, + ) + Characteristic( + uuid = FROMRADIO_CHARACTERISTIC, + properties = setOf(CharacteristicProperty.READ), + permission = Permission.READ, + ) + Characteristic( + uuid = LOGRADIO_CHARACTERISTIC, + properties = setOf(CharacteristicProperty.NOTIFY), + permission = Permission.READ, + ) + // NEW: Provide the Sync characteristic + syncCharHandle = + Characteristic( + uuid = FROMRADIOSYNC_CHARACTERISTIC, + properties = setOf(CharacteristicProperty.INDICATE), + permission = Permission.READ, + ) + } + }, + ) + } + + centralManager.simulatePeripherals(listOf(peripheralSpec)) + advanceUntilIdle() + + val nordicInterface = + NordicBleInterface( + serviceScope = this, + centralManager = centralManager, + service = service, + address = address, + ) + + // Wait for connection and discovery + advanceUntilIdle() + verify(timeout = 2000) { service.onConnect() } + + // Simulate an indication from FROMRADIOSYNC + peripheralSpec.simulateValueUpdate(syncCharHandle, payload) + advanceUntilIdle() + + // Verify handleFromRadio was called directly with the payload + verify(timeout = 2000) { service.handleFromRadio(p = payload) } + + nordicInterface.close() + } } diff --git a/core/ble/src/main/kotlin/org/meshtastic/core/ble/BleConnection.kt b/core/ble/src/main/kotlin/org/meshtastic/core/ble/BleConnection.kt index 08419cde8..0e3982421 100644 --- a/core/ble/src/main/kotlin/org/meshtastic/core/ble/BleConnection.kt +++ b/core/ble/src/main/kotlin/org/meshtastic/core/ble/BleConnection.kt @@ -20,14 +20,20 @@ import co.touchlab.kermit.Logger import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.shareIn import kotlinx.coroutines.withTimeout import no.nordicsemi.android.common.core.simpleSharedFlow import no.nordicsemi.kotlin.ble.client.RemoteCharacteristic +import no.nordicsemi.kotlin.ble.client.RemoteService import no.nordicsemi.kotlin.ble.client.android.CentralManager import no.nordicsemi.kotlin.ble.client.android.ConnectionPriority import no.nordicsemi.kotlin.ble.client.android.Peripheral @@ -105,25 +111,40 @@ class BleConnection( } } - /** Discovers characteristics for a specific service with retries. */ - @Suppress("ReturnCount") + /** A flow of discovered services. Useful for reacting to "Service Changed" indications. */ + val services: SharedFlow> = + _connectionState + .asSharedFlow() + .filter { it is ConnectionState.Connected } + .flatMapLatest { peripheral?.services() ?: flowOf(emptyList()) } + .filterNotNull() + .shareIn(scope, SharingStarted.WhileSubscribed(), replay = 1) + + /** Discovers characteristics for a specific service. */ suspend fun discoverCharacteristics( serviceUuid: Uuid, - characteristicUuids: List, - ): Map? = retryBleOperation(tag = tag) { - val p = peripheral ?: return@retryBleOperation null - val services = - withTimeout(SERVICE_DISCOVERY_TIMEOUT_MS) { p.services(listOf(serviceUuid)).filterNotNull().first() } - val service = services.find { it.uuid == serviceUuid } ?: return@retryBleOperation null + requiredUuids: List, + optionalUuids: List = emptyList(), + ): Map? { + val p = peripheral ?: return null - val result = mutableMapOf() - for (uuid in characteristicUuids) { - val char = service.characteristics.find { it.uuid == uuid } - if (char != null) { - result[uuid] = char + return retryBleOperation(tag = tag) { + val allRequested = requiredUuids + optionalUuids + val serviceList = + withTimeout(SERVICE_DISCOVERY_TIMEOUT_MS) { p.services(listOf(serviceUuid)).filterNotNull().first() } + val service = serviceList.find { it.uuid == serviceUuid } ?: return@retryBleOperation null + + val result = mutableMapOf() + for (uuid in allRequested) { + val char = service.characteristics.find { it.uuid == uuid } + if (char != null) { + result[uuid] = char + } } + + val hasAllRequired = requiredUuids.all { result.containsKey(it) } + if (hasAllRequired) result else null } - return@retryBleOperation if (result.size == characteristicUuids.size) result else null } private fun observePeripheralDetails(p: Peripheral) { diff --git a/core/ble/src/main/kotlin/org/meshtastic/core/ble/BluetoothBroadcastReceiver.kt b/core/ble/src/main/kotlin/org/meshtastic/core/ble/BluetoothBroadcastReceiver.kt deleted file mode 100644 index 4f3d3d686..000000000 --- a/core/ble/src/main/kotlin/org/meshtastic/core/ble/BluetoothBroadcastReceiver.kt +++ /dev/null @@ -1,48 +0,0 @@ -/* - * Copyright (c) 2025-2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.core.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 dagger.Lazy -import javax.inject.Inject - -/** BroadcastReceiver to handle Bluetooth adapter and device state changes. */ -class BluetoothBroadcastReceiver @Inject constructor(private val bluetoothRepository: Lazy) : - BroadcastReceiver() { - - val intentFilter: IntentFilter - get() = - IntentFilter().apply { - addAction(BluetoothAdapter.ACTION_STATE_CHANGED) - addAction(BluetoothDevice.ACTION_BOND_STATE_CHANGED) - } - - override fun onReceive(context: Context, intent: Intent) { - when (intent.action) { - BluetoothAdapter.ACTION_STATE_CHANGED, - BluetoothDevice.ACTION_BOND_STATE_CHANGED, - -> { - bluetoothRepository.get().refreshState() - } - } - } -} diff --git a/core/ble/src/main/kotlin/org/meshtastic/core/ble/BluetoothRepository.kt b/core/ble/src/main/kotlin/org/meshtastic/core/ble/BluetoothRepository.kt index 41a300100..a22e0729e 100644 --- a/core/ble/src/main/kotlin/org/meshtastic/core/ble/BluetoothRepository.kt +++ b/core/ble/src/main/kotlin/org/meshtastic/core/ble/BluetoothRepository.kt @@ -18,7 +18,6 @@ package org.meshtastic.core.ble import android.annotation.SuppressLint import android.bluetooth.BluetoothAdapter -import android.os.Build import androidx.lifecycle.Lifecycle import androidx.lifecycle.coroutineScope import co.touchlab.kermit.Logger @@ -86,7 +85,7 @@ constructor( internal suspend fun updateBluetoothState() { val hasPerms = - if (androidEnvironment.androidSdkVersion >= Build.VERSION_CODES.S) { + if (androidEnvironment.requiresBluetoothRuntimePermissions) { androidEnvironment.isBluetoothScanPermissionGranted && androidEnvironment.isBluetoothConnectPermissionGranted } else { diff --git a/core/ble/src/main/kotlin/org/meshtastic/core/ble/MeshtasticBleConstants.kt b/core/ble/src/main/kotlin/org/meshtastic/core/ble/MeshtasticBleConstants.kt index eb73e3ec4..789110ac6 100644 --- a/core/ble/src/main/kotlin/org/meshtastic/core/ble/MeshtasticBleConstants.kt +++ b/core/ble/src/main/kotlin/org/meshtastic/core/ble/MeshtasticBleConstants.kt @@ -37,4 +37,6 @@ object MeshtasticBleConstants { /** Characteristic for receiving log notifications from the radio. */ val LOGRADIO_CHARACTERISTIC: Uuid = Uuid.parse("5a3d6e49-06e6-4423-9944-e9de8cdf9547") + + val FROMRADIOSYNC_CHARACTERISTIC: Uuid = Uuid.parse("888a50c3-982d-45db-9963-c7923769165d") } diff --git a/core/ble/src/test/kotlin/org/meshtastic/core/ble/BleRetryTest.kt b/core/ble/src/test/kotlin/org/meshtastic/core/ble/BleRetryTest.kt new file mode 100644 index 000000000..0fdf4d1a0 --- /dev/null +++ b/core/ble/src/test/kotlin/org/meshtastic/core/ble/BleRetryTest.kt @@ -0,0 +1,85 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.ble + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class BleRetryTest { + + @Test + fun `retryBleOperation returns immediately on success`() = runTest { + var attempts = 0 + val result = + retryBleOperation(count = 3, delayMs = 10L) { + attempts++ + "success" + } + assertEquals("success", result) + assertEquals(1, attempts) + } + + @Test + fun `retryBleOperation retries on exception and succeeds`() = runTest { + var attempts = 0 + val result = + retryBleOperation(count = 3, delayMs = 10L) { + attempts++ + if (attempts < 2) { + throw RuntimeException("Temporary error") + } + "success" + } + assertEquals("success", result) + assertEquals(2, attempts) + } + + @Test + fun `retryBleOperation throws exception after max attempts`() = runTest { + var attempts = 0 + var caughtException: Exception? = null + try { + retryBleOperation(count = 3, delayMs = 10L) { + attempts++ + throw RuntimeException("Persistent error") + } + } catch (e: Exception) { + caughtException = e + } + + assertTrue(caughtException is RuntimeException) + assertEquals("Persistent error", caughtException?.message) + assertEquals(3, attempts) + } + + @Test(expected = CancellationException::class) + fun `retryBleOperation does not retry CancellationException`() = runTest { + var attempts = 0 + retryBleOperation(count = 3, delayMs = 10L) { + attempts++ + throw CancellationException("Cancelled") + } + // Test fails if it catches and doesn't rethrow, or if it retries. + // It shouldn't reach the assertion below because the exception should be thrown immediately. + assertEquals(1, attempts) + } +} diff --git a/core/ble/src/test/kotlin/org/meshtastic/core/ble/BleScannerTest.kt b/core/ble/src/test/kotlin/org/meshtastic/core/ble/BleScannerTest.kt new file mode 100644 index 000000000..4a4fa28a3 --- /dev/null +++ b/core/ble/src/test/kotlin/org/meshtastic/core/ble/BleScannerTest.kt @@ -0,0 +1,103 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.ble + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import no.nordicsemi.kotlin.ble.client.android.CentralManager +import no.nordicsemi.kotlin.ble.client.android.mock.mock +import no.nordicsemi.kotlin.ble.client.mock.AddressType +import no.nordicsemi.kotlin.ble.client.mock.PeripheralSpec +import no.nordicsemi.kotlin.ble.client.mock.Proximity +import no.nordicsemi.kotlin.ble.core.LegacyAdvertisingSetParameters +import no.nordicsemi.kotlin.ble.environment.android.mock.MockAndroidEnvironment +import org.junit.Assert.assertEquals +import org.junit.Test +import kotlin.time.Duration.Companion.seconds +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +@OptIn(ExperimentalCoroutinesApi::class, ExperimentalUuidApi::class) +class BleScannerTest { + + private val testDispatcher = UnconfinedTestDispatcher() + + @Test + fun `scan returns peripherals`() = runTest(testDispatcher) { + val mockEnvironment = MockAndroidEnvironment.Api31(isBluetoothEnabled = true) + val centralManager = CentralManager.mock(mockEnvironment, backgroundScope) + val scanner = BleScanner(centralManager) + + val peripheral = + PeripheralSpec.simulatePeripheral( + identifier = "00:11:22:33:44:55", + addressType = AddressType.RANDOM_STATIC, + proximity = Proximity.IMMEDIATE, + ) { + advertising(parameters = LegacyAdvertisingSetParameters(connectable = true)) { + CompleteLocalName("Test_Device") + } + } + + centralManager.simulatePeripherals(listOf(peripheral)) + + val result = scanner.scan(5.seconds).first() + + assertEquals("00:11:22:33:44:55", result.address) + assertEquals("Test_Device", result.name) + } + + @Test + fun `scan with filter returns only matching peripherals`() = runTest(testDispatcher) { + val mockEnvironment = MockAndroidEnvironment.Api31(isBluetoothEnabled = true) + val centralManager = CentralManager.mock(mockEnvironment, backgroundScope) + val scanner = BleScanner(centralManager) + + val targetUuid = Uuid.parse("4FAFC201-1FB5-459E-8FCC-C5C9C331914B") + + val matchingPeripheral = + PeripheralSpec.simulatePeripheral(identifier = "00:11:22:33:44:55", proximity = Proximity.IMMEDIATE) { + advertising(parameters = LegacyAdvertisingSetParameters(connectable = true)) { + CompleteLocalName("Matching_Device") + ServiceUuid(targetUuid) + } + } + + val nonMatchingPeripheral = + PeripheralSpec.simulatePeripheral(identifier = "AA:BB:CC:DD:EE:FF", proximity = Proximity.IMMEDIATE) { + advertising(parameters = LegacyAdvertisingSetParameters(connectable = true)) { + CompleteLocalName("Non_Matching_Device") + } + } + + centralManager.simulatePeripherals(listOf(matchingPeripheral, nonMatchingPeripheral)) + + val scannedDevices = mutableListOf() + val job = launch { scanner.scan(5.seconds) { ServiceUuid(targetUuid) }.toList(scannedDevices) } + + // Needs time to scan in mock environment + advanceUntilIdle() + job.cancel() + + // TODO: test filter logic correctly if necessary + } +}