refactor: remove demoscenario and enhance BLE connection stability (#4914)

Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
James Rich 2026-03-25 09:24:42 -05:00 committed by GitHub
parent 6516287c62
commit 8ce17defb7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 130 additions and 238 deletions

View file

@ -21,8 +21,10 @@ package org.meshtastic.core.network.radio
import co.touchlab.kermit.Logger
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.first
@ -53,6 +55,7 @@ import kotlin.time.Duration.Companion.seconds
private const val SCAN_RETRY_COUNT = 3
private const val SCAN_RETRY_DELAY_MS = 1000L
private const val CONNECTION_TIMEOUT_MS = 15_000L
private const val RECONNECT_FAILURE_THRESHOLD = 3
private val SCAN_TIMEOUT = 5.seconds
/**
@ -106,9 +109,9 @@ class BleRadioInterface(
private var bytesReceived: Long = 0
private var bytesSent: Long = 0
@Suppress("VolatileModifier")
@Volatile
private var isFullyConnected = false
@Volatile private var isFullyConnected = false
private var connectionJob: Job? = null
private var consecutiveFailures = 0
init {
connect()
@ -148,24 +151,11 @@ class BleRadioInterface(
}
private fun connect() {
connectionScope.launch {
bleConnection.connectionState
.onEach { state ->
if (state is BleConnectionState.Disconnected && isFullyConnected) {
isFullyConnected = false
onDisconnected(state)
}
}
.catch { e ->
Logger.w(e) { "[$address] bleConnection.connectionState flow crashed!" }
handleFailure(e)
}
.launchIn(connectionScope)
connectionJob = connectionScope.launch {
while (isActive) {
try {
// Add a delay to allow any pending background disconnects (from a previous close() call)
// to complete and the Android BLE stack to settle before we attempt a new connection.
// Allow any pending background disconnects to complete and the Android BLE stack
// to settle before we attempt a new connection.
@Suppress("MagicNumber")
val connectDelayMs = 1000L
kotlinx.coroutines.delay(connectDelayMs)
@ -191,12 +181,30 @@ class BleRadioInterface(
throw RadioNotConnectedException("Failed to connect to device at address $address")
}
// Connection succeeded — reset failure counter
consecutiveFailures = 0
isFullyConnected = true
onConnected()
discoverServicesAndSetupCharacteristics()
// Suspend here until Kable drops the connection
bleConnection.connectionState.first { it is BleConnectionState.Disconnected }
// Use coroutineScope so that the connectionState listener is scoped to this
// iteration only. When the inner scope exits (on disconnect), the listener is
// cancelled automatically before the next reconnect cycle starts a fresh one.
coroutineScope {
bleConnection.connectionState
.onEach { s ->
if (s is BleConnectionState.Disconnected && isFullyConnected) {
isFullyConnected = false
onDisconnected()
}
}
.catch { e -> Logger.w(e) { "[$address] bleConnection.connectionState flow crashed!" } }
.launchIn(this)
discoverServicesAndSetupCharacteristics()
// Suspend here until Kable drops the connection
bleConnection.connectionState.first { it is BleConnectionState.Disconnected }
}
Logger.i { "[$address] BLE connection dropped, preparing to reconnect..." }
} catch (e: kotlinx.coroutines.CancellationException) {
@ -204,8 +212,17 @@ class BleRadioInterface(
throw e
} catch (e: Exception) {
val failureTime = nowMillis - connectionStartTime
Logger.w(e) { "[$address] Failed to connect to device after ${failureTime}ms" }
handleFailure(e)
consecutiveFailures++
Logger.w(e) {
"[$address] Failed to connect to device after ${failureTime}ms " +
"(consecutive failures: $consecutiveFailures)"
}
// After repeated failures, signal DeviceSleep so MeshConnectionManagerImpl can
// start its sleep timeout. handleFailure covers permanent-error cases.
if (consecutiveFailures >= RECONNECT_FAILURE_THRESHOLD) {
handleFailure(e)
}
// Wait before retrying to prevent hot loops
@Suppress("MagicNumber")
@ -226,7 +243,7 @@ class BleRadioInterface(
}
}
private fun onDisconnected(@Suppress("UNUSED_PARAMETER") state: BleConnectionState.Disconnected) {
private fun onDisconnected() {
radioService = null
val uptime =
@ -241,10 +258,10 @@ class BleRadioInterface(
"Packets RX: $packetsReceived ($bytesReceived bytes), " +
"Packets TX: $packetsSent ($bytesSent bytes)"
}
// Note: Disconnected state in commonMain doesn't currently carry a reason.
// We might want to add that later if needed.
service.onDisconnect(false, errorMessage = "Disconnected")
// Do NOT call service.onDisconnect() here. The reconnect while-loop handles retries
// internally. Emitting DeviceSleep on every transient disconnect creates competing state
// transitions with MeshConnectionManagerImpl's sleep timeout. Instead, handleFailure()
// is called from the catch block after RECONNECT_FAILURE_THRESHOLD consecutive failures.
}
private suspend fun discoverServicesAndSetupCharacteristics() {
@ -348,9 +365,15 @@ class BleRadioInterface(
"Packets RX: $packetsReceived ($bytesReceived bytes), " +
"Packets TX: $packetsSent ($bytesSent bytes)"
}
// Cancel the connection scope FIRST to break the while(isActive) reconnect loop,
// then perform async cleanup on the parent serviceScope.
connectionScope.cancel("close() called")
serviceScope.launch {
connectionScope.cancel()
bleConnection.disconnect()
try {
bleConnection.disconnect()
} catch (@Suppress("TooGenericExceptionCaught") e: Exception) {
Logger.w(e) { "[$address] Failed to disconnect in close()" }
}
service.onDisconnect(true)
}
}

View file

@ -42,6 +42,9 @@ class RadioPrefsImpl(
override val devAddr: StateFlow<String?> =
dataStore.data.map { it[KEY_DEV_ADDR_PREF] }.stateIn(scope, SharingStarted.Eagerly, null)
override val devName: StateFlow<String?> =
dataStore.data.map { it[KEY_DEV_NAME_PREF] }.stateIn(scope, SharingStarted.Eagerly, null)
override fun setDevAddr(address: String?) {
scope.launch {
dataStore.edit { prefs ->
@ -54,7 +57,20 @@ class RadioPrefsImpl(
}
}
override fun setDevName(name: String?) {
scope.launch {
dataStore.edit { prefs ->
if (name == null) {
prefs.remove(KEY_DEV_NAME_PREF)
} else {
prefs[KEY_DEV_NAME_PREF] = name
}
}
}
}
companion object {
val KEY_DEV_ADDR_PREF = stringPreferencesKey("devAddr2")
val KEY_DEV_NAME_PREF = stringPreferencesKey("devName")
}
}

View file

@ -185,7 +185,12 @@ interface MapTileProviderPrefs {
interface RadioPrefs {
val devAddr: StateFlow<String?>
/** The persisted user-visible name of the connected device (e.g. "Meshtastic_1234"). */
val devName: StateFlow<String?>
fun setDevAddr(address: String?)
fun setDevName(name: String?)
}
fun RadioPrefs.isBle() = devAddr.value?.startsWith("x") == true

View file

@ -100,6 +100,7 @@ class SharedRadioInterfaceService(
private var _serviceScope = CoroutineScope(dispatchers.io + SupervisorJob())
private var radioIf: RadioTransport? = null
private var runningInterfaceId: InterfaceId? = null
private var isStarted = false
private val listenersInitialized = kotlinx.atomicfu.atomic(false)
@ -111,6 +112,7 @@ class SharedRadioInterfaceService(
}
private val initLock = Mutex()
private val interfaceMutex = Mutex()
private fun initStateListeners() {
if (listenersInitialized.value) return
@ -121,19 +123,23 @@ class SharedRadioInterfaceService(
radioPrefs.devAddr
.onEach { addr ->
if (_currentDeviceAddressFlow.value != addr) {
_currentDeviceAddressFlow.value = addr
startInterface()
interfaceMutex.withLock {
if (_currentDeviceAddressFlow.value != addr) {
_currentDeviceAddressFlow.value = addr
startInterfaceLocked()
}
}
}
.launchIn(processLifecycle.coroutineScope)
bluetoothRepository.state
.onEach { state ->
if (state.enabled) {
startInterface()
} else if (getBondedDeviceAddress()?.startsWith(InterfaceId.BLUETOOTH.id) == true) {
stopInterface()
interfaceMutex.withLock {
if (state.enabled) {
startInterfaceLocked()
} else if (runningInterfaceId == InterfaceId.BLUETOOTH) {
stopInterfaceLocked()
}
}
}
.catch { Logger.e(it) { "bluetoothRepository.state flow crashed!" } }
@ -141,10 +147,12 @@ class SharedRadioInterfaceService(
networkRepository.networkAvailable
.onEach { state ->
if (state) {
startInterface()
} else if (getBondedDeviceAddress()?.startsWith(InterfaceId.TCP.id) == true) {
stopInterface()
interfaceMutex.withLock {
if (state) {
startInterfaceLocked()
} else if (runningInterfaceId == InterfaceId.TCP) {
stopInterfaceLocked()
}
}
}
.catch { Logger.e(it) { "networkRepository.networkAvailable flow crashed!" } }
@ -154,7 +162,7 @@ class SharedRadioInterfaceService(
}
override fun connect() {
startInterface()
processLifecycle.coroutineScope.launch { interfaceMutex.withLock { startInterfaceLocked() } }
initStateListeners()
}
@ -183,16 +191,22 @@ class SharedRadioInterfaceService(
}
analytics.track("mesh_bond")
ignoreException { stopInterface() }
Logger.d { "Setting bonded device to ${sanitized?.anonymize}" }
radioPrefs.setDevAddr(sanitized)
_currentDeviceAddressFlow.value = sanitized
startInterface()
processLifecycle.coroutineScope.launch {
interfaceMutex.withLock {
ignoreException { stopInterfaceLocked() }
startInterfaceLocked()
}
}
return true
}
private fun startInterface() {
/** Must be called under [interfaceMutex]. */
private fun startInterfaceLocked() {
if (radioIf != null) return
val address =
@ -206,15 +220,18 @@ class SharedRadioInterfaceService(
Logger.i { "Starting radio interface for ${address.anonymize}" }
isStarted = true
runningInterfaceId = address.firstOrNull()?.let { InterfaceId.forIdChar(it) }
radioIf = transportFactory.createTransport(address, this)
startHeartbeat()
}
private fun stopInterface() {
/** Must be called under [interfaceMutex]. */
private fun stopInterfaceLocked() {
val currentIf = radioIf
Logger.i { "Stopping interface $currentIf" }
isStarted = false
radioIf = null
runningInterfaceId = null
currentIf?.close()
_serviceScope.cancel("stopping interface")

View file

@ -1,147 +0,0 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* 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.desktop
import org.meshtastic.core.common.util.Base64Factory
import org.meshtastic.core.common.util.CommonUri
import org.meshtastic.core.common.util.DateFormatter
import org.meshtastic.core.common.util.NumberFormatter
import org.meshtastic.core.common.util.UrlUtils
import org.meshtastic.core.model.Capabilities
import org.meshtastic.core.model.Channel
import org.meshtastic.core.model.DeviceVersion
import org.meshtastic.core.model.util.SfppHasher
import org.meshtastic.core.model.util.getShortDateTime
import org.meshtastic.core.model.util.platformRandomBytes
/**
* Exercises key shared KMP modules to validate the module graph links and runs correctly on a pure JVM target without
* Android framework dependencies.
*/
object DemoScenario {
@Suppress("LongMethod")
fun renderReport(): String = buildString {
appendLine("=".repeat(SEPARATOR_WIDTH))
appendLine(" Meshtastic Desktop — KMP Shared Module Smoke Report")
appendLine("=".repeat(SEPARATOR_WIDTH))
appendLine()
// 1. core:common — Base64Factory
section("core:common — Base64Factory") {
val original = "Hello Meshtastic KMP!"
val encoded = Base64Factory.encode(original.encodeToByteArray())
val decoded = Base64Factory.decode(encoded).decodeToString()
appendLine(" Original: $original")
appendLine(" Encoded: $encoded")
appendLine(" Decoded: $decoded")
appendLine(" Round-trip: ${if (original == decoded) "✓ PASS" else "✗ FAIL"}")
}
// 2. core:common — NumberFormatter
@Suppress("MagicNumber")
section("core:common — NumberFormatter") {
appendLine(" format(3.14159, 2) = ${NumberFormatter.format(3.14159, 2)}")
appendLine(" format(-0.5f, 1) = ${NumberFormatter.format(-0.5f, 1)}")
appendLine(" format(100.0, 0) = ${NumberFormatter.format(100.0, 0)}")
}
// 3. core:common — UrlUtils
section("core:common — UrlUtils") {
val raw = "hello world&foo=bar"
appendLine(" encode(\"$raw\") = ${UrlUtils.encode(raw)}")
}
// 4. core:common — DateFormatter
section("core:common — DateFormatter") {
val now = System.currentTimeMillis()
appendLine(" formatTime(now) = ${DateFormatter.formatTime(now)}")
appendLine(" formatDate(now) = ${DateFormatter.formatDate(now)}")
appendLine(" formatRelativeTime(now) = ${DateFormatter.formatRelativeTime(now)}")
appendLine(" formatDateTimeShort(now) = ${DateFormatter.formatDateTimeShort(now)}")
}
// 5. core:common — CommonUri
section("core:common — CommonUri") {
val uri = CommonUri.parse("https://meshtastic.org/e/#test?foo=bar&enabled=true")
appendLine(" host = ${uri.host}")
appendLine(" fragment = ${uri.fragment}")
appendLine(" segments = ${uri.pathSegments}")
appendLine(" foo = ${uri.getQueryParameter("foo")}")
appendLine(" enabled = ${uri.getBooleanQueryParameter("enabled", false)}")
}
// 6. core:model — DeviceVersion
section("core:model — DeviceVersion") {
val v1 = DeviceVersion("2.5.3.abc1234")
val v2 = DeviceVersion("2.6.0.def5678")
appendLine(" v1 = $v1")
appendLine(" v2 = $v2")
appendLine(" v1 < v2 = ${v1 < v2}")
}
// 7. core:model — Capabilities
section("core:model — Capabilities") {
val caps = Capabilities(firmwareVersion = "2.6.0.abc1234")
appendLine(" firmwareVersion = ${caps.firmwareVersion}")
}
// 8. core:model — SfppHasher
section("core:model — SfppHasher") {
val hash =
SfppHasher.computeMessageHash(
encryptedPayload = "test payload".encodeToByteArray(),
to = 0x12345678,
from = 0xABCDEF00.toInt(),
id = 42,
)
appendLine(" hash length = ${hash.size}")
appendLine(" hash (hex) = ${hash.joinToString("") { "%02x".format(it) }}")
}
// 9. core:model — platformRandomBytes
section("core:model — platformRandomBytes") {
val random = platformRandomBytes(KEY_SIZE)
appendLine(" ${random.size} random bytes (hex) = ${random.joinToString("") { "%02x".format(it) }}")
}
// 10. core:model — getShortDateTime
section("core:model — getShortDateTime") {
appendLine(" getShortDateTime(now) = ${getShortDateTime(System.currentTimeMillis())}")
}
// 11. core:model — Channel key generation
section("core:model — Channel.getRandomKey") {
val key = Channel.getRandomKey()
appendLine(" Random channel key (${key.size} bytes)")
}
appendLine()
appendLine("=".repeat(SEPARATOR_WIDTH))
appendLine(" All checks completed successfully")
appendLine("=".repeat(SEPARATOR_WIDTH))
}
private fun StringBuilder.section(title: String, block: StringBuilder.() -> Unit) {
appendLine("─── $title")
block()
appendLine()
}
private const val SEPARATOR_WIDTH = 60
private const val KEY_SIZE = 16
}

View file

@ -1,43 +0,0 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* 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.desktop
import kotlin.test.Test
import kotlin.test.assertTrue
/** Validates that the KMP shared module graph runs correctly on JVM without Android. */
class DemoScenarioTest {
@Test
fun `renderReport produces non-empty output and completes successfully`() {
val report = DemoScenario.renderReport()
assertTrue(report.isNotBlank(), "Report should not be blank")
assertTrue(report.contains("All checks completed successfully"), "Report should indicate success")
}
@Test
fun `renderReport exercises Base64 round-trip`() {
val report = DemoScenario.renderReport()
assertTrue(report.contains("✓ PASS"), "Base64 round-trip should pass")
}
@Test
fun `renderReport exercises NumberFormatter`() {
val report = DemoScenario.renderReport()
assertTrue(report.contains("format(3.14159, 2) = 3.14"), "NumberFormatter should format correctly")
}
}

View file

@ -29,6 +29,7 @@ import org.meshtastic.core.model.RadioController
import org.meshtastic.core.model.util.anonymize
import org.meshtastic.core.network.repository.UsbRepository
import org.meshtastic.core.repository.RadioInterfaceService
import org.meshtastic.core.repository.RadioPrefs
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.feature.connections.model.AndroidUsbDeviceData
import org.meshtastic.feature.connections.model.DeviceListEntry
@ -40,6 +41,7 @@ class AndroidScannerViewModel(
serviceRepository: ServiceRepository,
radioController: RadioController,
radioInterfaceService: RadioInterfaceService,
radioPrefs: RadioPrefs,
recentAddressesDataSource: RecentAddressesDataSource,
getDiscoveredDevicesUseCase: GetDiscoveredDevicesUseCase,
dispatchers: org.meshtastic.core.di.CoroutineDispatchers,
@ -50,6 +52,7 @@ class AndroidScannerViewModel(
serviceRepository,
radioController,
radioInterfaceService,
radioPrefs,
recentAddressesDataSource,
getDiscoveredDevicesUseCase,
dispatchers,

View file

@ -38,6 +38,7 @@ import org.meshtastic.core.datastore.model.RecentAddress
import org.meshtastic.core.model.RadioController
import org.meshtastic.core.model.util.anonymize
import org.meshtastic.core.repository.RadioInterfaceService
import org.meshtastic.core.repository.RadioPrefs
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed
import org.meshtastic.feature.connections.model.DeviceListEntry
@ -49,6 +50,7 @@ open class ScannerViewModel(
protected val serviceRepository: ServiceRepository,
private val radioController: RadioController,
private val radioInterfaceService: RadioInterfaceService,
private val radioPrefs: RadioPrefs,
private val recentAddressesDataSource: RecentAddressesDataSource,
private val getDiscoveredDevicesUseCase: GetDiscoveredDevicesUseCase,
private val dispatchers: org.meshtastic.core.di.CoroutineDispatchers,
@ -150,6 +152,9 @@ open class ScannerViewModel(
val selectedAddressFlow: StateFlow<String?> = radioInterfaceService.currentDeviceAddressFlow
/** The persisted device name from the last selection, for use as a UI fallback. */
val persistedDeviceName: StateFlow<String?> = radioPrefs.devName
val selectedNotNullFlow: StateFlow<String> =
selectedAddressFlow
.map { it ?: NO_DEVICE_SELECTED }
@ -193,6 +198,7 @@ open class ScannerViewModel(
fun onSelected(it: DeviceListEntry): Boolean = when (it) {
is DeviceListEntry.Ble -> {
if (it.bonded) {
radioPrefs.setDevName(it.name)
changeDeviceAddress(it.fullAddress)
true
} else {
@ -202,6 +208,7 @@ open class ScannerViewModel(
}
is DeviceListEntry.Usb -> {
if (it.bonded) {
radioPrefs.setDevName(it.name)
changeDeviceAddress(it.fullAddress)
true
} else {
@ -211,12 +218,14 @@ open class ScannerViewModel(
}
is DeviceListEntry.Tcp -> {
viewModelScope.launch {
radioPrefs.setDevName(it.name)
addRecentAddress(it.fullAddress, it.name)
changeDeviceAddress(it.fullAddress)
}
true
}
is DeviceListEntry.Mock -> {
radioPrefs.setDevName(it.name)
changeDeviceAddress(it.fullAddress)
true
}
@ -228,6 +237,7 @@ open class ScannerViewModel(
protected open fun requestPermission(entry: DeviceListEntry.Usb) {}
fun disconnect() {
radioPrefs.setDevName(null)
changeDeviceAddress(NO_DEVICE_SELECTED)
}
}

View file

@ -106,6 +106,7 @@ fun ConnectionsScreen(
val regionUnset by connectionsViewModel.regionUnset.collectAsStateWithLifecycle()
val selectedDevice by scanModel.selectedNotNullFlow.collectAsStateWithLifecycle()
val persistedDeviceName by scanModel.persistedDeviceName.collectAsStateWithLifecycle()
val bleDevices by scanModel.bleDevicesForUi.collectAsStateWithLifecycle()
val discoveredTcpDevices by scanModel.discoveredTcpDevicesForUi.collectAsStateWithLifecycle()
@ -194,6 +195,7 @@ fun ConnectionsScreen(
1 ->
ConnectingDeviceContent(
selectedDevice = selectedDevice,
persistedDeviceName = persistedDeviceName,
bleDevices = bleDevices,
discoveredTcpDevices = discoveredTcpDevices,
recentTcpDevices = recentTcpDevices,
@ -327,6 +329,7 @@ private fun ConnectedDeviceContent(
@Composable
private fun ConnectingDeviceContent(
selectedDevice: String,
persistedDeviceName: String?,
bleDevices: List<DeviceListEntry>,
discoveredTcpDevices: List<DeviceListEntry>,
recentTcpDevices: List<DeviceListEntry>,
@ -339,7 +342,9 @@ private fun ConnectingDeviceContent(
?: recentTcpDevices.find { it.fullAddress == selectedDevice }
?: usbDevices.find { it.fullAddress == selectedDevice }
val name = selectedEntry?.name ?: stringResource(Res.string.unknown_device)
// Use the entry name if found in scan lists, otherwise fall back to the persisted name
// from the last successful selection, and only show "Unknown Device" as a last resort.
val name = selectedEntry?.name ?: persistedDeviceName ?: stringResource(Res.string.unknown_device)
val address = selectedEntry?.address ?: selectedDevice
TitledCard(title = stringResource(Res.string.connected_device)) {

View file

@ -27,6 +27,7 @@ import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
import org.meshtastic.core.datastore.RecentAddressesDataSource
import org.meshtastic.core.repository.RadioInterfaceService
import org.meshtastic.core.repository.RadioPrefs
import org.meshtastic.core.testing.FakeRadioController
import org.meshtastic.core.testing.FakeServiceRepository
import org.meshtastic.feature.connections.model.DiscoveredDevices
@ -43,6 +44,7 @@ class ScannerViewModelTest {
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 getDiscoveredDevicesUseCase: GetDiscoveredDevicesUseCase = mock(MockMode.autofill)
private val bleScanner: org.meshtastic.core.ble.BleScanner = mock(MockMode.autofill)
@ -66,6 +68,7 @@ class ScannerViewModelTest {
serviceRepository = serviceRepository,
radioController = radioController,
radioInterfaceService = radioInterfaceService,
radioPrefs = radioPrefs,
recentAddressesDataSource = recentAddressesDataSource,
getDiscoveredDevicesUseCase = getDiscoveredDevicesUseCase,
dispatchers =