test(ble): add Robolectric coverage for the bonding-interruption fix (#5849) (#5850)

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
James Rich
2026-06-18 13:15:54 -05:00
committed by GitHub
parent 975adce303
commit 4e7e4c39cb
9 changed files with 747 additions and 68 deletions

View File

@@ -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)
}
}
}
}

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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<Throwable?> {
val result = CompletableDeferred<Throwable?>()
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<Exception> { 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
}
}

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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))
}

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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()
}
}

View File

@@ -311,6 +311,24 @@ class FakeBluetoothRepository :
private val _state = mutableStateFlow(BluetoothState(hasPermissions = true, enabled = true))
override val state: StateFlow<BluetoothState> = _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<BleDevice>()
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)
}

View File

@@ -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)
}
}
}
}

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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)
}
}

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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<List<DiscoveredService>>(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<List<DiscoveredService>>,
): Flow<DiscoveredDevices> = 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)
}
}

View File

@@ -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<List<DiscoveredService>>(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<List<DiscoveredService>>,
): Flow<DiscoveredDevices> = 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