From 8ce17defb766dc72fa3e3d6d8f6581391df6c9b1 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Wed, 25 Mar 2026 09:24:42 -0500 Subject: [PATCH] refactor: remove demoscenario and enhance BLE connection stability (#4914) Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com> --- .../core/network/radio/BleRadioInterface.kt | 85 ++++++---- .../core/prefs/radio/RadioPrefsImpl.kt | 16 ++ .../core/repository/AppPreferences.kt | 5 + .../service/SharedRadioInterfaceService.kt | 49 ++++-- .../org/meshtastic/desktop/DemoScenario.kt | 147 ------------------ .../meshtastic/desktop/DemoScenarioTest.kt | 43 ----- .../connections/AndroidScannerViewModel.kt | 3 + .../feature/connections/ScannerViewModel.kt | 10 ++ .../connections/ui/ConnectionsScreen.kt | 7 +- .../connections/ScannerViewModelTest.kt | 3 + 10 files changed, 130 insertions(+), 238 deletions(-) delete mode 100644 desktop/src/main/kotlin/org/meshtastic/desktop/DemoScenario.kt delete mode 100644 desktop/src/test/kotlin/org/meshtastic/desktop/DemoScenarioTest.kt diff --git a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BleRadioInterface.kt b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BleRadioInterface.kt index 65950848a..a4783a844 100644 --- a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BleRadioInterface.kt +++ b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BleRadioInterface.kt @@ -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) } } diff --git a/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/radio/RadioPrefsImpl.kt b/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/radio/RadioPrefsImpl.kt index d551f9333..cecd9a67a 100644 --- a/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/radio/RadioPrefsImpl.kt +++ b/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/radio/RadioPrefsImpl.kt @@ -42,6 +42,9 @@ class RadioPrefsImpl( override val devAddr: StateFlow = dataStore.data.map { it[KEY_DEV_ADDR_PREF] }.stateIn(scope, SharingStarted.Eagerly, null) + override val devName: StateFlow = + 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") } } diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AppPreferences.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AppPreferences.kt index d4b2f680f..e7f2974f6 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AppPreferences.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AppPreferences.kt @@ -185,7 +185,12 @@ interface MapTileProviderPrefs { interface RadioPrefs { val devAddr: StateFlow + /** The persisted user-visible name of the connected device (e.g. "Meshtastic_1234"). */ + val devName: StateFlow + fun setDevAddr(address: String?) + + fun setDevName(name: String?) } fun RadioPrefs.isBle() = devAddr.value?.startsWith("x") == true diff --git a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/SharedRadioInterfaceService.kt b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/SharedRadioInterfaceService.kt index 8a5e23787..d08fb5a8a 100644 --- a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/SharedRadioInterfaceService.kt +++ b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/SharedRadioInterfaceService.kt @@ -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") diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/DemoScenario.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/DemoScenario.kt deleted file mode 100644 index 217cdf258..000000000 --- a/desktop/src/main/kotlin/org/meshtastic/desktop/DemoScenario.kt +++ /dev/null @@ -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 . - */ -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 -} diff --git a/desktop/src/test/kotlin/org/meshtastic/desktop/DemoScenarioTest.kt b/desktop/src/test/kotlin/org/meshtastic/desktop/DemoScenarioTest.kt deleted file mode 100644 index 6aea461fe..000000000 --- a/desktop/src/test/kotlin/org/meshtastic/desktop/DemoScenarioTest.kt +++ /dev/null @@ -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 . - */ -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") - } -} diff --git a/feature/connections/src/androidMain/kotlin/org/meshtastic/feature/connections/AndroidScannerViewModel.kt b/feature/connections/src/androidMain/kotlin/org/meshtastic/feature/connections/AndroidScannerViewModel.kt index 7beb38aaa..979d4892a 100644 --- a/feature/connections/src/androidMain/kotlin/org/meshtastic/feature/connections/AndroidScannerViewModel.kt +++ b/feature/connections/src/androidMain/kotlin/org/meshtastic/feature/connections/AndroidScannerViewModel.kt @@ -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, diff --git a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ScannerViewModel.kt b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ScannerViewModel.kt index f6500b522..2ad96fd26 100644 --- a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ScannerViewModel.kt +++ b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ScannerViewModel.kt @@ -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 = radioInterfaceService.currentDeviceAddressFlow + /** The persisted device name from the last selection, for use as a UI fallback. */ + val persistedDeviceName: StateFlow = radioPrefs.devName + val selectedNotNullFlow: StateFlow = 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) } } diff --git a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/ConnectionsScreen.kt b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/ConnectionsScreen.kt index 5590843ad..2c7f661eb 100644 --- a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/ConnectionsScreen.kt +++ b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/ConnectionsScreen.kt @@ -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, discoveredTcpDevices: List, recentTcpDevices: List, @@ -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)) { 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 baf38afe2..6f291d68a 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 @@ -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 =