mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-06-26 06:25:24 -04:00
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user