diff --git a/core/ble/build.gradle.kts b/core/ble/build.gradle.kts
index fecb298a1..be45663dd 100644
--- a/core/ble/build.gradle.kts
+++ b/core/ble/build.gradle.kts
@@ -43,5 +43,13 @@ kotlin {
implementation(libs.kotlinx.coroutines.test)
implementation(projects.core.testing)
}
+
+ val androidHostTest by getting {
+ dependencies {
+ implementation(projects.core.testing)
+ implementation(libs.kotlinx.coroutines.test)
+ implementation(libs.androidx.test.ext.junit)
+ }
+ }
}
}
diff --git a/core/ble/src/androidHostTest/kotlin/org/meshtastic/core/ble/AndroidBluetoothRepositoryBondTest.kt b/core/ble/src/androidHostTest/kotlin/org/meshtastic/core/ble/AndroidBluetoothRepositoryBondTest.kt
new file mode 100644
index 000000000..b3a6dd2d9
--- /dev/null
+++ b/core/ble/src/androidHostTest/kotlin/org/meshtastic/core/ble/AndroidBluetoothRepositoryBondTest.kt
@@ -0,0 +1,252 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.core.ble
+
+import android.bluetooth.BluetoothAdapter
+import android.bluetooth.BluetoothDevice
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.LifecycleOwner
+import androidx.lifecycle.LifecycleRegistry
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.Deferred
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.test.TestDispatcher
+import kotlinx.coroutines.test.TestScope
+import kotlinx.coroutines.test.UnconfinedTestDispatcher
+import kotlinx.coroutines.test.runTest
+import org.junit.runner.RunWith
+import org.meshtastic.core.di.CoroutineDispatchers
+import org.meshtastic.core.testing.FakeBleDevice
+import org.meshtastic.core.testing.RobolectricBleBonding
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.RuntimeEnvironment
+import org.robolectric.Shadows.shadowOf
+import org.robolectric.annotation.Config
+import org.robolectric.annotation.Implementation
+import org.robolectric.annotation.Implements
+import org.robolectric.shadows.ShadowBluetoothDevice
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFailsWith
+import kotlin.test.assertFalse
+import kotlin.test.assertNull
+import kotlin.test.assertTrue
+
+/**
+ * Coverage for the #5849 fix in [AndroidBluetoothRepository.bond]: when `createBond()` returns false the flow no longer
+ * fails outright — it re-checks `bondState` and either resumes (already BONDED), keeps waiting on the broadcast
+ * receiver (still BONDING), or fails ("Failed to initiate bonding").
+ *
+ * Exercises the real production method via Robolectric shadows (no production seam): [RobolectricBleBonding] drives the
+ * address-cached [ShadowBluetoothDevice] and fires `ACTION_BOND_STATE_CHANGED` broadcasts. Each test uses a distinct
+ * MAC so the static device cache cannot bleed bond-state across tests.
+ */
+@OptIn(ExperimentalCoroutinesApi::class)
+@RunWith(RobolectricTestRunner::class)
+@Config(sdk = [34])
+class AndroidBluetoothRepositoryBondTest {
+
+ private fun newRepository(dispatcher: TestDispatcher): AndroidBluetoothRepository {
+ val context = RuntimeEnvironment.getApplication()
+ val dispatchers = CoroutineDispatchers(io = dispatcher, main = dispatcher, default = dispatcher)
+ return AndroidBluetoothRepository(context, dispatchers, startedLifecycle())
+ }
+
+ /** A minimal STARTED lifecycle so the repository's `init { ... }` launch has a live, non-cancelled scope. */
+ private fun startedLifecycle(): Lifecycle {
+ val owner =
+ object : LifecycleOwner {
+ override val lifecycle: LifecycleRegistry =
+ LifecycleRegistry.createUnsafe(this).apply { currentState = Lifecycle.State.STARTED }
+ }
+ return owner.lifecycle
+ }
+
+ /**
+ * Launch `bond()` in the background so it can suspend (e.g. in the BONDING branch) while the test fires the
+ * resolving broadcast. The returned [Deferred] completes with the throwable `bond()` raised, or `null` on success.
+ * Using launch + captured result avoids `async`/`await` exception-propagation surprises under the test scope.
+ */
+ private fun TestScope.launchBond(repo: AndroidBluetoothRepository, mac: String): Deferred {
+ val result = CompletableDeferred()
+ backgroundScope.launch {
+ result.complete(runCatching { repo.bond(FakeBleDevice(address = mac)) }.exceptionOrNull())
+ }
+ return result
+ }
+
+ @Test
+ fun `createBond false while still BONDING keeps waiting and resumes on a later BONDED broadcast`() =
+ runTest(UnconfinedTestDispatcher()) {
+ val mac = "AA:BB:CC:DD:EE:01"
+ RobolectricBleBonding.grantBluetoothConnectPermission()
+ RobolectricBleBonding.primeBond(mac, bondState = BluetoothDevice.BOND_BONDING, createBondReturns = false)
+ val repo = newRepository(UnconfinedTestDispatcher(testScheduler))
+
+ // bond() runs until it parks in the BONDING branch (receiver left registered).
+ val failure = launchBond(repo, mac)
+
+ // The OS later completes bonding — the broadcast must resume the suspended call.
+ RobolectricBleBonding.sendBondStateChanged(
+ mac,
+ newState = BluetoothDevice.BOND_BONDED,
+ previousState = BluetoothDevice.BOND_BONDING,
+ )
+
+ assertNull(failure.await(), "bond() should have resumed without an error")
+ }
+
+ @Test
+ fun `createBond false with no in-flight bond fails to initiate bonding`() = runTest(UnconfinedTestDispatcher()) {
+ val mac = "AA:BB:CC:DD:EE:02"
+ RobolectricBleBonding.grantBluetoothConnectPermission()
+ RobolectricBleBonding.primeBond(mac, bondState = BluetoothDevice.BOND_NONE, createBondReturns = false)
+ val repo = newRepository(UnconfinedTestDispatcher(testScheduler))
+
+ val error = assertFailsWith { repo.bond(FakeBleDevice(address = mac)) }
+ assertEquals("Failed to initiate bonding", error.message)
+ }
+
+ @Test
+ @Config(sdk = [34], shadows = [ShadowBondingThenBonded::class])
+ fun `createBond false but bond already established resumes immediately`() = runTest(UnconfinedTestDispatcher()) {
+ // Models the real-device race the fix targets: the bond completes between the early bondState check and
+ // the post-createBond re-check. The custom shadow returns BONDING first, then BONDED, with
+ // createBond()==false — driving the BOND_BONDED branch without any broadcast.
+ val mac = "AA:BB:CC:DD:EE:03"
+ val repo = newRepository(UnconfinedTestDispatcher(testScheduler))
+
+ // Resumes (no broadcast) via the post-createBond BOND_BONDED branch; assert no error surfaced.
+ assertNull(launchBond(repo, mac).await(), "bond() should resume when the re-check finds BOND_BONDED")
+ }
+
+ @Test
+ fun `already bonded device returns immediately without initiating a bond`() = runTest(UnconfinedTestDispatcher()) {
+ val mac = "AA:BB:CC:DD:EE:04"
+ RobolectricBleBonding.grantBluetoothConnectPermission()
+ // Make the device already bonded both at the adapter level (so isBonded is observable) and per-device
+ // (so bond() hits the early BOND_BONDED guard at line 85 without ever calling createBond()).
+ RobolectricBleBonding.primeBond(mac, bondState = BluetoothDevice.BOND_BONDED, createBondReturns = false)
+ shadowOf(BluetoothAdapter.getDefaultAdapter())
+ .setBondedDevices(setOf(BluetoothAdapter.getDefaultAdapter().getRemoteDevice(mac)))
+ val repo = newRepository(UnconfinedTestDispatcher(testScheduler))
+
+ assertNull(launchBond(repo, mac).await(), "an already-bonded device should return without error")
+ assertTrue(repo.isBonded(mac), "the device should remain reported as bonded")
+ }
+
+ @Test
+ fun `bond fails when bonding is rejected (BOND_NONE from BONDING)`() = runTest(UnconfinedTestDispatcher()) {
+ val mac = "AA:BB:CC:DD:EE:05"
+ RobolectricBleBonding.grantBluetoothConnectPermission()
+ RobolectricBleBonding.primeBond(mac, bondState = BluetoothDevice.BOND_BONDING, createBondReturns = false)
+ val repo = newRepository(UnconfinedTestDispatcher(testScheduler))
+
+ val failure = launchBond(repo, mac)
+ RobolectricBleBonding.sendBondStateChanged(
+ mac,
+ newState = BluetoothDevice.BOND_NONE,
+ previousState = BluetoothDevice.BOND_BONDING,
+ )
+
+ assertEquals("Bonding failed or rejected", failure.await()?.message)
+ }
+
+ @Test
+ fun `createBond true then a BONDED broadcast resumes the bond`() = runTest(UnconfinedTestDispatcher()) {
+ // The ordinary successful path: createBond() succeeds, the call parks on the receiver, and the OS later
+ // confirms via ACTION_BOND_STATE_CHANGED(BOND_BONDED).
+ val mac = "AA:BB:CC:DD:EE:08"
+ RobolectricBleBonding.grantBluetoothConnectPermission()
+ RobolectricBleBonding.primeBond(mac, bondState = BluetoothDevice.BOND_NONE, createBondReturns = true)
+ val repo = newRepository(UnconfinedTestDispatcher(testScheduler))
+
+ val failure = launchBond(repo, mac)
+ RobolectricBleBonding.sendBondStateChanged(
+ mac,
+ newState = BluetoothDevice.BOND_BONDED,
+ previousState = BluetoothDevice.BOND_BONDING,
+ )
+
+ assertNull(failure.await(), "a freshly initiated bond should resolve on the BONDED broadcast")
+ }
+
+ @Test
+ fun `a BOND_NONE broadcast from a non-BONDING state does not fail the parked bond`() =
+ runTest(UnconfinedTestDispatcher()) {
+ // Only BOND_NONE *from* BOND_BONDING means rejection; a spurious BOND_NONE (prev != BONDING) must be
+ // ignored and leave the call waiting.
+ val mac = "AA:BB:CC:DD:EE:09"
+ RobolectricBleBonding.grantBluetoothConnectPermission()
+ RobolectricBleBonding.primeBond(mac, bondState = BluetoothDevice.BOND_BONDING, createBondReturns = false)
+ val repo = newRepository(UnconfinedTestDispatcher(testScheduler))
+
+ val failure = launchBond(repo, mac)
+ RobolectricBleBonding.sendBondStateChanged(
+ mac,
+ newState = BluetoothDevice.BOND_NONE,
+ previousState = BluetoothDevice.BOND_NONE,
+ )
+ assertFalse(failure.isCompleted, "a spurious BOND_NONE must not resolve the bond")
+
+ // A genuine completion still resolves it (and cleans up the background coroutine).
+ RobolectricBleBonding.sendBondStateChanged(
+ mac,
+ newState = BluetoothDevice.BOND_BONDED,
+ previousState = BluetoothDevice.BOND_BONDING,
+ )
+ assertNull(failure.await())
+ }
+
+ @Test
+ fun `isBonded reflects the adapter bonded devices`() = runTest(UnconfinedTestDispatcher()) {
+ val bondedMac = "AA:BB:CC:DD:EE:06"
+ val otherMac = "AA:BB:CC:DD:EE:07"
+ RobolectricBleBonding.grantBluetoothConnectPermission()
+ val bondedDevice = BluetoothAdapter.getDefaultAdapter().getRemoteDevice(bondedMac)
+ shadowOf(BluetoothAdapter.getDefaultAdapter()).setBondedDevices(setOf(bondedDevice))
+ val repo = newRepository(UnconfinedTestDispatcher(testScheduler))
+
+ assertTrue(repo.isBonded(bondedMac))
+ assertFalse(repo.isBonded(otherMac))
+ }
+
+ @Test
+ fun `isValid accepts a well-formed MAC and rejects garbage`() = runTest(UnconfinedTestDispatcher()) {
+ val repo = newRepository(UnconfinedTestDispatcher(testScheduler))
+
+ assertTrue(repo.isValid("AA:BB:CC:DD:EE:FF"))
+ assertFalse(repo.isValid("not-a-mac"))
+ }
+
+ /**
+ * Custom shadow that returns [BluetoothDevice.BOND_BONDING] on the first `getBondState()` read (the early guard)
+ * and [BluetoothDevice.BOND_BONDED] thereafter (the post-`createBond` re-check), with `createBond()` returning
+ * false — reproducing the bond-completed-mid-method race for the BOND_BONDED branch of the fix.
+ */
+ @Implements(BluetoothDevice::class)
+ class ShadowBondingThenBonded : ShadowBluetoothDevice() {
+ private var bondStateReads = 0
+
+ @Implementation
+ override fun getBondState(): Int =
+ if (bondStateReads++ == 0) BluetoothDevice.BOND_BONDING else BluetoothDevice.BOND_BONDED
+
+ @Implementation override fun createBond(): Boolean = false
+ }
+}
diff --git a/core/testing/src/androidMain/kotlin/org/meshtastic/core/testing/FakeBle.android.kt b/core/testing/src/androidMain/kotlin/org/meshtastic/core/testing/FakeBle.android.kt
new file mode 100644
index 000000000..4c058e368
--- /dev/null
+++ b/core/testing/src/androidMain/kotlin/org/meshtastic/core/testing/FakeBle.android.kt
@@ -0,0 +1,25 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.core.testing
+
+/**
+ * Android-specific override: throw [SecurityException] (the missing-permission path on Android). This is needed because
+ * [SecurityException] is JVM-only and not available in commonMain.
+ */
+fun FakeBluetoothRepository.failBondWithSecurityException(message: String = "BLUETOOTH_CONNECT not granted") {
+ bondOutcome = FakeBluetoothRepository.BondOutcome.Security(SecurityException(message))
+}
diff --git a/core/testing/src/androidMain/kotlin/org/meshtastic/core/testing/RobolectricBleBonding.kt b/core/testing/src/androidMain/kotlin/org/meshtastic/core/testing/RobolectricBleBonding.kt
new file mode 100644
index 000000000..6a7c1bf69
--- /dev/null
+++ b/core/testing/src/androidMain/kotlin/org/meshtastic/core/testing/RobolectricBleBonding.kt
@@ -0,0 +1,100 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.core.testing
+
+import android.Manifest
+import android.app.Application
+import android.bluetooth.BluetoothAdapter
+import android.bluetooth.BluetoothDevice
+import android.content.Intent
+import android.os.Looper
+import org.robolectric.RuntimeEnvironment
+import org.robolectric.Shadows.shadowOf
+import org.robolectric.shadows.ShadowBluetoothDevice
+
+/**
+ * Reusable Robolectric helpers for driving Android Bluetooth bonding logic in `androidHostTest` source sets.
+ *
+ * These let a unit test exercise the real `AndroidBluetoothRepository.bond()` (and any future BLE bonding code) without
+ * an emulator and without a production seam. They rely on two Robolectric behaviors (verified against Robolectric
+ * 4.16.x):
+ * - [android.bluetooth.BluetoothAdapter.getRemoteDevice] caches the returned [BluetoothDevice] by address in a static
+ * map, so the shadow configured here is the *same* instance production code reads when it calls
+ * `getRemoteDevice(mac)` internally.
+ * - [ShadowBluetoothDevice.createBond] calls `checkForBluetoothConnectPermission()` first, so tests must call
+ * [grantBluetoothConnectPermission] or `createBond()` throws [SecurityException] instead of returning a value.
+ *
+ * Isolation note: because the device cache is static and survives across tests in the same JVM, give each test a
+ * distinct MAC so bond-state cannot bleed between tests.
+ */
+object RobolectricBleBonding {
+
+ /** A syntactically valid BLE MAC for tests that don't care about the specific address. */
+ const val TEST_BLE_MAC: String = "AA:BB:CC:DD:EE:FF"
+
+ private val application: Application
+ get() = RuntimeEnvironment.getApplication()
+
+ /**
+ * The default adapter Robolectric exposes; production resolves the same one via
+ * [android.bluetooth.BluetoothManager].
+ */
+ private val adapter: BluetoothAdapter
+ get() = BluetoothAdapter.getDefaultAdapter()
+
+ /** Grant the runtime permissions [ShadowBluetoothDevice.createBond] checks, so it returns instead of throwing. */
+ fun grantBluetoothConnectPermission() {
+ shadowOf(application)
+ .grantPermissions(Manifest.permission.BLUETOOTH_CONNECT, Manifest.permission.BLUETOOTH_SCAN)
+ }
+
+ /**
+ * The (address-cached) [ShadowBluetoothDevice] for [mac] — the same instance production's `getRemoteDevice` sees.
+ */
+ fun deviceShadow(mac: String = TEST_BLE_MAC): ShadowBluetoothDevice = shadowOf(adapter.getRemoteDevice(mac))
+
+ /**
+ * Configure the cached device for [mac]: its [bondState] and what `createBond()` returns.
+ *
+ * Defaulting [createBondReturns] to `false` reproduces the #5849 entry condition (createBond() returned false →
+ * re-check bondState). Note Robolectric's `createBond()` sets bondState to BONDED only when [createBondReturns] is
+ * `true`; with `false` the [bondState] you set here is what the production re-check reads.
+ */
+ fun primeBond(
+ mac: String = TEST_BLE_MAC,
+ bondState: Int = BluetoothDevice.BOND_NONE,
+ createBondReturns: Boolean = false,
+ ): ShadowBluetoothDevice = deviceShadow(mac).apply {
+ setBondState(bondState)
+ setCreatedBond(createBondReturns)
+ }
+
+ /**
+ * Broadcast `ACTION_BOND_STATE_CHANGED` for [mac] and pump the main looper so a runtime-registered
+ * [android.content.BroadcastReceiver] (e.g. the one `bond()` registers) is delivered synchronously.
+ */
+ fun sendBondStateChanged(mac: String, newState: Int, previousState: Int) {
+ val intent =
+ Intent(BluetoothDevice.ACTION_BOND_STATE_CHANGED).apply {
+ putExtra(BluetoothDevice.EXTRA_DEVICE, adapter.getRemoteDevice(mac))
+ putExtra(BluetoothDevice.EXTRA_BOND_STATE, newState)
+ putExtra(BluetoothDevice.EXTRA_PREVIOUS_BOND_STATE, previousState)
+ }
+ application.sendBroadcast(intent)
+ shadowOf(Looper.getMainLooper()).idle()
+ }
+}
diff --git a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeBle.kt b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeBle.kt
index efaf16a22..178f994e2 100644
--- a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeBle.kt
+++ b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeBle.kt
@@ -311,6 +311,24 @@ class FakeBluetoothRepository :
private val _state = mutableStateFlow(BluetoothState(hasPermissions = true, enabled = true))
override val state: StateFlow = _state.asStateFlow()
+ /**
+ * Controls what [bond] does. Defaults to [BondOutcome.Success] so existing tests that never touch this knob keep
+ * the historical "bonding always succeeds" behavior. Set it (or use the [failBondWith] /
+ * [failBondWithSecurityException] helpers) to drive the failure paths of consumers such as
+ * `AndroidScannerViewModel.requestBonding`.
+ */
+ var bondOutcome: BondOutcome = BondOutcome.Success
+
+ /** Every device passed to [bond], in call order — lets tests assert that bonding was (or was not) attempted. */
+ val bondCalls = mutableListOf()
+
+ init {
+ registerResetAction {
+ bondOutcome = BondOutcome.Success
+ bondCalls.clear()
+ }
+ }
+
override fun refreshState() {}
override fun isValid(bleAddress: String): Boolean = bleAddress.isNotBlank()
@@ -318,9 +336,18 @@ class FakeBluetoothRepository :
override fun isBonded(address: String): Boolean = _state.value.bondedDevices.any { it.address == address }
override suspend fun bond(device: BleDevice) {
- val currentState = _state.value
- if (!currentState.bondedDevices.contains(device)) {
- _state.value = currentState.copy(bondedDevices = currentState.bondedDevices + device)
+ bondCalls += device
+ when (val outcome = bondOutcome) {
+ is BondOutcome.Security -> throw outcome.error
+
+ is BondOutcome.Fail -> throw outcome.error
+
+ BondOutcome.Success -> {
+ val currentState = _state.value
+ if (!currentState.bondedDevices.contains(device)) {
+ _state.value = currentState.copy(bondedDevices = currentState.bondedDevices + device)
+ }
+ }
}
}
@@ -331,4 +358,25 @@ class FakeBluetoothRepository :
fun setHasPermissions(hasPermissions: Boolean) {
_state.value = _state.value.copy(hasPermissions = hasPermissions)
}
+
+ /**
+ * The outcome [FakeBluetoothRepository.bond] produces. [Fail] and [Security] both simply throw their wrapped error;
+ * the distinct cases exist only to document caller intent (via [failBondWith] vs [failBondWithSecurityException])
+ * and leave a seam should the fake ever need to branch on permission failures.
+ */
+ sealed interface BondOutcome {
+ /** bond() completes normally and records the device as bonded (pre-existing default behavior). */
+ data object Success : BondOutcome
+
+ /** bond() throws [error] — models a generic/flaky bonding failure (timeout, dropped broadcast, etc.). */
+ data class Fail(val error: Throwable) : BondOutcome
+
+ /** bond() throws [error] — models a missing-permission (BLUETOOTH_CONNECT) failure. */
+ data class Security(val error: Throwable) : BondOutcome
+ }
+}
+
+/** Make the next [FakeBluetoothRepository.bond] call throw a generic [error] (the flaky/interrupted-bonding path). */
+fun FakeBluetoothRepository.failBondWith(error: Throwable = Exception("bond failed")) {
+ bondOutcome = FakeBluetoothRepository.BondOutcome.Fail(error)
}
diff --git a/feature/connections/build.gradle.kts b/feature/connections/build.gradle.kts
index b1c2c0d5d..97fd9f284 100644
--- a/feature/connections/build.gradle.kts
+++ b/feature/connections/build.gradle.kts
@@ -41,5 +41,13 @@ kotlin {
}
androidMain.dependencies { implementation(libs.usb.serial.android) }
+
+ val androidHostTest by getting {
+ dependencies {
+ implementation(projects.core.testing)
+ implementation(libs.kotlinx.coroutines.test)
+ implementation(libs.androidx.test.ext.junit)
+ }
+ }
}
}
diff --git a/feature/connections/src/androidHostTest/kotlin/org/meshtastic/feature/connections/AndroidScannerViewModelBondingTest.kt b/feature/connections/src/androidHostTest/kotlin/org/meshtastic/feature/connections/AndroidScannerViewModelBondingTest.kt
new file mode 100644
index 000000000..fffb9f51b
--- /dev/null
+++ b/feature/connections/src/androidHostTest/kotlin/org/meshtastic/feature/connections/AndroidScannerViewModelBondingTest.kt
@@ -0,0 +1,160 @@
+/*
+ * 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.connections
+
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.LifecycleObserver
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.resetMain
+import kotlinx.coroutines.test.runTest
+import kotlinx.coroutines.test.setMain
+import org.junit.runner.RunWith
+import org.meshtastic.core.network.repository.UsbRepository
+import org.meshtastic.core.testing.FakeBleDevice
+import org.meshtastic.core.testing.failBondWith
+import org.meshtastic.core.testing.failBondWithSecurityException
+import org.meshtastic.feature.connections.model.DeviceListEntry
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.RuntimeEnvironment
+import org.robolectric.annotation.Config
+import kotlin.test.AfterTest
+import kotlin.test.BeforeTest
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertNotNull
+import kotlin.test.assertNull
+import kotlin.test.assertTrue
+
+/**
+ * Coverage for the #5849 fix in [AndroidScannerViewModel.requestBonding]: the transport is armed
+ * (`changeDeviceAddress`) on every bonding outcome EXCEPT a permissions [SecurityException], so an interrupted bond no
+ * longer strands the device. Driven through the public [ScannerViewModel.onSelected] entry point (which routes an
+ * unbonded BLE entry to `requestBonding`).
+ *
+ * Robolectric is used only because the class under test lives in `androidMain`; the bonding outcomes themselves are
+ * injected via [org.meshtastic.core.testing.FakeBluetoothRepository], keeping each assertion deterministic. The real
+ * `AndroidBluetoothRepository.bond()` branches are covered separately by `AndroidBluetoothRepositoryBondTest`.
+ */
+@OptIn(ExperimentalCoroutinesApi::class)
+@RunWith(RobolectricTestRunner::class)
+@Config(sdk = [34])
+class AndroidScannerViewModelBondingTest {
+
+ private val mac = "AA:BB:CC:DD:EE:FF"
+ private val expectedFullAddress = "x$mac"
+
+ private val harness = ScannerViewModelHarness()
+ private lateinit var viewModel: AndroidScannerViewModel
+
+ @BeforeTest
+ fun setUp() {
+ Dispatchers.setMain(harness.testDispatcher)
+ viewModel =
+ AndroidScannerViewModel(
+ serviceRepository = harness.serviceRepository,
+ radioController = harness.radioController,
+ radioInterfaceService = harness.radioInterfaceService,
+ radioPrefs = harness.radioPrefs,
+ recentAddressesDataSource = harness.recentAddressesDataSource,
+ getDiscoveredDevicesUseCase = harness.getDiscoveredDevicesUseCase,
+ networkRepository = harness.networkRepository,
+ dispatchers = harness.dispatchers,
+ bluetoothRepository = harness.bluetoothRepository,
+ usbRepository = inertUsbRepository(),
+ uiPrefs = harness.uiPrefs,
+ bleScanner = harness.bleScanner,
+ )
+ }
+
+ @AfterTest
+ fun tearDown() {
+ Dispatchers.resetMain()
+ }
+
+ /**
+ * A real [UsbRepository] (it is `final` and cross-module, so Mokkery can't mock it) made inert for this test: a
+ * DESTROYED [Lifecycle] means its `init` launch and eager `stateIn` never run, so its (cyclic) lazies are never
+ * evaluated. The USB path is irrelevant to bonding — the ViewModel only stores this collaborator.
+ */
+ private fun inertUsbRepository(): UsbRepository = UsbRepository(
+ application = RuntimeEnvironment.getApplication(),
+ dispatchers = harness.dispatchers,
+ processLifecycle = DestroyedLifecycle,
+ usbBroadcastReceiverLazy = lazy { error("UsbBroadcastReceiver must not be used in this test") },
+ usbManagerLazy = lazy { null },
+ usbSerialProberLazy = lazy { error("UsbSerialProber must not be used in this test") },
+ )
+
+ private object DestroyedLifecycle : Lifecycle() {
+ override val currentState: State = State.DESTROYED
+
+ override fun addObserver(observer: LifecycleObserver) = Unit
+
+ override fun removeObserver(observer: LifecycleObserver) = Unit
+ }
+
+ @Test
+ fun `successful bond arms the transport`() = runTest(harness.testDispatcher) {
+ viewModel.onSelected(ScannerViewModelHarness.unbondedBleEntry(mac))
+ testScheduler.advanceUntilIdle()
+
+ assertEquals(1, harness.bluetoothRepository.bondCalls.size)
+ assertEquals(expectedFullAddress, harness.radioController.lastSetDeviceAddress)
+ assertNull(harness.serviceRepository.errorMessage.value)
+ }
+
+ @Test
+ fun `generic bond failure still arms the transport`() = runTest(harness.testDispatcher) {
+ // The interrupted-bonding case the fix targets: bond() throws a non-permission error, but we must still
+ // arm the transport so its reconnect loop can converge instead of leaving the device inert.
+ harness.bluetoothRepository.failBondWith(Exception("Failed to initiate bonding"))
+
+ viewModel.onSelected(ScannerViewModelHarness.unbondedBleEntry(mac))
+ testScheduler.advanceUntilIdle()
+
+ assertEquals(1, harness.bluetoothRepository.bondCalls.size)
+ assertEquals(expectedFullAddress, harness.radioController.lastSetDeviceAddress)
+ assertNull(harness.serviceRepository.errorMessage.value)
+ }
+
+ @Test
+ fun `security exception does not arm the transport and surfaces an error`() = runTest(harness.testDispatcher) {
+ // Missing BLUETOOTH_CONNECT: connecting would fail the same way, so surface the error and do NOT arm.
+ harness.bluetoothRepository.failBondWithSecurityException()
+
+ viewModel.onSelected(ScannerViewModelHarness.unbondedBleEntry(mac))
+ testScheduler.advanceUntilIdle()
+
+ assertEquals(1, harness.bluetoothRepository.bondCalls.size)
+ assertNull(harness.radioController.lastSetDeviceAddress)
+ assertNotNull(harness.serviceRepository.errorMessage.value)
+ }
+
+ @Test
+ fun `already bonded entry arms the transport without bonding`() = runTest(harness.testDispatcher) {
+ // R6: selecting an already-bonded device connects directly without invoking createBond().
+ val bonded = DeviceListEntry.Ble(device = FakeBleDevice(address = mac, name = "Node"), bonded = true)
+
+ val initiatedImmediately = viewModel.onSelected(bonded)
+ testScheduler.advanceUntilIdle()
+
+ assertTrue(initiatedImmediately)
+ assertTrue(harness.bluetoothRepository.bondCalls.isEmpty())
+ assertEquals(expectedFullAddress, harness.radioController.lastSetDeviceAddress)
+ }
+}
diff --git a/feature/connections/src/commonTest/kotlin/org/meshtastic/feature/connections/ScannerViewModelHarness.kt b/feature/connections/src/commonTest/kotlin/org/meshtastic/feature/connections/ScannerViewModelHarness.kt
new file mode 100644
index 000000000..3bb1a1532
--- /dev/null
+++ b/feature/connections/src/commonTest/kotlin/org/meshtastic/feature/connections/ScannerViewModelHarness.kt
@@ -0,0 +1,126 @@
+/*
+ * 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.connections
+
+import dev.mokkery.MockMode
+import dev.mokkery.answering.returns
+import dev.mokkery.every
+import dev.mokkery.mock
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.test.TestDispatcher
+import kotlinx.coroutines.test.UnconfinedTestDispatcher
+import org.meshtastic.core.ble.BleScanner
+import org.meshtastic.core.datastore.RecentAddressesDataSource
+import org.meshtastic.core.di.CoroutineDispatchers
+import org.meshtastic.core.network.repository.DiscoveredService
+import org.meshtastic.core.network.repository.NetworkRepository
+import org.meshtastic.core.repository.RadioInterfaceService
+import org.meshtastic.core.repository.RadioPrefs
+import org.meshtastic.core.testing.FakeBleDevice
+import org.meshtastic.core.testing.FakeBluetoothRepository
+import org.meshtastic.core.testing.FakeRadioController
+import org.meshtastic.core.testing.FakeServiceRepository
+import org.meshtastic.core.testing.FakeUiPrefs
+import org.meshtastic.feature.connections.model.DeviceListEntry
+import org.meshtastic.feature.connections.model.DiscoveredDevices
+import org.meshtastic.feature.connections.model.GetDiscoveredDevicesUseCase
+
+/**
+ * Reusable construction harness for [ScannerViewModel] (and its platform subclasses).
+ *
+ * Bundles the ~10 collaborators the ViewModel needs — hand-written fakes where assertions are made ([radioController],
+ * [serviceRepository], [bluetoothRepository]) and Mokkery autofill mocks for the peripheral ones — plus the common
+ * init-time stubbing, so a test only writes the bits it actually asserts on. Lives in `commonTest` so both the
+ * platform-neutral test and the `androidHostTest` subclass tests can reuse it (the KMP default hierarchy makes
+ * `androidHostTest` depend on `commonTest`).
+ *
+ * Usage: construct the harness, `Dispatchers.setMain(harness.testDispatcher)`, then [buildBase] (platform-neutral) or
+ * build a platform subclass from the exposed collaborators (see `AndroidScannerViewModelBondingTest`).
+ */
+@OptIn(ExperimentalCoroutinesApi::class)
+class ScannerViewModelHarness(val testDispatcher: TestDispatcher = UnconfinedTestDispatcher()) {
+
+ val serviceRepository = FakeServiceRepository()
+ val radioController = FakeRadioController()
+ val bluetoothRepository = FakeBluetoothRepository()
+ val uiPrefs = FakeUiPrefs()
+
+ val radioInterfaceService: RadioInterfaceService = mock(MockMode.autofill)
+ val radioPrefs: RadioPrefs = mock(MockMode.autofill)
+ val recentAddressesDataSource: RecentAddressesDataSource = mock(MockMode.autofill)
+ val networkRepository: NetworkRepository = mock(MockMode.autofill)
+ val bleScanner: BleScanner = mock(MockMode.autofill)
+
+ /** Drives the base device list (bonded BLE / USB / TCP) for tests that exercise the discovery flows. */
+ val baseDevicesFlow = MutableStateFlow(DiscoveredDevices())
+
+ /** NSD-resolved services, gated by the network-scan flag in the ViewModel. */
+ val resolvedServicesFlow = MutableStateFlow>(emptyList())
+
+ val dispatchers = CoroutineDispatchers(io = testDispatcher, main = testDispatcher, default = testDispatcher)
+
+ /**
+ * A fake [GetDiscoveredDevicesUseCase] that mirrors the real behavior: it combines [baseDevicesFlow] with the
+ * provided resolved list so tests can verify NSD gating.
+ */
+ val getDiscoveredDevicesUseCase =
+ object : GetDiscoveredDevicesUseCase {
+ override fun invoke(
+ showMock: Boolean,
+ resolvedList: Flow>,
+ ): Flow = combine(baseDevicesFlow, resolvedList) { base, resolved ->
+ val tcpDevices =
+ resolved.map { DeviceListEntry.Tcp(name = it.name, fullAddress = "t${it.hostAddress}") }
+ base.copy(discoveredTcpDevices = tcpDevices)
+ }
+ }
+
+ init {
+ every { radioInterfaceService.isMockTransport() } returns false
+ every { radioInterfaceService.currentDeviceAddressFlow } returns MutableStateFlow(null)
+ every { recentAddressesDataSource.recentAddresses } returns MutableStateFlow(emptyList())
+ every { networkRepository.resolvedList } returns resolvedServicesFlow
+ every { networkRepository.networkAvailable } returns flowOf(true)
+ }
+
+ /**
+ * Build the platform-neutral [ScannerViewModel]. Call only after `Dispatchers.setMain(testDispatcher)` because the
+ * ViewModel's `init` launches work on `viewModelScope` (Main).
+ */
+ fun buildBase(): ScannerViewModel = ScannerViewModel(
+ serviceRepository = serviceRepository,
+ radioController = radioController,
+ radioInterfaceService = radioInterfaceService,
+ radioPrefs = radioPrefs,
+ recentAddressesDataSource = recentAddressesDataSource,
+ getDiscoveredDevicesUseCase = getDiscoveredDevicesUseCase,
+ networkRepository = networkRepository,
+ dispatchers = dispatchers,
+ uiPrefs = uiPrefs,
+ bleScanner = bleScanner,
+ )
+
+ companion object {
+ /** A scanned-but-unbonded BLE entry — the input that routes through `requestBonding`. */
+ fun unbondedBleEntry(address: String, name: String = "Node"): DeviceListEntry.Ble =
+ DeviceListEntry.Ble(device = FakeBleDevice(address = address, name = name), bonded = false)
+ }
+}
diff --git a/feature/connections/src/commonTest/kotlin/org/meshtastic/feature/connections/ScannerViewModelTest.kt b/feature/connections/src/commonTest/kotlin/org/meshtastic/feature/connections/ScannerViewModelTest.kt
index 03bad540d..c9e401e9c 100644
--- a/feature/connections/src/commonTest/kotlin/org/meshtastic/feature/connections/ScannerViewModelTest.kt
+++ b/feature/connections/src/commonTest/kotlin/org/meshtastic/feature/connections/ScannerViewModelTest.kt
@@ -17,32 +17,19 @@
package org.meshtastic.feature.connections
import app.cash.turbine.test
-import dev.mokkery.MockMode
import dev.mokkery.answering.returns
import dev.mokkery.every
import dev.mokkery.matcher.any
-import dev.mokkery.mock
import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
-import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.filterNotNull
-import kotlinx.coroutines.flow.flowOf
-import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
-import org.meshtastic.core.datastore.RecentAddressesDataSource
import org.meshtastic.core.network.repository.DiscoveredService
-import org.meshtastic.core.network.repository.NetworkRepository
-import org.meshtastic.core.repository.RadioInterfaceService
-import org.meshtastic.core.repository.RadioPrefs
import org.meshtastic.core.testing.FakeBleDevice
-import org.meshtastic.core.testing.FakeRadioController
-import org.meshtastic.core.testing.FakeServiceRepository
import org.meshtastic.feature.connections.model.DeviceListEntry
import org.meshtastic.feature.connections.model.DiscoveredDevices
-import org.meshtastic.feature.connections.model.GetDiscoveredDevicesUseCase
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
@@ -52,69 +39,34 @@ import kotlin.test.assertNotNull
@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
class ScannerViewModelTest {
- private val testDispatcher = UnconfinedTestDispatcher()
+ private val harness = ScannerViewModelHarness()
private lateinit var viewModel: ScannerViewModel
- private val serviceRepository = FakeServiceRepository()
- private val radioController = FakeRadioController()
- private val radioInterfaceService: RadioInterfaceService = mock(MockMode.autofill)
- private val radioPrefs: RadioPrefs = mock(MockMode.autofill)
- private val recentAddressesDataSource: RecentAddressesDataSource = mock(MockMode.autofill)
- private val networkRepository: NetworkRepository = mock(MockMode.autofill)
- private val bleScanner: org.meshtastic.core.ble.BleScanner = mock(MockMode.autofill)
- private val uiPrefs = org.meshtastic.core.testing.FakeUiPrefs()
- private val resolvedServicesFlow = MutableStateFlow>(emptyList())
- private val baseDevicesFlow = MutableStateFlow(DiscoveredDevices())
+ // Convenience aliases so the existing test bodies read unchanged.
+ private val serviceRepository
+ get() = harness.serviceRepository
- /**
- * A fake [GetDiscoveredDevicesUseCase] that mirrors the real behavior: it combines the provided [resolvedList] with
- * base device data so tests can verify NSD gating.
- */
- private val getDiscoveredDevicesUseCase =
- object : GetDiscoveredDevicesUseCase {
- override fun invoke(
- showMock: Boolean,
- resolvedList: Flow>,
- ): Flow = combine(baseDevicesFlow, resolvedList) { base, resolved ->
- val tcpDevices =
- resolved.map { DeviceListEntry.Tcp(name = it.name, fullAddress = "t${it.hostAddress}") }
- base.copy(discoveredTcpDevices = tcpDevices)
- }
- }
+ private val radioController
+ get() = harness.radioController
+
+ private val bleScanner
+ get() = harness.bleScanner
+
+ private val baseDevicesFlow
+ get() = harness.baseDevicesFlow
+
+ private val resolvedServicesFlow
+ get() = harness.resolvedServicesFlow
@BeforeTest
fun setUp() {
- Dispatchers.setMain(testDispatcher)
-
- every { radioInterfaceService.isMockTransport() } returns false
- every { radioInterfaceService.currentDeviceAddressFlow } returns MutableStateFlow(null)
-
- every { recentAddressesDataSource.recentAddresses } returns MutableStateFlow(emptyList())
- every { networkRepository.resolvedList } returns resolvedServicesFlow
- every { networkRepository.networkAvailable } returns flowOf(true)
+ Dispatchers.setMain(harness.testDispatcher)
serviceRepository.setConnectionProgress("")
baseDevicesFlow.value = DiscoveredDevices()
resolvedServicesFlow.value = emptyList()
- viewModel =
- ScannerViewModel(
- serviceRepository = serviceRepository,
- radioController = radioController,
- radioInterfaceService = radioInterfaceService,
- radioPrefs = radioPrefs,
- recentAddressesDataSource = recentAddressesDataSource,
- getDiscoveredDevicesUseCase = getDiscoveredDevicesUseCase,
- networkRepository = networkRepository,
- dispatchers =
- org.meshtastic.core.di.CoroutineDispatchers(
- io = testDispatcher,
- main = testDispatcher,
- default = testDispatcher,
- ),
- uiPrefs = uiPrefs,
- bleScanner = bleScanner,
- )
+ viewModel = harness.buildBase()
}
@AfterTest