From 4e7e4c39cbf77b5d9b546c4b138e23b8b3774e73 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Thu, 18 Jun 2026 13:15:54 -0500 Subject: [PATCH] test(ble): add Robolectric coverage for the bonding-interruption fix (#5849) (#5850) Co-authored-by: Claude Opus 4.8 --- core/ble/build.gradle.kts | 8 + .../ble/AndroidBluetoothRepositoryBondTest.kt | 252 ++++++++++++++++++ .../core/testing/FakeBle.android.kt | 25 ++ .../core/testing/RobolectricBleBonding.kt | 100 +++++++ .../org/meshtastic/core/testing/FakeBle.kt | 54 +++- feature/connections/build.gradle.kts | 8 + .../AndroidScannerViewModelBondingTest.kt | 160 +++++++++++ .../connections/ScannerViewModelHarness.kt | 126 +++++++++ .../connections/ScannerViewModelTest.kt | 82 ++---- 9 files changed, 747 insertions(+), 68 deletions(-) create mode 100644 core/ble/src/androidHostTest/kotlin/org/meshtastic/core/ble/AndroidBluetoothRepositoryBondTest.kt create mode 100644 core/testing/src/androidMain/kotlin/org/meshtastic/core/testing/FakeBle.android.kt create mode 100644 core/testing/src/androidMain/kotlin/org/meshtastic/core/testing/RobolectricBleBonding.kt create mode 100644 feature/connections/src/androidHostTest/kotlin/org/meshtastic/feature/connections/AndroidScannerViewModelBondingTest.kt create mode 100644 feature/connections/src/commonTest/kotlin/org/meshtastic/feature/connections/ScannerViewModelHarness.kt 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