diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 901e6854f..f759ccfb0 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -245,7 +245,7 @@ dependencies { implementation(libs.accompanist.permissions) implementation(libs.kermit) - implementation(libs.nordic) + implementation(libs.nordic.client.android) debugImplementation(libs.androidx.compose.ui.test.manifest) @@ -263,6 +263,10 @@ dependencies { testImplementation(libs.junit) testImplementation(libs.mockk) testImplementation(libs.kotlinx.coroutines.test) + testImplementation(libs.nordic.client.android.mock) + testImplementation(libs.nordic.client.mock) + testImplementation(libs.nordic.core.mock) + testImplementation(libs.nordic.core.android.mock) } aboutLibraries { diff --git a/app/src/main/java/com/geeksville/mesh/model/BTScanModel.kt b/app/src/main/java/com/geeksville/mesh/model/BTScanModel.kt index a8cca8e9b..319e07008 100644 --- a/app/src/main/java/com/geeksville/mesh/model/BTScanModel.kt +++ b/app/src/main/java/com/geeksville/mesh/model/BTScanModel.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025 Meshtastic LLC + * 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 @@ -14,7 +14,6 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ - package com.geeksville.mesh.model import android.app.Application @@ -209,10 +208,11 @@ constructor( Logger.i { "Bonding complete for ${entry.peripheral.address.anonymize}, selecting device..." } changeDeviceAddress(entry.fullAddress) } catch (ex: SecurityException) { - Logger.e(ex) { "Bonding failed for ${entry.peripheral.address.anonymize} Permissions not granted" } + Logger.w(ex) { "Bonding failed for ${entry.peripheral.address.anonymize} Permissions not granted" } serviceRepository.setErrorMessage("Bonding failed: ${ex.message} Permissions not granted") } catch (ex: Exception) { - Logger.e(ex) { "Bonding failed for ${entry.peripheral.address.anonymize}" } + // Bonding is often flaky and can fail for many reasons (timeout, user cancel, etc) + Logger.w(ex) { "Bonding failed for ${entry.peripheral.address.anonymize}" } serviceRepository.setErrorMessage("Bonding failed: ${ex.message}") } } diff --git a/app/src/main/java/com/geeksville/mesh/repository/radio/BleError.kt b/app/src/main/java/com/geeksville/mesh/repository/radio/BleError.kt index c9100ad5f..af35efae7 100644 --- a/app/src/main/java/com/geeksville/mesh/repository/radio/BleError.kt +++ b/app/src/main/java/com/geeksville/mesh/repository/radio/BleError.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025 Meshtastic LLC + * 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 @@ -14,7 +14,6 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ - package com.geeksville.mesh.repository.radio import com.geeksville.mesh.service.RadioNotConnectedException @@ -67,8 +66,7 @@ sealed class BleError(val message: String, val shouldReconnect: Boolean) { * * @param exception The underlying GattException. */ - class GattError(exception: GattException) : - BleError("Gatt exception: ${exception.message}", shouldReconnect = true) + class GattError(exception: GattException) : BleError("Gatt exception: ${exception.message}", shouldReconnect = true) /** * Wraps a generic BluetoothException. The reconnection strategy depends on the nature of the Bluetooth error. @@ -86,9 +84,12 @@ sealed class BleError(val message: String, val shouldReconnect: Boolean) { class OperationFailed(exception: OperationFailedException) : BleError("Operation failed: ${exception.message}", shouldReconnect = true) - /** An invalid attribute was used. This is a non-recoverable error. */ + /** + * An invalid attribute was used. This usually happens when the GATT handles become stale (e.g. after a service + * change or an unexpected disconnect). This is recoverable via a fresh connection and discovery. + */ class InvalidAttribute(exception: InvalidAttributeException) : - BleError("Invalid attribute: ${exception.message}", shouldReconnect = false) + BleError("Invalid attribute: ${exception.message}", shouldReconnect = true) /** An error occurred while scanning for devices. This may be recoverable. */ class Scanning(exception: ScanningException) : 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 f85df2fc0..d7b9a71e2 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 @@ -37,7 +37,6 @@ import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.channelFlow import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onCompletion import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.isActive @@ -49,6 +48,7 @@ import no.nordicsemi.kotlin.ble.client.RemoteCharacteristic import no.nordicsemi.kotlin.ble.client.android.CentralManager import no.nordicsemi.kotlin.ble.client.android.ConnectionPriority import no.nordicsemi.kotlin.ble.client.android.Peripheral +import no.nordicsemi.kotlin.ble.client.exception.InvalidAttributeException import no.nordicsemi.kotlin.ble.core.CharacteristicProperty import no.nordicsemi.kotlin.ble.core.ConnectionState import no.nordicsemi.kotlin.ble.core.WriteType @@ -78,12 +78,12 @@ constructor( ) : IRadioInterface { private val exceptionHandler = CoroutineExceptionHandler { _, throwable -> - Logger.e(throwable) { "[$address] Uncaught exception in connectionScope" } + Logger.w(throwable) { "[$address] Uncaught exception in connectionScope" } serviceScope.launch { try { peripheral?.disconnect() } catch (e: Exception) { - Logger.e(e) { "[$address] Failed to disconnect in exception handler" } + Logger.w(e) { "[$address] Failed to disconnect in exception handler" } } } service.onDisconnect(BleError.from(throwable)) @@ -116,6 +116,10 @@ constructor( val packet = try { fromRadioCharacteristic?.read()?.takeIf { it.isNotEmpty() } + } catch (e: InvalidAttributeException) { + Logger.w(e) { "[$address] Attribute invalidated during read, clearing characteristics" } + handleInvalidAttribute(e) + null } catch (e: Exception) { Logger.w(e) { "[$address] Error reading fromRadioCharacteristic (likely disconnected)" } null @@ -153,11 +157,6 @@ constructor( dispatchPacket(packet) } .catch { ex -> Logger.w(ex) { "[$address] Exception while draining packet queue" } } - .onCompletion { - if (drainedCount > 0) { - Logger.d { "[$address] Drained $drainedCount packets from packet queue" } - } - } .collect() } } @@ -184,7 +183,8 @@ constructor( } } catch (e: Exception) { val failureTime = System.currentTimeMillis() - connectionStartTime - Logger.e(e) { "[$address] Failed to connect to peripheral after ${failureTime}ms" } + // BLE connection errors are common and often transient + Logger.w(e) { "[$address] Failed to connect to peripheral after ${failureTime}ms" } service.onDisconnect(BleError.from(e)) } } @@ -226,10 +226,7 @@ constructor( .onEach { state -> Logger.i { "[$address] BLE connection state changed to $state" } if (state is ConnectionState.Disconnected) { - toRadioCharacteristic = null - fromNumCharacteristic = null - fromRadioCharacteristic = null - logRadioCharacteristic = null + clearCharacteristics() val uptime = if (connectionStartTime > 0) { @@ -301,11 +298,11 @@ constructor( } } .catch { e -> - Logger.e(e) { "[$address] Service discovery failed" } + Logger.w(e) { "[$address] Service discovery failed" } try { peripheral.disconnect() } catch (e2: Exception) { - Logger.e(e2) { "[$address] Failed to disconnect in discovery catch" } + Logger.w(e2) { "[$address] Failed to disconnect in discovery catch" } } service.onDisconnect(BleError.from(e)) } @@ -324,10 +321,9 @@ constructor( connectionScope.launch { drainPacketQueueAndDispatch() } } ?.catch { e -> - Logger.e(e) { "[$address] Error subscribing to fromNumCharacteristic" } + Logger.w(e) { "[$address] Error subscribing to fromNumCharacteristic" } service.onDisconnect(BleError.from(e)) } - ?.onCompletion { cause -> Logger.d { "[$address] fromNum sub flow completed, cause=$cause" } } ?.launchIn(scope = connectionScope) retryCall { logRadioCharacteristic?.subscribe() } @@ -337,10 +333,9 @@ constructor( dispatchPacket(notifyBytes) } ?.catch { e -> - Logger.e(e) { "[$address] Error subscribing to logRadioCharacteristic" } + Logger.w(e) { "[$address] Error subscribing to logRadioCharacteristic" } service.onDisconnect(BleError.from(e)) } - ?.onCompletion { cause -> Logger.d { "[$address] logRadio sub flow completed, cause=$cause" } } ?.launchIn(scope = connectionScope) } @@ -354,7 +349,7 @@ constructor( } catch (e: Exception) { currentAttempt++ if (currentAttempt >= RETRY_COUNT) { - Logger.e(e) { "[$address] BLE operation failed after $RETRY_COUNT attempts, giving up" } + Logger.w(e) { "[$address] BLE operation failed after $RETRY_COUNT attempts, giving up" } throw e } Logger.w(e) { @@ -398,8 +393,11 @@ constructor( characteristic.write(p, writeType = writeType) } drainPacketQueueAndDispatch() + } catch (e: InvalidAttributeException) { + Logger.w(e) { "[$address] Attribute invalidated during write, clearing characteristics" } + handleInvalidAttribute(e) } catch (e: Exception) { - Logger.e(e) { + Logger.w(e) { "[$address] Failed to write packet to toRadioCharacteristic after " + "$packetsSent successful writes" } @@ -435,6 +433,18 @@ constructor( } } + private fun handleInvalidAttribute(e: InvalidAttributeException) { + clearCharacteristics() + service.onDisconnect(BleError.from(e)) + } + + private fun clearCharacteristics() { + toRadioCharacteristic = null + fromNumCharacteristic = null + fromRadioCharacteristic = null + logRadioCharacteristic = null + } + companion object { private const val RETRY_COUNT = 3 private const val RETRY_DELAY_MS = 500L diff --git a/app/src/main/java/com/geeksville/mesh/repository/radio/RadioInterfaceService.kt b/app/src/main/java/com/geeksville/mesh/repository/radio/RadioInterfaceService.kt index 3bbbfe4b2..002f5f17f 100644 --- a/app/src/main/java/com/geeksville/mesh/repository/radio/RadioInterfaceService.kt +++ b/app/src/main/java/com/geeksville/mesh/repository/radio/RadioInterfaceService.kt @@ -65,7 +65,7 @@ import javax.inject.Singleton */ @Suppress("LongParameterList") @Singleton -class RadioInterfaceService +open class RadioInterfaceService @Inject constructor( private val context: Application, @@ -224,7 +224,7 @@ constructor( } // Handle an incoming packet from the radio, broadcasts it as an android intent - fun handleFromRadio(p: ByteArray) { + open fun handleFromRadio(p: ByteArray) { if (logReceives) { try { receivedPacketsLog.write(p) diff --git a/app/src/main/java/com/geeksville/mesh/repository/radio/TCPInterface.kt b/app/src/main/java/com/geeksville/mesh/repository/radio/TCPInterface.kt index de7955f15..5f58f0ef8 100644 --- a/app/src/main/java/com/geeksville/mesh/repository/radio/TCPInterface.kt +++ b/app/src/main/java/com/geeksville/mesh/repository/radio/TCPInterface.kt @@ -134,7 +134,8 @@ constructor( } else { 0 } - Logger.e(ex) { "[$address] TCP IOException after ${uptime}ms - ${ex.message}" } + // Connection failures are common when the radio is offline or out of range + Logger.w(ex) { "[$address] TCP connection error after ${uptime}ms - ${ex.message}" } onDeviceDisconnect(false) } catch (ex: Throwable) { val uptime = diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshService.kt b/app/src/main/java/com/geeksville/mesh/service/MeshService.kt index 668ad6e04..f46108cd7 100644 --- a/app/src/main/java/com/geeksville/mesh/service/MeshService.kt +++ b/app/src/main/java/com/geeksville/mesh/service/MeshService.kt @@ -135,25 +135,46 @@ class MeshService : Service() { val notification = connectionManager.updateStatusNotification() + val foregroundServiceType = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + var types = ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE + if (hasLocationPermission()) { + types = types or ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION + } + types + } else { + 0 + } + + @Suppress("TooGenericExceptionCaught") try { - ServiceCompat.startForeground( - this, - SERVICE_NOTIFY_ID, - notification, + ServiceCompat.startForeground(this, SERVICE_NOTIFY_ID, notification, foregroundServiceType) + } catch (ex: SecurityException) { + // On Android 14+ starting a location FGS from the background can fail with SecurityException + // if the app is not in an allowed state. Retry without the location type if that was requested. + val connectedDeviceOnly = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - var types = ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE - if (hasLocationPermission()) { - types = types or ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION - } - types + ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE } else { 0 - }, - ) + } + if (foregroundServiceType != connectedDeviceOnly) { + Logger.w(ex) { + "Failed to start foreground service with location type, retrying with connectedDevice only" + } + try { + ServiceCompat.startForeground(this, SERVICE_NOTIFY_ID, notification, connectedDeviceOnly) + } catch (retryEx: Exception) { + Logger.e(retryEx) { "Failed to start foreground service even after retry" } + } + } else { + Logger.e(ex) { "SecurityException starting foreground service" } + } } catch (ex: Exception) { Logger.e(ex) { "Error starting foreground service" } return START_NOT_STICKY } + return if (!wantForeground) { Logger.i { "Stopping mesh service because no device is selected" } ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE) diff --git a/app/src/test/java/com/geeksville/mesh/repository/radio/NordicBleInterfaceDrainTest.kt b/app/src/test/java/com/geeksville/mesh/repository/radio/NordicBleInterfaceDrainTest.kt new file mode 100644 index 000000000..0644ca527 --- /dev/null +++ b/app/src/test/java/com/geeksville/mesh/repository/radio/NordicBleInterfaceDrainTest.kt @@ -0,0 +1,159 @@ +/* + * 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 com.geeksville.mesh.repository.radio + +import io.mockk.clearMocks +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.delay +import kotlinx.coroutines.test.StandardTestDispatcher +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.ConnectionResult +import no.nordicsemi.kotlin.ble.client.mock.PeripheralSpec +import no.nordicsemi.kotlin.ble.client.mock.PeripheralSpecEventHandler +import no.nordicsemi.kotlin.ble.client.mock.Proximity +import no.nordicsemi.kotlin.ble.client.mock.ReadResponse +import no.nordicsemi.kotlin.ble.client.mock.WriteResponse +import no.nordicsemi.kotlin.ble.client.mock.internal.MockRemoteCharacteristic +import no.nordicsemi.kotlin.ble.core.CharacteristicProperty +import no.nordicsemi.kotlin.ble.core.LegacyAdvertisingSetParameters +import no.nordicsemi.kotlin.ble.core.Permission +import org.junit.Test +import java.util.UUID +import kotlin.time.Duration.Companion.milliseconds +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +@OptIn(ExperimentalCoroutinesApi::class, ExperimentalUuidApi::class) +class NordicBleInterfaceDrainTest { + + private val testDispatcher = StandardTestDispatcher() + private val address = "00:11:22:33:44:55" + + private fun UUID.toKotlinUuid(): Uuid = Uuid.parse(this.toString()) + + @Test + fun `drainPacketQueueAndDispatch reads multiple packets until empty`() = runTest(testDispatcher) { + val centralManager = CentralManager.Factory.mock(scope = backgroundScope) + val service = mockk(relaxed = true) + + var fromRadioHandle: Int = -1 + var fromNumHandle: Int = -1 + val packetsToRead = mutableListOf(byteArrayOf(0x01), byteArrayOf(0x02), byteArrayOf(0x03)) + + val eventHandler = + object : PeripheralSpecEventHandler { + override fun onConnectionRequest(preferredPhy: List) = + ConnectionResult.Accept + + override fun onReadRequest(characteristic: MockRemoteCharacteristic): ReadResponse { + if (characteristic.instanceId == fromRadioHandle) { + return if (packetsToRead.isNotEmpty()) { + val p = packetsToRead.removeAt(0) + println("Mock: Returning packet ${p.contentToString()}") + ReadResponse.Success(p) + } else { + println("Mock: Queue empty, returning empty") + ReadResponse.Success(byteArrayOf()) + } + } + return ReadResponse.Success(byteArrayOf()) + } + + override fun onWriteRequest(characteristic: MockRemoteCharacteristic, value: ByteArray) = + WriteResponse.Success + } + + val peripheralSpec = + PeripheralSpec.simulatePeripheral(identifier = address, proximity = Proximity.IMMEDIATE) { + advertising( + parameters = LegacyAdvertisingSetParameters(connectable = true, interval = 100.milliseconds), + ) { + CompleteLocalName("Meshtastic_Drain") + } + connectable( + name = "Meshtastic_Drain", + isBonded = true, + eventHandler = eventHandler, + cachedServices = { + Service(uuid = BleConstants.BTM_SERVICE_UUID.toKotlinUuid()) { + Characteristic( + uuid = BleConstants.BTM_TORADIO_CHARACTER.toKotlinUuid(), + properties = + setOf( + CharacteristicProperty.WRITE, + CharacteristicProperty.WRITE_WITHOUT_RESPONSE, + ), + permission = Permission.WRITE, + ) + fromNumHandle = + Characteristic( + uuid = BleConstants.BTM_FROMNUM_CHARACTER.toKotlinUuid(), + properties = setOf(CharacteristicProperty.NOTIFY), + permission = Permission.READ, + ) + fromRadioHandle = + Characteristic( + uuid = BleConstants.BTM_FROMRADIO_CHARACTER.toKotlinUuid(), + properties = setOf(CharacteristicProperty.READ), + permission = Permission.READ, + ) + Characteristic( + uuid = BleConstants.BTM_LOGRADIO_CHARACTER.toKotlinUuid(), + properties = setOf(CharacteristicProperty.NOTIFY), + permission = Permission.READ, + ) + } + }, + ) + } + + centralManager.simulatePeripherals(listOf(peripheralSpec)) + + val nordicInterface = + NordicBleInterface( + serviceScope = this, + centralManager = centralManager, + service = service, + address = address, + ) + + // Wait for connection + delay(2000.milliseconds) + verify(timeout = 5000) { service.onConnect() } + clearMocks(service, answers = false, recordedCalls = true) + + // Trigger drain + println("Simulating FromNum notification...") + peripheralSpec.simulateValueUpdate(fromNumHandle, byteArrayOf(0x01)) + + // Wait for all packets to be processed + delay(2000.milliseconds) + + // Verify handleFromRadio was called 3 times + verify(timeout = 2000) { service.handleFromRadio(p = byteArrayOf(0x01)) } + verify(timeout = 2000) { service.handleFromRadio(p = byteArrayOf(0x02)) } + verify(timeout = 2000) { service.handleFromRadio(p = byteArrayOf(0x03)) } + + assert(packetsToRead.isEmpty()) { "All packets should have been read" } + + nordicInterface.close() + } +} 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 new file mode 100644 index 000000000..9d23cb2db --- /dev/null +++ b/app/src/test/java/com/geeksville/mesh/repository/radio/NordicBleInterfaceRetryTest.kt @@ -0,0 +1,279 @@ +/* + * 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 com.geeksville.mesh.repository.radio + +import co.touchlab.kermit.Logger +import co.touchlab.kermit.Severity +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.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.ConnectionResult +import no.nordicsemi.kotlin.ble.client.mock.PeripheralSpec +import no.nordicsemi.kotlin.ble.client.mock.PeripheralSpecEventHandler +import no.nordicsemi.kotlin.ble.client.mock.Proximity +import no.nordicsemi.kotlin.ble.client.mock.ReadResponse +import no.nordicsemi.kotlin.ble.client.mock.internal.MockRemoteCharacteristic +import no.nordicsemi.kotlin.ble.core.CharacteristicProperty +import no.nordicsemi.kotlin.ble.core.LegacyAdvertisingSetParameters +import no.nordicsemi.kotlin.ble.core.Permission +import org.junit.Before +import org.junit.Test +import java.util.UUID +import kotlin.time.Duration.Companion.milliseconds +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +@OptIn(ExperimentalCoroutinesApi::class, ExperimentalUuidApi::class) +class NordicBleInterfaceRetryTest { + + private val testDispatcher = UnconfinedTestDispatcher() + private val address = "00:11:22:33:44:55" + + private fun UUID.toKotlinUuid(): Uuid = Uuid.parse(this.toString()) + + @Before + fun setup() { + Logger.setLogWriters( + object : co.touchlab.kermit.LogWriter() { + override fun log(severity: Severity, message: String, tag: String, throwable: Throwable?) { + println("[$severity] $tag: $message") + throwable?.printStackTrace() + } + }, + ) + } + + @Test + fun `write succeeds after one retry`() = runTest(testDispatcher) { + val centralManager = CentralManager.Factory.mock(scope = backgroundScope) + val service = mockk(relaxed = true) + + var toRadioHandle: Int = -1 + var writeAttempts = 0 + var writtenValue: ByteArray? = null + + val eventHandler = + object : PeripheralSpecEventHandler { + override fun onConnectionRequest( + preferredPhy: List, + ): ConnectionResult = ConnectionResult.Accept + + override fun onWriteCommand(characteristic: MockRemoteCharacteristic, value: ByteArray) { + if (characteristic.instanceId == toRadioHandle) { + writeAttempts++ + if (writeAttempts == 1) { + println("Simulating first write failure") + throw RuntimeException("Temporary failure") + } + println("Second write attempt succeeding") + writtenValue = value + } + } + + 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_Retry") + } + connectable( + name = "Meshtastic_Retry", + isBonded = true, + eventHandler = eventHandler, + cachedServices = { + Service(uuid = BleConstants.BTM_SERVICE_UUID.toKotlinUuid()) { + toRadioHandle = + Characteristic( + uuid = BleConstants.BTM_TORADIO_CHARACTER.toKotlinUuid(), + properties = + setOf( + CharacteristicProperty.WRITE, + CharacteristicProperty.WRITE_WITHOUT_RESPONSE, + ), + permission = Permission.WRITE, + ) + Characteristic( + uuid = BleConstants.BTM_FROMNUM_CHARACTER.toKotlinUuid(), + properties = setOf(CharacteristicProperty.NOTIFY), + permission = Permission.READ, + ) + Characteristic( + uuid = BleConstants.BTM_FROMRADIO_CHARACTER.toKotlinUuid(), + properties = setOf(CharacteristicProperty.READ), + permission = Permission.READ, + ) + Characteristic( + uuid = BleConstants.BTM_LOGRADIO_CHARACTER.toKotlinUuid(), + properties = setOf(CharacteristicProperty.NOTIFY), + permission = Permission.READ, + ) + } + }, + ) + } + + centralManager.simulatePeripherals(listOf(peripheralSpec)) + delay(100.milliseconds) + + val nordicInterface = + NordicBleInterface( + serviceScope = this, + centralManager = centralManager, + service = service, + address = address, + ) + + // Wait for connection and stable state + delay(2000.milliseconds) + verify(timeout = 5000) { service.onConnect() } + + // Clear initial discovery errors if any (sometimes mock emits empty list initially) + clearMocks(service, answers = false, recordedCalls = true) + + // Test writing + val dataToSend = byteArrayOf(0x01, 0x02, 0x03) + nordicInterface.handleSendToRadio(dataToSend) + + // Give it time to process retries (500ms delay per retry in code) + delay(1500.milliseconds) + + assert(writeAttempts == 2) { "Should have attempted write twice, but was $writeAttempts" } + assert(writtenValue != null) { "Value should have been eventually written" } + assert(writtenValue!!.contentEquals(dataToSend)) + + // Verify we didn't disconnect due to the retryable error + verify(exactly = 0) { service.onDisconnect(any()) } + + nordicInterface.close() + } + + @Test + fun `write fails after max retries`() = runTest(testDispatcher) { + val uniqueAddress = "11:22:33:44:55:66" + val centralManager = CentralManager.Factory.mock(scope = backgroundScope) + val service = mockk(relaxed = true) + + var toRadioHandle: Int = -1 + var writeAttempts = 0 + + val eventHandler = + object : PeripheralSpecEventHandler { + override fun onConnectionRequest( + preferredPhy: List, + ): ConnectionResult = ConnectionResult.Accept + + override fun onWriteCommand(characteristic: MockRemoteCharacteristic, value: ByteArray) { + if (characteristic.instanceId == toRadioHandle) { + writeAttempts++ + println("Simulating write failure #$writeAttempts") + throw RuntimeException("Persistent failure") + } + } + + override fun onReadRequest(characteristic: MockRemoteCharacteristic): ReadResponse = + ReadResponse.Success(byteArrayOf()) + } + + val peripheralSpec = + PeripheralSpec.simulatePeripheral(identifier = uniqueAddress, proximity = Proximity.IMMEDIATE) { + advertising( + parameters = LegacyAdvertisingSetParameters(connectable = true, interval = 100.milliseconds), + ) { + CompleteLocalName("Meshtastic_Fail") + } + connectable( + name = "Meshtastic_Fail", + isBonded = true, + eventHandler = eventHandler, + cachedServices = { + Service(uuid = BleConstants.BTM_SERVICE_UUID.toKotlinUuid()) { + toRadioHandle = + Characteristic( + uuid = BleConstants.BTM_TORADIO_CHARACTER.toKotlinUuid(), + properties = + setOf( + CharacteristicProperty.WRITE, + CharacteristicProperty.WRITE_WITHOUT_RESPONSE, + ), + permission = Permission.WRITE, + ) + Characteristic( + uuid = BleConstants.BTM_FROMNUM_CHARACTER.toKotlinUuid(), + properties = setOf(CharacteristicProperty.NOTIFY), + permission = Permission.READ, + ) + Characteristic( + uuid = BleConstants.BTM_FROMRADIO_CHARACTER.toKotlinUuid(), + properties = setOf(CharacteristicProperty.READ), + permission = Permission.READ, + ) + Characteristic( + uuid = BleConstants.BTM_LOGRADIO_CHARACTER.toKotlinUuid(), + properties = setOf(CharacteristicProperty.NOTIFY), + permission = Permission.READ, + ) + } + }, + ) + } + + centralManager.simulatePeripherals(listOf(peripheralSpec)) + delay(100.milliseconds) + + val nordicInterface = + NordicBleInterface( + serviceScope = this, + centralManager = centralManager, + service = service, + address = uniqueAddress, + ) + + // Wait for connection + delay(2000.milliseconds) + verify(timeout = 5000) { service.onConnect() } + + // Clear initial discovery errors + clearMocks(service, answers = false, recordedCalls = true) + + // Trigger write which will fail repeatedly + nordicInterface.handleSendToRadio(byteArrayOf(0x01)) + + // Wait for all 3 attempts + delays (500ms * 2) + delay(2500.milliseconds) + + assert(writeAttempts == 3) { + "Should have attempted write 3 times (initial + 2 retries), but was $writeAttempts" + } + + // Verify onDisconnect was called after retries exhausted + // Nordic BLE wraps RuntimeException in BluetoothException + verify { service.onDisconnect(any()) } + + nordicInterface.close() + } +} 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 new file mode 100644 index 000000000..d5c5344b7 --- /dev/null +++ b/app/src/test/java/com/geeksville/mesh/repository/radio/NordicBleInterfaceTest.kt @@ -0,0 +1,572 @@ +/* + * 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 com.geeksville.mesh.repository.radio + +import co.touchlab.kermit.Logger +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.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.ConnectionResult +import no.nordicsemi.kotlin.ble.client.mock.PeripheralSpec +import no.nordicsemi.kotlin.ble.client.mock.PeripheralSpecEventHandler +import no.nordicsemi.kotlin.ble.client.mock.Proximity +import no.nordicsemi.kotlin.ble.client.mock.ReadResponse +import no.nordicsemi.kotlin.ble.client.mock.WriteResponse +import no.nordicsemi.kotlin.ble.client.mock.internal.MockRemoteCharacteristic +import no.nordicsemi.kotlin.ble.core.CharacteristicProperty +import no.nordicsemi.kotlin.ble.core.LegacyAdvertisingSetParameters +import no.nordicsemi.kotlin.ble.core.Permission +import no.nordicsemi.kotlin.ble.core.and +import org.junit.Before +import org.junit.Test +import java.util.UUID +import kotlin.time.Duration.Companion.milliseconds +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +@OptIn(ExperimentalCoroutinesApi::class, ExperimentalUuidApi::class) +class NordicBleInterfaceTest { + + private val testDispatcher = UnconfinedTestDispatcher() + private val address = "00:11:22:33:44:55" + + private fun UUID.toKotlinUuid(): Uuid = Uuid.parse(this.toString()) + + @Before + fun setup() { + Logger.setLogWriters( + object : co.touchlab.kermit.LogWriter() { + override fun log(severity: Severity, message: String, tag: String, throwable: Throwable?) { + println("[$severity] $tag: $message") + throwable?.printStackTrace() + } + }, + ) + } + + @Test + fun `full connection and notification flow`() = runTest(testDispatcher) { + val centralManager = CentralManager.Factory.mock(scope = backgroundScope) + val service = mockk(relaxed = true) + + var fromNumHandle: Int = -1 + var logRadioHandle: Int = -1 + var fromRadioHandle: Int = -1 + var fromRadioValue: ByteArray = byteArrayOf() + + lateinit var otaPeripheral: PeripheralSpec + + val eventHandler = + object : PeripheralSpecEventHandler { + override fun onConnectionRequest( + preferredPhy: List, + ): ConnectionResult = ConnectionResult.Accept + + override fun onWriteRequest( + characteristic: MockRemoteCharacteristic, + value: ByteArray, + ): WriteResponse = WriteResponse.Success + + override fun onReadRequest(characteristic: MockRemoteCharacteristic): ReadResponse { + if (characteristic.instanceId == fromRadioHandle) { + return ReadResponse.Success(fromRadioValue) + } + return ReadResponse.Success(byteArrayOf()) + } + } + + otaPeripheral = + PeripheralSpec.simulatePeripheral(identifier = address, proximity = Proximity.IMMEDIATE) { + advertising( + parameters = LegacyAdvertisingSetParameters(connectable = true, interval = 100.milliseconds), + ) { + CompleteLocalName("Meshtastic_1234") + } + connectable( + name = "Meshtastic_1234", + isBonded = true, + eventHandler = eventHandler, + cachedServices = { + Service(uuid = BleConstants.BTM_SERVICE_UUID.toKotlinUuid()) { + Characteristic( + uuid = BleConstants.BTM_TORADIO_CHARACTER.toKotlinUuid(), + properties = + CharacteristicProperty.WRITE and CharacteristicProperty.WRITE_WITHOUT_RESPONSE, + permission = Permission.WRITE, + ) + fromNumHandle = + Characteristic( + uuid = BleConstants.BTM_FROMNUM_CHARACTER.toKotlinUuid(), + properties = setOf(CharacteristicProperty.NOTIFY), + permission = Permission.READ, + ) + fromRadioHandle = + Characteristic( + uuid = BleConstants.BTM_FROMRADIO_CHARACTER.toKotlinUuid(), + properties = setOf(CharacteristicProperty.READ), + permission = Permission.READ, + ) + logRadioHandle = + Characteristic( + uuid = BleConstants.BTM_LOGRADIO_CHARACTER.toKotlinUuid(), + properties = setOf(CharacteristicProperty.NOTIFY), + permission = Permission.READ, + ) + } + }, + ) + } + + centralManager.simulatePeripherals(listOf(otaPeripheral)) + + println("Bonded peripherals: ${centralManager.getBondedPeripherals().size}") + centralManager.getBondedPeripherals().forEach { println("Found bonded peripheral: ${it.address}") } + + // Give it a moment to stabilize + delay(100.milliseconds) + + // Create the interface + println("Creating NordicBleInterface") + val nordicInterface = + NordicBleInterface( + serviceScope = this, + centralManager = centralManager, + service = service, + address = address, + ) + + // Wait for connection and discovery + println("Waiting for connection...") + delay(2000.milliseconds) + + println("Verifying onConnect...") + verify(timeout = 5000) { service.onConnect() } + println("onConnect verified.") + + // Set data available on fromRadio BEFORE notifying fromNum + fromRadioValue = byteArrayOf(0xCA.toByte(), 0xFE.toByte()) + + // Simulate a notification from fromNum (indicates there are packets to read) + otaPeripheral.simulateValueUpdate(fromNumHandle, byteArrayOf(0x01)) + + // Wait for drain to start + delay(500.milliseconds) + + // Simulate a log radio notification + val logData = "test log".toByteArray() + otaPeripheral.simulateValueUpdate(logRadioHandle, logData) + + delay(500.milliseconds) + + // Explicitly stub handleFromRadio just in case relaxed mock fails + io.mockk.every { service.handleFromRadio(any()) } returns Unit + + // Verify that handleFromRadio was called (any arguments) with timeout + verify(timeout = 2000) { service.handleFromRadio(any()) } + + nordicInterface.close() + } + + @Test + fun `handleSendToRadio writes to toRadioCharacteristic`() = runTest(testDispatcher) { + val centralManager = CentralManager.Factory.mock(scope = backgroundScope) + val service = mockk(relaxed = true) + + var toRadioHandle: Int = -1 + var writtenValue: ByteArray? = null + + val eventHandler = + object : PeripheralSpecEventHandler { + override fun onConnectionRequest( + preferredPhy: List, + ): ConnectionResult = ConnectionResult.Accept + + override fun onWriteRequest( + characteristic: MockRemoteCharacteristic, + value: ByteArray, + ): WriteResponse { + // Keep this for WITH_RESPONSE + println("onWriteRequest: charId=${characteristic.instanceId}, toRadioHandle=$toRadioHandle") + if (characteristic.instanceId == toRadioHandle) { + writtenValue = value + } + return WriteResponse.Success + } + + override fun onWriteCommand(characteristic: MockRemoteCharacteristic, value: ByteArray) { + // This is for WITHOUT_RESPONSE + println("onWriteCommand: charId=${characteristic.instanceId}, toRadioHandle=$toRadioHandle") + if (characteristic.instanceId == toRadioHandle) { + println("onWriteCommand matched! value=${value.toHexString()}") + writtenValue = value + } else { + println("onWriteCommand mismatch.") + } + } + + 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_1234") + } + connectable( + name = "Meshtastic_1234", + isBonded = true, + eventHandler = eventHandler, + cachedServices = { + Service(uuid = BleConstants.BTM_SERVICE_UUID.toKotlinUuid()) { + toRadioHandle = + Characteristic( + uuid = BleConstants.BTM_TORADIO_CHARACTER.toKotlinUuid(), + properties = + setOf( + CharacteristicProperty.WRITE, + CharacteristicProperty.WRITE_WITHOUT_RESPONSE, + ), + permission = Permission.WRITE, + ) + .also { + println("Captured toRadioHandle: $it") + // toRadioHandle is assigned by the expression itself + } + // Add other required chars to avoid discovery failure + Characteristic( + uuid = BleConstants.BTM_FROMNUM_CHARACTER.toKotlinUuid(), + properties = setOf(CharacteristicProperty.NOTIFY), + permission = Permission.READ, + ) + Characteristic( + uuid = BleConstants.BTM_FROMRADIO_CHARACTER.toKotlinUuid(), + properties = setOf(CharacteristicProperty.READ), + permission = Permission.READ, + ) + Characteristic( + uuid = BleConstants.BTM_LOGRADIO_CHARACTER.toKotlinUuid(), + properties = setOf(CharacteristicProperty.NOTIFY), + permission = Permission.READ, + ) + } + }, + ) + } + + centralManager.simulatePeripherals(listOf(peripheralSpec)) + delay(100.milliseconds) + + val nordicInterface = + NordicBleInterface( + serviceScope = this, + centralManager = centralManager, + service = service, + address = address, + ) + + // Wait for connection + delay(1000.milliseconds) + verify(timeout = 2000) { service.onConnect() } + + // Test writing + val dataToSend = byteArrayOf(0x01, 0x02, 0x03) + nordicInterface.handleSendToRadio(dataToSend) + + // Give it time to process + delay(500.milliseconds) + + assert(writtenValue != null) { "Value should have been written" } + assert(writtenValue!!.contentEquals(dataToSend)) { + "Written value ${writtenValue?.contentToString()} does not match expected ${dataToSend.contentToString()}" + } + + nordicInterface.close() + } + + @Test + fun `disconnection triggers onDisconnect`() = runTest(testDispatcher) { + val centralManager = CentralManager.Factory.mock(scope = backgroundScope) + + // Mock service + val service = mockk(relaxed = true) + // Explicitly stub handleFromRadio just in case + io.mockk.every { service.handleFromRadio(any()) } returns Unit + + val eventHandler = + object : PeripheralSpecEventHandler { + override fun onConnectionRequest( + preferredPhy: List, + ): ConnectionResult = ConnectionResult.Accept + + // Minimal implementation for connection test + 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_1234") + } + connectable( + name = "Meshtastic_1234", + isBonded = true, + eventHandler = eventHandler, + cachedServices = { + Service(uuid = BleConstants.BTM_SERVICE_UUID.toKotlinUuid()) { + Characteristic( + uuid = BleConstants.BTM_TORADIO_CHARACTER.toKotlinUuid(), + properties = + setOf( + CharacteristicProperty.WRITE, + CharacteristicProperty.WRITE_WITHOUT_RESPONSE, + ), + permission = Permission.WRITE, + ) + Characteristic( + uuid = BleConstants.BTM_FROMNUM_CHARACTER.toKotlinUuid(), + properties = setOf(CharacteristicProperty.NOTIFY), + permission = Permission.READ, + ) + Characteristic( + uuid = BleConstants.BTM_FROMRADIO_CHARACTER.toKotlinUuid(), + properties = setOf(CharacteristicProperty.READ), + permission = Permission.READ, + ) + Characteristic( + uuid = BleConstants.BTM_LOGRADIO_CHARACTER.toKotlinUuid(), + properties = setOf(CharacteristicProperty.NOTIFY), + permission = Permission.READ, + ) + } + }, + ) + } + + centralManager.simulatePeripherals(listOf(peripheralSpec)) + delay(100.milliseconds) + + val nordicInterface = + NordicBleInterface( + serviceScope = this, + centralManager = centralManager, + service = service, + address = address, + ) + + // Wait for connection + delay(1000.milliseconds) + verify(timeout = 2000) { service.onConnect() } + + // Find the connected peripheral from CentralManager to trigger disconnect + val connectedPeripheral = centralManager.getBondedPeripherals().first { it.address == address } + + println("Simulating disconnect via peripheral.disconnect()") + connectedPeripheral.disconnect() + + // Wait for disconnect event propagation + delay(1000.milliseconds) + + // Verify onDisconnect was called on the service + // NordicBleInterface calls onDisconnect(BleError.Disconnected) + verify { service.onDisconnect(any()) } + + nordicInterface.close() + } + + @Test + fun `discovery fails if required characteristic missing`() = runTest(testDispatcher) { + val centralManager = CentralManager.Factory.mock(scope = backgroundScope) + + // Mock service + val service = mockk(relaxed = true) + io.mockk.every { service.handleFromRadio(any()) } returns Unit + + val eventHandler = + object : PeripheralSpecEventHandler { + override fun onConnectionRequest( + preferredPhy: List, + ): ConnectionResult = 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_1234") + } + connectable( + name = "Meshtastic_1234", + isBonded = true, + eventHandler = eventHandler, + cachedServices = { + Service(uuid = BleConstants.BTM_SERVICE_UUID.toKotlinUuid()) { + // OMIT toRadio characteristic to force failure + /* + Characteristic( + uuid = BleConstants.BTM_TORADIO_CHARACTER.toKotlinUuid(), + properties = setOf(CharacteristicProperty.WRITE, CharacteristicProperty.WRITE_WITHOUT_RESPONSE), + permission = Permission.WRITE + ) + */ + Characteristic( + uuid = BleConstants.BTM_FROMNUM_CHARACTER.toKotlinUuid(), + properties = setOf(CharacteristicProperty.NOTIFY), + permission = Permission.READ, + ) + Characteristic( + uuid = BleConstants.BTM_FROMRADIO_CHARACTER.toKotlinUuid(), + properties = setOf(CharacteristicProperty.READ), + permission = Permission.READ, + ) + Characteristic( + uuid = BleConstants.BTM_LOGRADIO_CHARACTER.toKotlinUuid(), + properties = setOf(CharacteristicProperty.NOTIFY), + permission = Permission.READ, + ) + } + }, + ) + } + + centralManager.simulatePeripherals(listOf(peripheralSpec)) + delay(100.milliseconds) + + val nordicInterface = + NordicBleInterface( + serviceScope = this, + centralManager = centralManager, + service = service, + address = address, + ) + + // Wait for connection and eventual failure + delay(1000.milliseconds) + + // Verify that discovery failed + verify { service.onDisconnect(any()) } + + nordicInterface.close() + } + + @Test + fun `write exception triggers disconnect`() = runTest(testDispatcher) { + val uniqueAddress = "11:22:33:44:55:66" + val centralManager = CentralManager.Factory.mock(scope = backgroundScope) + + // Mock service + val service = mockk(relaxed = true) + io.mockk.every { service.handleFromRadio(any()) } returns Unit + + val eventHandler = + object : PeripheralSpecEventHandler { + override fun onConnectionRequest( + preferredPhy: List, + ): ConnectionResult = ConnectionResult.Accept + + override fun onReadRequest(characteristic: MockRemoteCharacteristic): ReadResponse = + ReadResponse.Success(byteArrayOf()) + + // Throw exception on write + override fun onWriteCommand(characteristic: MockRemoteCharacteristic, value: ByteArray): Unit = + throw RuntimeException("Simulated write failure") + } + + val peripheralSpec = + PeripheralSpec.simulatePeripheral(identifier = uniqueAddress, proximity = Proximity.IMMEDIATE) { + advertising( + parameters = LegacyAdvertisingSetParameters(connectable = true, interval = 100.milliseconds), + ) { + CompleteLocalName("Meshtastic_1234") + } + connectable( + name = "Meshtastic_1234", + isBonded = true, + eventHandler = eventHandler, + cachedServices = { + Service(uuid = BleConstants.BTM_SERVICE_UUID.toKotlinUuid()) { + Characteristic( + uuid = BleConstants.BTM_TORADIO_CHARACTER.toKotlinUuid(), + properties = + setOf( + CharacteristicProperty.WRITE, + CharacteristicProperty.WRITE_WITHOUT_RESPONSE, + ), + permission = Permission.WRITE, + ) + Characteristic( + uuid = BleConstants.BTM_FROMNUM_CHARACTER.toKotlinUuid(), + properties = setOf(CharacteristicProperty.NOTIFY), + permission = Permission.READ, + ) + Characteristic( + uuid = BleConstants.BTM_FROMRADIO_CHARACTER.toKotlinUuid(), + properties = setOf(CharacteristicProperty.READ), + permission = Permission.READ, + ) + Characteristic( + uuid = BleConstants.BTM_LOGRADIO_CHARACTER.toKotlinUuid(), + properties = setOf(CharacteristicProperty.NOTIFY), + permission = Permission.READ, + ) + } + }, + ) + } + + centralManager.simulatePeripherals(listOf(peripheralSpec)) + delay(1000.milliseconds) + + val nordicInterface = + NordicBleInterface( + serviceScope = this, + centralManager = centralManager, + service = service, + address = uniqueAddress, + ) + + // Wait for connection + delay(1000.milliseconds) + verify(timeout = 2000) { service.onConnect() } + + // Trigger write which will fail + nordicInterface.handleSendToRadio(byteArrayOf(0x01)) + + // Wait for error propagation + delay(500.milliseconds) + + // Verify onDisconnect was called with error + verify { service.onDisconnect(any()) } + + nordicInterface.close() + } +} diff --git a/core/analytics/src/google/kotlin/org/meshtastic/core/analytics/platform/GooglePlatformAnalytics.kt b/core/analytics/src/google/kotlin/org/meshtastic/core/analytics/platform/GooglePlatformAnalytics.kt index e76595977..fd95f1d6e 100644 --- a/core/analytics/src/google/kotlin/org/meshtastic/core/analytics/platform/GooglePlatformAnalytics.kt +++ b/core/analytics/src/google/kotlin/org/meshtastic/core/analytics/platform/GooglePlatformAnalytics.kt @@ -240,16 +240,18 @@ constructor( // Filter out normal coroutine cancellations if (throwable is CancellationException) return - // Only record non-fatal exceptions for actual Errors (or if a throwable is provided) - if (throwable != null) { - Firebase.crashlytics.recordException(throwable) - } else if (severity >= Severity.Error) { - Firebase.crashlytics.setCustomKeys { - key(KEY_PRIORITY, severity.ordinal) - key(KEY_TAG, tag) - key(KEY_MESSAGE, message) + // Only record non-fatal exceptions for actual Errors (Severity.Error or Severity.Assert) + if (severity >= Severity.Error) { + if (throwable != null) { + Firebase.crashlytics.recordException(throwable) + } else { + Firebase.crashlytics.setCustomKeys { + key(KEY_PRIORITY, severity.ordinal) + key(KEY_TAG, tag) + key(KEY_MESSAGE, message) + } + Firebase.crashlytics.recordException(Exception(message)) } - Firebase.crashlytics.recordException(Exception(message)) } } } diff --git a/feature/firmware/build.gradle.kts b/feature/firmware/build.gradle.kts index 6145bfd25..2650c8375 100644 --- a/feature/firmware/build.gradle.kts +++ b/feature/firmware/build.gradle.kts @@ -65,7 +65,7 @@ dependencies { implementation(libs.kotlinx.collections.immutable) implementation(libs.kermit) - implementation(libs.nordic) + implementation(libs.nordic.client.android) implementation(libs.nordic.dfu) implementation(libs.coil) implementation(libs.coil.network.okhttp) @@ -77,5 +77,9 @@ dependencies { testImplementation(libs.junit) testImplementation(libs.kotlinx.coroutines.test) + testImplementation(libs.nordic.client.android.mock) + testImplementation(libs.nordic.client.mock) + testImplementation(libs.nordic.core.android.mock) + testImplementation(libs.nordic.core.mock) testImplementation(libs.mockk) } diff --git a/feature/firmware/src/test/kotlin/org/meshtastic/feature/firmware/ota/BleOtaTransportErrorTest.kt b/feature/firmware/src/test/kotlin/org/meshtastic/feature/firmware/ota/BleOtaTransportErrorTest.kt new file mode 100644 index 000000000..9ad49d93e --- /dev/null +++ b/feature/firmware/src/test/kotlin/org/meshtastic/feature/firmware/ota/BleOtaTransportErrorTest.kt @@ -0,0 +1,265 @@ +/* + * 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.feature.firmware.ota + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.StandardTestDispatcher +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.ConnectionResult +import no.nordicsemi.kotlin.ble.client.mock.PeripheralSpec +import no.nordicsemi.kotlin.ble.client.mock.PeripheralSpecEventHandler +import no.nordicsemi.kotlin.ble.client.mock.Proximity +import no.nordicsemi.kotlin.ble.client.mock.WriteResponse +import no.nordicsemi.kotlin.ble.client.mock.internal.MockRemoteCharacteristic +import no.nordicsemi.kotlin.ble.core.CharacteristicProperty +import no.nordicsemi.kotlin.ble.core.LegacyAdvertisingSetParameters +import no.nordicsemi.kotlin.ble.core.Permission +import no.nordicsemi.kotlin.ble.core.and +import org.junit.Assert.assertTrue +import org.junit.Test +import java.util.UUID +import kotlin.time.Duration.Companion.milliseconds +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +@OptIn(ExperimentalCoroutinesApi::class, ExperimentalUuidApi::class) +class BleOtaTransportErrorTest { + + private val testDispatcher = StandardTestDispatcher() + private val address = "00:11:22:33:44:55" + + private val serviceUuid = UUID.fromString("4FAFC201-1FB5-459E-8FCC-C5C9C331914B") + private val otaCharacteristicUuid = UUID.fromString("62ec0272-3ec5-11eb-b378-0242ac130005") + private val txCharacteristicUuid = UUID.fromString("62ec0272-3ec5-11eb-b378-0242ac130003") + + private fun UUID.toKotlinUuid(): Uuid = Uuid.parse(this.toString()) + + @Test + fun `startOta fails when device rejects hash`() = runTest(testDispatcher) { + val centralManager = CentralManager.Factory.mock(scope = backgroundScope) + lateinit var otaPeripheral: PeripheralSpec + var txCharHandle: Int = -1 + + val eventHandler = + object : PeripheralSpecEventHandler { + override fun onConnectionRequest(preferredPhy: List) = + ConnectionResult.Accept + + override fun onWriteRequest( + characteristic: MockRemoteCharacteristic, + value: ByteArray, + ): WriteResponse { + val command = value.decodeToString() + if (command.startsWith("OTA")) { + backgroundScope.launch { + delay(50.milliseconds) + otaPeripheral.simulateValueUpdate(txCharHandle, "ERR Hash Rejected\n".toByteArray()) + } + } + return WriteResponse.Success + } + } + + otaPeripheral = + PeripheralSpec.simulatePeripheral(identifier = address, proximity = Proximity.IMMEDIATE) { + advertising( + parameters = LegacyAdvertisingSetParameters(connectable = true, interval = 100.milliseconds), + ) { + CompleteLocalName("ESP32-OTA") + } + connectable(name = "ESP32-OTA", eventHandler = eventHandler, isBonded = true) { + Service(uuid = serviceUuid.toKotlinUuid()) { + Characteristic( + uuid = otaCharacteristicUuid.toKotlinUuid(), + properties = + CharacteristicProperty.WRITE and CharacteristicProperty.WRITE_WITHOUT_RESPONSE, + permission = Permission.WRITE, + ) + txCharHandle = + Characteristic( + uuid = txCharacteristicUuid.toKotlinUuid(), + property = CharacteristicProperty.NOTIFY, + permission = Permission.READ, + ) + } + } + } + + centralManager.simulatePeripherals(listOf(otaPeripheral)) + val transport = BleOtaTransport(centralManager, address, testDispatcher) + + transport.connect().getOrThrow() + + val result = transport.startOta(1024, "badhash") {} + assertTrue(result.isFailure) + assertTrue(result.exceptionOrNull() is OtaProtocolException.HashRejected) + + transport.close() + } + + @Test + fun `streamFirmware fails when connection lost`() = runTest(testDispatcher) { + val centralManager = CentralManager.Factory.mock(scope = backgroundScope) + lateinit var otaPeripheral: PeripheralSpec + var txCharHandle: Int = -1 + + val eventHandler = + object : PeripheralSpecEventHandler { + override fun onConnectionRequest(preferredPhy: List) = + ConnectionResult.Accept + + override fun onWriteRequest( + characteristic: MockRemoteCharacteristic, + value: ByteArray, + ): WriteResponse { + backgroundScope.launch { + delay(50.milliseconds) + otaPeripheral.simulateValueUpdate(txCharHandle, "OK\n".toByteArray()) + } + return WriteResponse.Success + } + } + + otaPeripheral = + PeripheralSpec.simulatePeripheral(identifier = address, proximity = Proximity.IMMEDIATE) { + advertising( + parameters = LegacyAdvertisingSetParameters(connectable = true, interval = 100.milliseconds), + ) { + CompleteLocalName("ESP32-OTA") + } + connectable(name = "ESP32-OTA", eventHandler = eventHandler, isBonded = true) { + Service(uuid = serviceUuid.toKotlinUuid()) { + Characteristic( + uuid = otaCharacteristicUuid.toKotlinUuid(), + properties = + CharacteristicProperty.WRITE and CharacteristicProperty.WRITE_WITHOUT_RESPONSE, + permission = Permission.WRITE, + ) + txCharHandle = + Characteristic( + uuid = txCharacteristicUuid.toKotlinUuid(), + property = CharacteristicProperty.NOTIFY, + permission = Permission.READ, + ) + } + } + } + + centralManager.simulatePeripherals(listOf(otaPeripheral)) + val transport = BleOtaTransport(centralManager, address, testDispatcher) + + transport.connect().getOrThrow() + transport.startOta(1024, "hash") {}.getOrThrow() + + // Find the connected peripheral and disconnect it + // We use isBonded=true to ensure it shows up in getBondedPeripherals() + val peripheral = centralManager.getBondedPeripherals().first { it.address == address } + peripheral.disconnect() + + // Wait for state propagation + delay(100.milliseconds) + + val data = ByteArray(1024) { it.toByte() } + val result = transport.streamFirmware(data, 512) {} + + assertTrue("Should fail due to connection loss", result.isFailure) + assertTrue(result.exceptionOrNull() is OtaProtocolException.TransferFailed) + assertTrue(result.exceptionOrNull()?.message?.contains("Connection lost") == true) + + transport.close() + } + + @Test + fun `streamFirmware fails on hash mismatch at verification`() = runTest(testDispatcher) { + val centralManager = CentralManager.Factory.mock(scope = backgroundScope) + lateinit var otaPeripheral: PeripheralSpec + var txCharHandle: Int = -1 + + val eventHandler = + object : PeripheralSpecEventHandler { + override fun onConnectionRequest(preferredPhy: List) = + ConnectionResult.Accept + + override fun onWriteRequest( + characteristic: MockRemoteCharacteristic, + value: ByteArray, + ): WriteResponse { + backgroundScope.launch { + delay(50.milliseconds) + otaPeripheral.simulateValueUpdate(txCharHandle, "OK\n".toByteArray()) + } + return WriteResponse.Success + } + + override fun onWriteCommand(characteristic: MockRemoteCharacteristic, value: ByteArray) { + backgroundScope.launch { + delay(10.milliseconds) + otaPeripheral.simulateValueUpdate(txCharHandle, "ACK\n".toByteArray()) + } + } + } + + otaPeripheral = + PeripheralSpec.simulatePeripheral(identifier = address, proximity = Proximity.IMMEDIATE) { + advertising( + parameters = LegacyAdvertisingSetParameters(connectable = true, interval = 100.milliseconds), + ) { + CompleteLocalName("ESP32-OTA") + } + connectable(name = "ESP32-OTA", eventHandler = eventHandler, isBonded = true) { + Service(uuid = serviceUuid.toKotlinUuid()) { + Characteristic( + uuid = otaCharacteristicUuid.toKotlinUuid(), + properties = + CharacteristicProperty.WRITE and CharacteristicProperty.WRITE_WITHOUT_RESPONSE, + permission = Permission.WRITE, + ) + txCharHandle = + Characteristic( + uuid = txCharacteristicUuid.toKotlinUuid(), + property = CharacteristicProperty.NOTIFY, + permission = Permission.READ, + ) + } + } + } + + centralManager.simulatePeripherals(listOf(otaPeripheral)) + val transport = BleOtaTransport(centralManager, address, testDispatcher) + + transport.connect().getOrThrow() + transport.startOta(1024, "hash") {}.getOrThrow() + + // Setup final response to be a Hash Mismatch error after chunks are sent + backgroundScope.launch { + delay(1000.milliseconds) + otaPeripheral.simulateValueUpdate(txCharHandle, "ERR Hash Mismatch\n".toByteArray()) + } + + val data = ByteArray(1024) { it.toByte() } + val result = transport.streamFirmware(data, 512) {} + + assertTrue("Should fail due to hash mismatch, but got ${result.exceptionOrNull()}", result.isFailure) + assertTrue(result.exceptionOrNull() is OtaProtocolException.VerificationFailed) + + transport.close() + } +} diff --git a/feature/firmware/src/test/kotlin/org/meshtastic/feature/firmware/ota/BleOtaTransportNordicMockTest.kt b/feature/firmware/src/test/kotlin/org/meshtastic/feature/firmware/ota/BleOtaTransportNordicMockTest.kt new file mode 100644 index 000000000..657cd18c4 --- /dev/null +++ b/feature/firmware/src/test/kotlin/org/meshtastic/feature/firmware/ota/BleOtaTransportNordicMockTest.kt @@ -0,0 +1,166 @@ +/* + * 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.feature.firmware.ota + +import co.touchlab.kermit.Logger +import co.touchlab.kermit.Severity +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +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.ConnectionResult +import no.nordicsemi.kotlin.ble.client.mock.PeripheralSpec +import no.nordicsemi.kotlin.ble.client.mock.PeripheralSpecEventHandler +import no.nordicsemi.kotlin.ble.client.mock.Proximity +import no.nordicsemi.kotlin.ble.client.mock.WriteResponse +import no.nordicsemi.kotlin.ble.client.mock.internal.MockRemoteCharacteristic +import no.nordicsemi.kotlin.ble.core.CharacteristicProperty +import no.nordicsemi.kotlin.ble.core.LegacyAdvertisingSetParameters +import no.nordicsemi.kotlin.ble.core.Permission +import no.nordicsemi.kotlin.ble.core.and +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import java.util.UUID +import kotlin.time.Duration.Companion.milliseconds +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +private val SERVICE_UUID = UUID.fromString("4FAFC201-1FB5-459E-8FCC-C5C9C331914B") +private val OTA_CHARACTERISTIC_UUID = UUID.fromString("62ec0272-3ec5-11eb-b378-0242ac130005") +private val TX_CHARACTERISTIC_UUID = UUID.fromString("62ec0272-3ec5-11eb-b378-0242ac130003") + +@OptIn(ExperimentalCoroutinesApi::class, ExperimentalUuidApi::class) +class BleOtaTransportNordicMockTest { + + private val testDispatcher = kotlinx.coroutines.test.StandardTestDispatcher() + private val address = "00:11:22:33:44:55" + + private fun java.util.UUID.toKotlinUuid(): Uuid = Uuid.parse(this.toString()) + + @Before + fun setup() { + Logger.setLogWriters( + object : co.touchlab.kermit.LogWriter() { + override fun log(severity: Severity, message: String, tag: String, throwable: Throwable?) { + println("[$severity] $tag: $message") + throwable?.printStackTrace() + } + }, + ) + } + + @Test + fun `full ota flow with nordic mocks`() = runTest(testDispatcher) { + // Use default mock environment + // Use backgroundScope so that simulation coroutines are cancelled after the test + val centralManager = CentralManager.Factory.mock(scope = backgroundScope) + + var otaCharHandle: Int = -1 + var txCharHandle: Int = -1 + + // Use a property to hold the peripheral since we need it in the event handler + lateinit var otaPeripheral: PeripheralSpec + + val eventHandler = + object : PeripheralSpecEventHandler { + override fun onConnectionRequest( + preferredPhy: List, + ): ConnectionResult = ConnectionResult.Accept + + override fun onWriteRequest( + characteristic: MockRemoteCharacteristic, + value: ByteArray, + ): WriteResponse { + val command = value.decodeToString() + if (command.startsWith("OTA")) { + backgroundScope.launch { + delay(50.milliseconds) + otaPeripheral.simulateValueUpdate(txCharHandle, "OK\n".toByteArray()) + } + } + return WriteResponse.Success + } + + override fun onWriteCommand(characteristic: MockRemoteCharacteristic, value: ByteArray) { + // For firmware chunks (WITHOUT_RESPONSE) + backgroundScope.launch { + delay(10.milliseconds) + // In the real protocol, ACK is sent after each chunk. + // OK is sent after the final chunk is verified. + otaPeripheral.simulateValueUpdate(txCharHandle, "ACK\n".toByteArray()) + } + } + } + + otaPeripheral = + PeripheralSpec.simulatePeripheral(identifier = address, proximity = Proximity.IMMEDIATE) { + advertising( + parameters = LegacyAdvertisingSetParameters(connectable = true, interval = 100.milliseconds), + ) { + CompleteLocalName("ESP32-OTA") + } + connectable(name = "ESP32-OTA", eventHandler = eventHandler) { + Service(uuid = SERVICE_UUID.toKotlinUuid()) { + otaCharHandle = + Characteristic( + uuid = OTA_CHARACTERISTIC_UUID.toKotlinUuid(), + properties = + CharacteristicProperty.WRITE and CharacteristicProperty.WRITE_WITHOUT_RESPONSE, + permission = Permission.WRITE, + ) + txCharHandle = + Characteristic( + uuid = TX_CHARACTERISTIC_UUID.toKotlinUuid(), + property = CharacteristicProperty.NOTIFY, + permission = Permission.READ, + ) + } + } + } + + centralManager.simulatePeripherals(listOf(otaPeripheral)) + + val transport = BleOtaTransport(centralManager, address, testDispatcher) + + // 1. Connect + // Note: BleOtaTransport includes a 5s delay at the start of connect() + val connectResult = transport.connect() + assertTrue("Connection failed: ${connectResult.exceptionOrNull()}", connectResult.isSuccess) + + // 2. Start OTA + val startResult = transport.startOta(1024L, "somehash") {} + assertTrue("Start OTA failed: ${startResult.exceptionOrNull()}", startResult.isSuccess) + + // 3. Stream firmware + // Need to simulate an OK at the very end + backgroundScope.launch { + // Wait for chunks to be sent. 1024 bytes with 512 chunk size = 2 chunks. + // Each chunk takes 10ms + ACK processing. + delay(500.milliseconds) + otaPeripheral.simulateValueUpdate(txCharHandle, "OK\n".toByteArray()) + } + + val data = ByteArray(1024) { it.toByte() } + val streamResult = transport.streamFirmware(data, 512) {} + assertTrue("Stream firmware failed: ${streamResult.exceptionOrNull()}", streamResult.isSuccess) + + transport.close() + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 26f01bdba..3c5cd1125 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -54,6 +54,7 @@ wire = "6.0.0-alpha02" vico = "3.0.0-beta.3" # Removed gradle-doctor dependency-guard = "0.5.0" +nordic-ble = "2.0.0-alpha12" [libraries] @@ -187,7 +188,11 @@ markdown-renderer = { module = "com.mikepenz:multiplatform-markdown-renderer", v markdown-renderer-m3 = { module = "com.mikepenz:multiplatform-markdown-renderer-m3", version.ref = "markdownRenderer" } markdown-renderer-android = { module = "com.mikepenz:multiplatform-markdown-renderer-android", version.ref = "markdownRenderer" } material = { module = "com.google.android.material:material", version = "1.13.0" } -nordic = { module = "no.nordicsemi.kotlin.ble:client-android", version = "2.0.0-alpha12" } +nordic-client-android = { module = "no.nordicsemi.kotlin.ble:client-android", version.ref = "nordic-ble" } +nordic-client-android-mock = { module = "no.nordicsemi.kotlin.ble:client-android-mock", version.ref = "nordic-ble" } +nordic-client-mock = { module = "no.nordicsemi.kotlin.ble:client-mock", version.ref = "nordic-ble" } +nordic-core-android-mock = { module = "no.nordicsemi.kotlin.ble:core-android-mock", version.ref = "nordic-ble" } +nordic-core-mock = { module = "no.nordicsemi.kotlin.ble:core-mock", version.ref = "nordic-ble" } nordic-dfu = { module = "no.nordicsemi.android:dfu", version = "2.10.1" } org-eclipse-paho-client-mqttv3 = { module = "org.eclipse.paho:org.eclipse.paho.client.mqttv3", version = "1.2.5" } osmbonuspack = { module = "com.github.MKergall:osmbonuspack", version = "6.9.0" }