From 6708cc5148f7502646abbe24e28f8cf2ee36a46d Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Thu, 24 Sep 2026 09:03:19 +0000 Subject: [PATCH] refactor(model): parse the selected device address once (#7328) --- .../kotlin/org/meshtastic/app/MainActivity.kt | 5 +- .../main/kotlin/org/meshtastic/app/ui/Main.kt | 6 +- .../meshtastic/core/model/DeviceAddress.kt | 56 ++++++++++++++ .../core/model/DeviceAddressTest.kt | 75 +++++++++++++++++++ .../core/repository/AppPreferences.kt | 14 ++-- .../core/repository/AppPreferencesTest.kt | 14 ++++ .../core/service/BootReconnectDecision.kt | 7 +- .../core/service/MeshServiceOrchestrator.kt | 3 +- .../service/SharedRadioInterfaceService.kt | 9 ++- .../core/service/BootReconnectDecisionTest.kt | 8 ++ ...SharedRadioInterfaceServiceLivenessTest.kt | 22 ++++++ .../core/ui/viewmodel/ConnectionsViewModel.kt | 21 ++++-- .../ui/viewmodel/ConnectionsViewModelTest.kt | 26 +++++++ .../kotlin/org/meshtastic/desktop/Main.kt | 3 +- .../firmware/FirmwareUpdateViewModel.kt | 6 +- 15 files changed, 247 insertions(+), 28 deletions(-) create mode 100644 core/model/src/commonMain/kotlin/org/meshtastic/core/model/DeviceAddress.kt create mode 100644 core/model/src/commonTest/kotlin/org/meshtastic/core/model/DeviceAddressTest.kt diff --git a/androidApp/src/main/kotlin/org/meshtastic/app/MainActivity.kt b/androidApp/src/main/kotlin/org/meshtastic/app/MainActivity.kt index d9b393676d..4f599e76bb 100644 --- a/androidApp/src/main/kotlin/org/meshtastic/app/MainActivity.kt +++ b/androidApp/src/main/kotlin/org/meshtastic/app/MainActivity.kt @@ -60,6 +60,7 @@ import org.meshtastic.app.node.component.InlineMap import org.meshtastic.app.node.metrics.getTracerouteMapOverlayInsets import org.meshtastic.app.ui.MainScreen import org.meshtastic.core.barcode.rememberBarcodeScanner +import org.meshtastic.core.model.DeviceAddress import org.meshtastic.core.navigation.DEEP_LINK_BASE_URI import org.meshtastic.core.network.repository.UsbRepository import org.meshtastic.core.nfc.NfcEmulatorEffect @@ -101,7 +102,6 @@ import org.meshtastic.core.ui.util.brandHighlightOrNull import org.meshtastic.core.ui.util.brandPalette import org.meshtastic.core.ui.util.showToast import org.meshtastic.core.ui.viewmodel.UIViewModel -import org.meshtastic.feature.connections.NO_DEVICE_SELECTED import org.meshtastic.feature.intro.AppIntroductionScreen import org.meshtastic.feature.intro.IntroViewModel import org.meshtastic.feature.map.MapScreen @@ -408,8 +408,7 @@ class MainActivity : AppCompatActivity() { } private fun showConnectionsPageIfNoDeviceSelected() { - val selectedAddress = model.currentDeviceAddressFlow.value - if (!selectedAddress.isNullOrBlank() && selectedAddress != NO_DEVICE_SELECTED) return + if (DeviceAddress.parse(model.currentDeviceAddressFlow.value) != null) return handleMeshtasticUri("$DEEP_LINK_BASE_URI/connections".toUri()) } diff --git a/androidApp/src/main/kotlin/org/meshtastic/app/ui/Main.kt b/androidApp/src/main/kotlin/org/meshtastic/app/ui/Main.kt index a8a4f2aea7..d2f1b14383 100644 --- a/androidApp/src/main/kotlin/org/meshtastic/app/ui/Main.kt +++ b/androidApp/src/main/kotlin/org/meshtastic/app/ui/Main.kt @@ -34,6 +34,7 @@ import org.koin.compose.koinInject import org.koin.compose.viewmodel.koinViewModel import org.meshtastic.app.BuildConfig import org.meshtastic.core.model.ConnectionState +import org.meshtastic.core.model.DeviceAddress import org.meshtastic.core.model.service.LockdownState import org.meshtastic.core.navigation.NodesRoute import org.meshtastic.core.navigation.TopLevelDestination @@ -122,10 +123,7 @@ fun MainScreen() { } private fun initialRoute(deviceAddress: String?): NavKey = - if (deviceAddress.isNullOrSelectedNone()) TopLevelDestination.Connect.route else NodesRoute.Nodes - -/** True when no device address is persisted, or the address is the "none" sentinel (`"n"`). */ -private fun String?.isNullOrSelectedNone(): Boolean = isNullOrBlank() || this == "n" + if (DeviceAddress.parse(deviceAddress) == null) TopLevelDestination.Connect.route else NodesRoute.Nodes @Composable @Suppress("LongMethod", "CyclomaticComplexMethod") diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/DeviceAddress.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/DeviceAddress.kt new file mode 100644 index 0000000000..be7e19c466 --- /dev/null +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/DeviceAddress.kt @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.model + +import org.meshtastic.core.common.util.isValidDeviceAddress + +/** + * A selected-radio address, parsed from its stored form: one [InterfaceId] character followed by the transport's own + * identity for the device. + * + * [raw] is the persisted string and stays the key for the per-device database and every per-device preference, so it is + * never rewritten. The legacy `!` BLE prefix therefore keeps its `!` in [raw] and only reads as + * [InterfaceId.BLUETOOTH]. + */ +class DeviceAddress private constructor(val raw: String, val interfaceId: InterfaceId) { + + /** The address without its transport prefix: a BLE MAC, a `host:port`, a USB serial key. */ + val identity: String + get() = raw.substring(1) + + override fun equals(other: Any?): Boolean = other is DeviceAddress && other.raw == raw + + override fun hashCode(): Int = raw.hashCode() + + override fun toString(): String = "DeviceAddress($interfaceId)" + + companion object { + private const val LEGACY_BLUETOOTH_PREFIX = '!' + + /** + * Parses [raw], or returns `null` for no selection: `null`, blank, any no-device sentinel, or an unknown + * prefix. + */ + fun parse(raw: String?): DeviceAddress? { + if (raw == null || !isValidDeviceAddress(raw)) return null + val prefix = raw.first() + val interfaceId = + if (prefix == LEGACY_BLUETOOTH_PREFIX) InterfaceId.BLUETOOTH else InterfaceId.forIdChar(prefix) + return interfaceId?.takeUnless { it == InterfaceId.NOP }?.let { DeviceAddress(raw, it) } + } + } +} diff --git a/core/model/src/commonTest/kotlin/org/meshtastic/core/model/DeviceAddressTest.kt b/core/model/src/commonTest/kotlin/org/meshtastic/core/model/DeviceAddressTest.kt new file mode 100644 index 0000000000..37e08487fe --- /dev/null +++ b/core/model/src/commonTest/kotlin/org/meshtastic/core/model/DeviceAddressTest.kt @@ -0,0 +1,75 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.model + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals +import kotlin.test.assertNull + +class DeviceAddressTest { + + @Test + fun `every no-device spelling parses to no selection`() { + listOf(null, "", " ", "n", "N", "null", ".n", "default", "DEFAULT").forEach { raw -> + assertNull(DeviceAddress.parse(raw), "expected no selection for '$raw'") + } + } + + @Test + fun `an unknown prefix parses to no selection`() { + assertNull(DeviceAddress.parse("z12:34")) + } + + @Test + fun `each prefix names its transport and keeps the rest as identity`() { + val cases = + mapOf( + "x11:22:33:44:55:66" to InterfaceId.BLUETOOTH, + "s1027:29987:0" to InterfaceId.SERIAL, + "t192.168.1.20:4403" to InterfaceId.TCP, + "m" to InterfaceId.MOCK, + "r" to InterfaceId.REPLAY, + ) + cases.forEach { (raw, interfaceId) -> + val address = checkNotNull(DeviceAddress.parse(raw)) + assertEquals(interfaceId, address.interfaceId) + assertEquals(raw, address.raw) + assertEquals(raw.drop(1), address.identity) + } + } + + @Test + fun `the legacy bang prefix reads as BLE and keeps its stored form`() { + val address = checkNotNull(DeviceAddress.parse("!11:22:33:44:55:66")) + + assertEquals(InterfaceId.BLUETOOTH, address.interfaceId) + assertEquals("!11:22:33:44:55:66", address.raw) + assertEquals("11:22:33:44:55:66", address.identity) + } + + @Test + fun `addresses are equal only when their stored form is`() { + assertEquals(DeviceAddress.parse("xAA"), DeviceAddress.parse("xAA")) + assertNotEquals(DeviceAddress.parse("xAA"), DeviceAddress.parse("!AA")) + } + + @Test + fun `toString does not reveal the identity`() { + assertEquals("DeviceAddress(BLUETOOTH)", DeviceAddress.parse("x11:22:33:44:55:66").toString()) + } +} 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 f97acd2a2b..4c315129f5 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 @@ -17,7 +17,9 @@ package org.meshtastic.core.repository import kotlinx.coroutines.flow.StateFlow +import org.meshtastic.core.model.DeviceAddress import org.meshtastic.core.model.DeviceType +import org.meshtastic.core.model.InterfaceId /** Reactive interface for analytics-related preferences. */ interface AnalyticsPrefs { @@ -342,15 +344,17 @@ interface RadioPrefs { fun setDevName(name: String?) } -fun RadioPrefs.isBle() = devAddr.value?.startsWith("x") == true +/** The saved selection, parsed; `null` when nothing is selected. */ +val RadioPrefs.selectedDevice: DeviceAddress? + get() = DeviceAddress.parse(devAddr.value) -fun RadioPrefs.isSerial() = devAddr.value?.startsWith("s") == true +fun RadioPrefs.isBle() = selectedDevice?.interfaceId == InterfaceId.BLUETOOTH -fun RadioPrefs.isMock() = devAddr.value?.startsWith("m") == true +fun RadioPrefs.isSerial() = selectedDevice?.interfaceId == InterfaceId.SERIAL -fun RadioPrefs.isTcp() = devAddr.value?.startsWith("t") == true +fun RadioPrefs.isMock() = selectedDevice?.interfaceId == InterfaceId.MOCK -fun RadioPrefs.isNoop() = devAddr.value?.startsWith("n") == true +fun RadioPrefs.isTcp() = selectedDevice?.interfaceId == InterfaceId.TCP /** Reactive interface for mesh connection settings. */ interface MeshPrefs { diff --git a/core/repository/src/commonTest/kotlin/org/meshtastic/core/repository/AppPreferencesTest.kt b/core/repository/src/commonTest/kotlin/org/meshtastic/core/repository/AppPreferencesTest.kt index d1eb7b2e92..a97fb30a99 100644 --- a/core/repository/src/commonTest/kotlin/org/meshtastic/core/repository/AppPreferencesTest.kt +++ b/core/repository/src/commonTest/kotlin/org/meshtastic/core/repository/AppPreferencesTest.kt @@ -30,6 +30,20 @@ class AppPreferencesTest { assertTrue(prefs.isBle()) } + @Test + fun `RadioPrefs isBle returns true for the legacy bang prefix`() { + val prefs = FakeRadioPrefs() + prefs.setDevAddr("!12345678") + assertTrue(prefs.isBle()) + } + + @Test + fun `RadioPrefs reports no transport for the none sentinel`() { + val prefs = FakeRadioPrefs() + prefs.setDevAddr("n") + assertFalse(prefs.isBle() || prefs.isSerial() || prefs.isTcp() || prefs.isMock()) + } + @Test fun `RadioPrefs isBle returns false for other prefix`() { val prefs = FakeRadioPrefs() diff --git a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/BootReconnectDecision.kt b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/BootReconnectDecision.kt index ce9b4716c8..dbc2d95391 100644 --- a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/BootReconnectDecision.kt +++ b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/BootReconnectDecision.kt @@ -17,9 +17,8 @@ package org.meshtastic.core.service import org.meshtastic.core.common.util.isValidDeviceAddress - -/** Prefix the app uses for a persisted BLE device address. TCP is `t`, USB is `s`. */ -private const val BLE_ADDRESS_PREFIX = 'x' +import org.meshtastic.core.model.DeviceAddress +import org.meshtastic.core.model.InterfaceId /** What a boot / package-replaced broadcast should do about the previously selected device. */ enum class BootReconnectDecision { @@ -53,7 +52,7 @@ enum class BootReconnectDecision { fun bootReconnectDecision(address: String?, hasBluetoothPermission: Boolean): BootReconnectDecision = when { !isValidDeviceAddress(address) -> BootReconnectDecision.NO_DEVICE - address?.firstOrNull() == BLE_ADDRESS_PREFIX && !hasBluetoothPermission -> + DeviceAddress.parse(address)?.interfaceId == InterfaceId.BLUETOOTH && !hasBluetoothPermission -> BootReconnectDecision.BLE_PERMISSION_MISSING else -> BootReconnectDecision.START_SERVICE diff --git a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/MeshServiceOrchestrator.kt b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/MeshServiceOrchestrator.kt index 5a6d225896..f6c68021e0 100644 --- a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/MeshServiceOrchestrator.kt +++ b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/MeshServiceOrchestrator.kt @@ -34,6 +34,7 @@ import org.meshtastic.core.common.util.isValidDeviceAddress import org.meshtastic.core.common.util.safeCatching import org.meshtastic.core.common.util.safeCatchingAll import org.meshtastic.core.di.CoroutineDispatchers +import org.meshtastic.core.model.DeviceAddress import org.meshtastic.core.model.InterfaceId import org.meshtastic.core.repository.MeshConnectionManager import org.meshtastic.core.repository.MeshMessageProcessor @@ -112,7 +113,7 @@ class MeshServiceOrchestrator( // later successful manual connect with no recovery layer for the life of the process. The OS enforces the // permission at the socket, so a genuinely local connect fails exactly as it would have; the difference is // the user now has an explanation and the fix in hand. - if (address?.firstOrNull() == InterfaceId.TCP.id && !localNetworkAccess.isGranted()) { + if (DeviceAddress.parse(address)?.interfaceId == InterfaceId.TCP && !localNetworkAccess.isGranted()) { Logger.w { "Local network access not granted; the persisted TCP reconnect may time out" } serviceStateWriter.setErrorMessage( // Same shape as ScannerViewModel's scan-failure messages: resource lookup can fail outside a 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 b2817151df..68c4a61b10 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 @@ -66,6 +66,7 @@ import org.meshtastic.core.common.util.nowMillis import org.meshtastic.core.common.util.safeCatching import org.meshtastic.core.di.CoroutineDispatchers import org.meshtastic.core.model.ConnectionState +import org.meshtastic.core.model.DeviceAddress import org.meshtastic.core.model.DeviceType import org.meshtastic.core.model.InterfaceId import org.meshtastic.core.model.MeshActivity @@ -113,7 +114,11 @@ private data class UsbRecoveryTriggerState( ) private fun selectedSerialPresence(address: String?, keys: Set): SelectedSerialPresence { - val key = address?.takeIf { it.firstOrNull() == InterfaceId.SERIAL.id }?.drop(1)?.takeIf { it.isNotEmpty() } + val key = + DeviceAddress.parse(address) + ?.takeIf { it.interfaceId == InterfaceId.SERIAL } + ?.identity + ?.takeIf { it.isNotEmpty() } return SelectedSerialPresence(key = key, present = key != null && key in keys) } @@ -929,7 +934,7 @@ class SharedRadioInterfaceService( } throw publicationFailure } - runningTransportId = address.firstOrNull()?.let { InterfaceId.forIdChar(it) } + runningTransportId = DeviceAddress.parse(address)?.interfaceId isStarted = true startHeartbeat() } diff --git a/core/service/src/commonTest/kotlin/org/meshtastic/core/service/BootReconnectDecisionTest.kt b/core/service/src/commonTest/kotlin/org/meshtastic/core/service/BootReconnectDecisionTest.kt index ac3ec89497..f1682cc71e 100644 --- a/core/service/src/commonTest/kotlin/org/meshtastic/core/service/BootReconnectDecisionTest.kt +++ b/core/service/src/commonTest/kotlin/org/meshtastic/core/service/BootReconnectDecisionTest.kt @@ -53,6 +53,14 @@ class BootReconnectDecisionTest { ) } + @Test + fun `a legacy bang BLE device is not reconnected without the Bluetooth permission`() { + assertEquals( + BootReconnectDecision.BLE_PERMISSION_MISSING, + bootReconnectDecision("!AA:BB:CC:DD:EE:FF", hasBluetoothPermission = false), + ) + } + @Test fun `TCP and USB devices reconnect regardless of the Bluetooth permission`() { // Gating these would break reconnection for users who have deliberately never granted Bluetooth access. diff --git a/core/service/src/commonTest/kotlin/org/meshtastic/core/service/SharedRadioInterfaceServiceLivenessTest.kt b/core/service/src/commonTest/kotlin/org/meshtastic/core/service/SharedRadioInterfaceServiceLivenessTest.kt index f1177fa62c..25090abaa2 100644 --- a/core/service/src/commonTest/kotlin/org/meshtastic/core/service/SharedRadioInterfaceServiceLivenessTest.kt +++ b/core/service/src/commonTest/kotlin/org/meshtastic/core/service/SharedRadioInterfaceServiceLivenessTest.kt @@ -647,6 +647,28 @@ class SharedRadioInterfaceServiceLivenessTest { } } + @Test + fun `BLE liveness timeout restarts a transport saved with the legacy bang prefix`() = runTest(testDispatcher) { + clock = 0L + val service = createConnectedService("!AA:BB:CC:DD:EE:FF") + try { + clock = 65_000L + service.checkLiveness() + testDispatcher.scheduler.runCurrent() + advanceTimeBy(1_000L) + + assertEquals( + 2, + createdTransports.size, + "A silent legacy BLE link should be restarted like any BLE link", + ) + assertTrue(createdTransports.first().closeCalled, "Old transport must be closed") + } finally { + service.disconnect() + advanceTimeBy(1_000L) + } + } + @Test fun `BLE liveness restart contains factory failure and a later connect can retry`() = runTest(testDispatcher) { clock = 0L diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/ConnectionsViewModel.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/ConnectionsViewModel.kt index 8ebf7e34a0..4ff7890392 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/ConnectionsViewModel.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/ConnectionsViewModel.kt @@ -35,10 +35,12 @@ import org.koin.core.annotation.KoinViewModel import org.meshtastic.core.common.util.nowMillis import org.meshtastic.core.database.entity.FirmwareRelease import org.meshtastic.core.model.ConnectionState +import org.meshtastic.core.model.DeviceAddress import org.meshtastic.core.model.DeviceHardware import org.meshtastic.core.model.FirmwareUpdateNotice import org.meshtastic.core.model.FirmwareUpdateNoticePolicy import org.meshtastic.core.model.FirmwareUpdateTransport +import org.meshtastic.core.model.InterfaceId import org.meshtastic.core.model.MyNodeInfo import org.meshtastic.core.model.Node import org.meshtastic.core.model.util.TimeConstants @@ -245,7 +247,8 @@ class ConnectionsViewModel( combine(firmwareUpdateInputs, localHardware) { inputs, hardware -> val state = inputs.connectionState if (state !is ConnectionState.Connected) return@combine null - val transport = inputs.address?.firstOrNull()?.toFirmwareUpdateTransport() ?: return@combine null + val transport = + DeviceAddress.parse(inputs.address)?.interfaceId?.toFirmwareUpdateTransport() ?: return@combine null val stableRelease = inputs.stableRelease ?: return@combine null val deviceHardware = hardware ?: return@combine null FirmwareUpdateCandidate( @@ -353,9 +356,15 @@ private data class FirmwareUpdateCandidate( val transport: FirmwareUpdateTransport, ) -private fun Char.toFirmwareUpdateTransport(): FirmwareUpdateTransport? = when (this) { - 'x' -> FirmwareUpdateTransport.Bluetooth - 's' -> FirmwareUpdateTransport.Serial - 't' -> FirmwareUpdateTransport.Tcp - else -> null +private fun InterfaceId.toFirmwareUpdateTransport(): FirmwareUpdateTransport? = when (this) { + InterfaceId.BLUETOOTH -> FirmwareUpdateTransport.Bluetooth + + InterfaceId.SERIAL -> FirmwareUpdateTransport.Serial + + InterfaceId.TCP -> FirmwareUpdateTransport.Tcp + + InterfaceId.MOCK, + InterfaceId.NOP, + InterfaceId.REPLAY, + -> null } diff --git a/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/viewmodel/ConnectionsViewModelTest.kt b/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/viewmodel/ConnectionsViewModelTest.kt index dee52c9a89..2f521d3e9c 100644 --- a/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/viewmodel/ConnectionsViewModelTest.kt +++ b/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/viewmodel/ConnectionsViewModelTest.kt @@ -370,6 +370,32 @@ class ConnectionsViewModelTest { assertEquals("meshtastic:///firmware/update", dispatchedNotifications.single().deepLinkUri) } + @Test + fun `a device saved with the legacy bang prefix gets the firmware update notice`() = runTest { + val target = "tbeam" + deviceHardwareRepository.setHardware( + hwModel = HardwareModel.TBEAM.value, + target = target, + device = DeviceHardware(architecture = "esp32", platformioTarget = target), + ) + nodeRepository.setMyId("!local") + nodeRepository.setMyNodeInfo(TestDataFactory.createMyNodeInfo(firmwareVersion = "2.7.0", pioEnv = target)) + nodeRepository.setOurNode( + org.meshtastic.core.model.Node( + num = 1, + user = User.Builder().also { wb -> wb.hw_model = HardwareModel.TBEAM }.build(), + ), + ) + radioPrefs.setDevAddr("!AA:BB:CC:DD:EE:FF") + firmwareReleaseRepository.setManifestTargets("v2.8.0", setOf(target)) + firmwareReleaseRepository.setStableRelease(FirmwareRelease(id = "v2.8.0")) + serviceRepository.setConnectionState(ConnectionState.Connected) + + advanceUntilIdle() + + assertNotNull(viewModel.firmwareUpdateNotice.value) + } + @Test fun `does not persist firmware notification dedupe when scheduling is unavailable`() = runTest { val hardwareModel = HardwareModel.TBEAM.value diff --git a/desktopApp/src/main/kotlin/org/meshtastic/desktop/Main.kt b/desktopApp/src/main/kotlin/org/meshtastic/desktop/Main.kt index 84681d7ace..c23c87d83e 100644 --- a/desktopApp/src/main/kotlin/org/meshtastic/desktop/Main.kt +++ b/desktopApp/src/main/kotlin/org/meshtastic/desktop/Main.kt @@ -80,6 +80,7 @@ import org.meshtastic.core.common.BuildConfigProvider import org.meshtastic.core.common.log.InMemoryLogBuffer import org.meshtastic.core.common.util.CommonUri import org.meshtastic.core.database.desktopDataDir +import org.meshtastic.core.model.DeviceAddress import org.meshtastic.core.navigation.MultiBackstack import org.meshtastic.core.navigation.SettingsRoute import org.meshtastic.core.navigation.TopLevelDestination @@ -396,7 +397,7 @@ private fun ApplicationScope.MeshtasticWindow( val multiBackstack = rememberMultiBackstack( // Land on Connections for first-run / no-device-selected; otherwise on Nodes. - if (uiViewModel.currentDeviceAddressFlow.value.let { it.isNullOrBlank() || it == "n" }) { + if (DeviceAddress.parse(uiViewModel.currentDeviceAddressFlow.value) == null) { TopLevelDestination.Connect.route } else { TopLevelDestination.Nodes.route diff --git a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt index 55925d957b..b26f3eec38 100644 --- a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt +++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt @@ -52,6 +52,7 @@ import org.meshtastic.core.datastore.BootloaderWarningDataSource import org.meshtastic.core.datastore.FirmwareRecoveryDataSource import org.meshtastic.core.datastore.model.PendingFirmwareRecovery import org.meshtastic.core.model.ConnectionState +import org.meshtastic.core.model.DeviceAddress import org.meshtastic.core.model.DeviceHardware import org.meshtastic.core.model.InterfaceId import org.meshtastic.core.model.MyNodeInfo @@ -67,6 +68,7 @@ import org.meshtastic.core.repository.RadioPrefs import org.meshtastic.core.repository.isBle import org.meshtastic.core.repository.isSerial import org.meshtastic.core.repository.isTcp +import org.meshtastic.core.repository.selectedDevice import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.UiText import org.meshtastic.core.resources.firmware_maintenance_cdc_unblock_failed @@ -274,7 +276,7 @@ class FirmwareUpdateViewModel( _state.value = FirmwareUpdateState.Checking safeCatching { val ourNode = nodeRepository.myNodeInfo.value - val address = radioPrefs.devAddr.value?.drop(1) + val address = radioPrefs.selectedDevice?.identity if (address == null || ourNode == null) { // Not connected: offer to re-flash a device stranded in bootloader mode if we saved a // recovery record when its (now-interrupted) update was triggered. Otherwise, no device. @@ -1329,7 +1331,7 @@ private fun isValidBluetoothAddress(address: String?): Boolean = address != null && BLUETOOTH_ADDRESS_REGEX.matches(address) private fun isBluetoothInterfaceAddress(address: String): Boolean = - address.startsWith(InterfaceId.BLUETOOTH.id) || address.startsWith("!") + DeviceAddress.parse(address)?.interfaceId == InterfaceId.BLUETOOTH private fun FirmwareReleaseRepository.getReleaseFlow(type: FirmwareReleaseType): Flow = when (type) { FirmwareReleaseType.STABLE -> stableRelease