refactor(model): parse the selected device address once (#7328)

This commit is contained in:
James Rich authored and GitHub committed 2026-09-24 09:03:19 +00:00
1 parent 75c191b21c
commit 6708cc5148
15 files changed
+247 -28

No files matched your search

@@ -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())
}
@@ -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")
@@ -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 <https://www.gnu.org/licenses/>.
*/
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) }
}
}
}
@@ -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 <https://www.gnu.org/licenses/>.
*/
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())
}
}
@@ -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 {
@@ -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()
@@ -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
@@ -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
@@ -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<String>): 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()
}
@@ -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.
@@ -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
@@ -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
}
@@ -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
@@ -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
@@ -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<FirmwareRelease?> = when (type) {
FirmwareReleaseType.STABLE -> stableRelease