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" }