diff --git a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/database/DatabaseManager.kt b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/database/DatabaseManager.kt
index adf0b93dc..f2ef17e5c 100644
--- a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/database/DatabaseManager.kt
+++ b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/database/DatabaseManager.kt
@@ -33,12 +33,17 @@ interface DatabaseManager {
suspend fun switchActiveDatabase(address: String?)
/**
- * Associates the currently-active connection with the physical node [nodeNum] learned from the radio handshake, so
- * the same node reached over a different transport (BLE / TCP / USB) shares one database. The first transport to
- * report a node claims the active DB for it; a later transport folds its own data in and switches to the claimed
- * DB. Idempotent once a node/transport pair has been unified.
+ * Associates the currently-active connection with the physical device learned from the radio handshake, so the same
+ * device reached over a different transport (BLE / TCP / USB) shares one database. The first transport to report
+ * the device claims the active DB for it; a later transport folds its own data in and switches to the claimed DB.
+ * Idempotent once a device/transport pair has been unified.
+ *
+ * [deviceId] is the factory-burned hardware id from MyNodeInfo (null/blank when the platform or firmware doesn't
+ * report one, or when lockdown zeroes it). It is the preferred claim key because it survives the node-number
+ * changes firmware 2.8 introduced (`my_node_num = crc32(public_key)` renumbers on upgrade, erase, and re-key);
+ * [nodeNum] remains the fallback claim for hardware without a device id and for claims made by older app versions.
*/
- suspend fun associateNode(nodeNum: Int)
+ suspend fun associateDevice(nodeNum: Int, deviceId: String?)
/** Returns true if a database exists for the given device address. */
fun hasDatabaseFor(address: String?): Boolean
diff --git a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/Crc32.kt b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/Crc32.kt
new file mode 100644
index 000000000..64d2a9d04
--- /dev/null
+++ b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/Crc32.kt
@@ -0,0 +1,52 @@
+/*
+ * 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.common.util
+
+import okio.ByteString
+
+/**
+ * CRC-32 (IEEE 802.3 / zlib): reflected polynomial 0xEDB88320, initial value and final XOR 0xFFFFFFFF.
+ *
+ * Matches the firmware's `crc32Buffer` (ErriezCRC32), which firmware 2.8+ uses to derive the node number from the
+ * device public key: `my_node_num = crc32(config.security.public_key)` (NodeDB::createNewIdentity). Lets the app
+ * recognize a pubkey-derived ("canonical") node number when a node reappears under a new num after a firmware upgrade.
+ */
+object Crc32 {
+ private const val POLYNOMIAL: UInt = 0xEDB88320u
+ private const val INITIAL: UInt = 0xFFFFFFFFu
+ private const val BITS_PER_BYTE = 8
+ private const val BYTE_MASK: UInt = 0xFFu
+ private const val TABLE_SIZE = 256
+
+ private val table =
+ UIntArray(TABLE_SIZE) { index ->
+ var crc = index.toUInt()
+ repeat(BITS_PER_BYTE) { crc = if (crc and 1u != 0u) (crc shr 1) xor POLYNOMIAL else crc shr 1 }
+ crc
+ }
+
+ fun compute(bytes: ByteString): UInt {
+ var crc = INITIAL
+ for (i in 0 until bytes.size) {
+ val byte = bytes[i].toUInt() and BYTE_MASK
+ crc = (crc shr BITS_PER_BYTE) xor table[((crc xor byte) and BYTE_MASK).toInt()]
+ }
+ return crc.inv()
+ }
+}
+
+fun ByteString.crc32(): UInt = Crc32.compute(this)
diff --git a/core/common/src/commonTest/kotlin/org/meshtastic/core/common/util/Crc32Test.kt b/core/common/src/commonTest/kotlin/org/meshtastic/core/common/util/Crc32Test.kt
new file mode 100644
index 000000000..6458f10f7
--- /dev/null
+++ b/core/common/src/commonTest/kotlin/org/meshtastic/core/common/util/Crc32Test.kt
@@ -0,0 +1,47 @@
+/*
+ * 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.common.util
+
+import okio.ByteString
+import okio.ByteString.Companion.encodeUtf8
+import okio.ByteString.Companion.toByteString
+import kotlin.test.Test
+import kotlin.test.assertEquals
+
+class Crc32Test {
+
+ @Test
+ fun matchesStandardCheckValue() {
+ assertEquals(0xCBF43926u, "123456789".encodeUtf8().crc32())
+ }
+
+ @Test
+ fun matchesZlibForAsciiText() {
+ assertEquals(0x414FA339u, "The quick brown fox jumps over the lazy dog".encodeUtf8().crc32())
+ }
+
+ @Test
+ fun matchesZlibForKeySizedBuffer() {
+ val key = ByteArray(32) { it.toByte() }.toByteString()
+ assertEquals(0x91267E8Au, key.crc32())
+ }
+
+ @Test
+ fun emptyInputYieldsZero() {
+ assertEquals(0u, ByteString.EMPTY.crc32())
+ }
+}
diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/datasource/NodeInfoWriteDataSource.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/datasource/NodeInfoWriteDataSource.kt
index 4e8380983..2506d38fa 100644
--- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/datasource/NodeInfoWriteDataSource.kt
+++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/datasource/NodeInfoWriteDataSource.kt
@@ -23,7 +23,8 @@ import org.meshtastic.core.database.entity.NodeEntity
interface NodeInfoWriteDataSource {
suspend fun upsert(node: NodeEntity)
- suspend fun installConfig(mi: MyNodeEntity, nodes: List)
+ /** @return node numbers removed by identity migration (see `NodeInfoDao.installConfig`). */
+ suspend fun installConfig(mi: MyNodeEntity, nodes: List): List
suspend fun clearNodeDB(preserveFavorites: Boolean)
diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/datasource/SwitchingNodeInfoWriteDataSource.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/datasource/SwitchingNodeInfoWriteDataSource.kt
index 61be88500..4a85eb70a 100644
--- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/datasource/SwitchingNodeInfoWriteDataSource.kt
+++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/datasource/SwitchingNodeInfoWriteDataSource.kt
@@ -34,9 +34,14 @@ class SwitchingNodeInfoWriteDataSource(
withContext(dispatchers.io) { dbManager.withDb { it.nodeInfoDao().upsert(node) } }
}
- override suspend fun installConfig(mi: MyNodeEntity, nodes: List) {
- withContext(dispatchers.io) { dbManager.withDb { it.nodeInfoDao().installConfig(mi, nodes) } }
- }
+ override suspend fun installConfig(mi: MyNodeEntity, nodes: List): List =
+ withContext(dispatchers.io) {
+ // Throw rather than no-op when no database is available: the config-flow manager treats a
+ // successful install as "node DB ready → Connected", and its catch runs the transport
+ // recovery path — silently skipping the install would fake a healthy connection.
+ dbManager.withDb { it.nodeInfoDao().installConfig(mi, nodes) }
+ ?: error("Node DB install skipped: no active database")
+ }
override suspend fun clearNodeDB(preserveFavorites: Boolean) {
withContext(dispatchers.io) { dbManager.withDb { it.nodeInfoDao().clearNodeInfo(preserveFavorites) } }
diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConfigFlowManagerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConfigFlowManagerImpl.kt
index a0cd86a3d..c029ac717 100644
--- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConfigFlowManagerImpl.kt
+++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConfigFlowManagerImpl.kt
@@ -206,7 +206,13 @@ class MeshConfigFlowManagerImpl(
scope.handledLaunch {
try {
- nodeRepository.installConfig(info, entities)
+ val removedNums = nodeRepository.installConfig(info, entities)
+ if (removedNums.isNotEmpty()) {
+ // Identity migration dropped stale rows (e.g. the device renumbered after a firmware
+ // 2.8 upgrade); evict them from the in-memory index so lookups can't resurrect them.
+ Logger.i { "Config install migrated ${removedNums.size} stale node identit(y/ies)" }
+ removedNums.forEach(nodeManager::removeByNodenum)
+ }
} catch (e: CancellationException) {
throw e
} catch (@Suppress("TooGenericExceptionCaught") e: Exception) {
@@ -238,6 +244,11 @@ class MeshConfigFlowManagerImpl(
// Transition to Stage 1, discarding any stale data from a prior interrupted handshake.
handshakeState.value = HandshakeState.ReceivingConfig(rawMyNodeInfo = myInfo)
+ // Device id before node num: RadioControllerImpl gates its DB association on a non-null num,
+ // so ordering this way guarantees the association never fires with a stale device id.
+ // Hex, not utf8: device_id is raw hardware bytes, and a lossy decode could collapse two
+ // distinct devices into the same id.
+ nodeManager.setMyDeviceId(myInfo.device_id.hex().takeIf { it.isNotBlank() })
nodeManager.setMyNodeNum(myInfo.my_node_num)
nodeManager.setFirmwareEdition(myInfo.firmware_edition)
applyEventFirmwareNotificationDefaults(myInfo.firmware_edition)
@@ -335,7 +346,8 @@ class MeshConfigFlowManagerImpl(
hasWifi = metadata?.hasWifi == true,
channelUtilization = 0f,
airUtilTx = 0f,
- deviceId = device_id.utf8(),
+ // Hex, not utf8: device_id is raw hardware bytes (see setMyDeviceId above).
+ deviceId = device_id.hex().ifEmpty { null },
pioEnv = pio_env.ifEmpty { null },
)
}
diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/NodeManagerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/NodeManagerImpl.kt
index 0b24b3004..ced03a830 100644
--- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/NodeManagerImpl.kt
+++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/NodeManagerImpl.kt
@@ -124,6 +124,12 @@ class NodeManagerImpl(
myNodeNum.value = num
}
+ override val myDeviceId = MutableStateFlow(null)
+
+ override fun setMyDeviceId(id: String?) {
+ myDeviceId.value = id
+ }
+
override val firmwareEdition = MutableStateFlow(null)
override fun setFirmwareEdition(edition: FirmwareEdition?) {
@@ -149,6 +155,7 @@ class NodeManagerImpl(
isNodeDbReady.value = false
allowNodeDbWrites.value = false
myNodeNum.value = null
+ myDeviceId.value = null
firmwareEdition.value = null
}
diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/NodeRepositoryImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/NodeRepositoryImpl.kt
index d3d883a83..34e56c214 100644
--- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/NodeRepositoryImpl.kt
+++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/NodeRepositoryImpl.kt
@@ -198,7 +198,7 @@ class NodeRepositoryImpl(
withContext(dispatchers.io) { nodeInfoWriteDataSource.upsert(node.toEntity()) }
/** Installs initial configuration data (local info and remote nodes) into the database. */
- override suspend fun installConfig(mi: MyNodeInfo, nodes: List) = withContext(dispatchers.io) {
+ override suspend fun installConfig(mi: MyNodeInfo, nodes: List): List = withContext(dispatchers.io) {
nodeInfoWriteDataSource.installConfig(mi.toEntity(), nodes.map { it.toEntity() })
}
diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshConfigFlowManagerImplTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshConfigFlowManagerImplTest.kt
index 6dd40e464..070e55381 100644
--- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshConfigFlowManagerImplTest.kt
+++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshConfigFlowManagerImplTest.kt
@@ -35,6 +35,7 @@ import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import okio.ByteString.Companion.encodeUtf8
+import okio.ByteString.Companion.toByteString
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.repository.CommandSender
import org.meshtastic.core.repository.HandshakeConstants
@@ -102,6 +103,9 @@ class MeshConfigFlowManagerImplTest {
every { nodeManager.myNodeNum } returns MutableStateFlow(null)
every { notificationPrefs.nodeEventsAutoDisabledForEvent } returns MutableStateFlow(false)
every { notificationPrefs.nodeEventsEnabled } returns MutableStateFlow(true)
+ // autofill returns null for List, which would NPE the non-null return; individual
+ // tests override this stub where they exercise install failures or migration results.
+ everySuspend { nodeRepository.installConfig(any(), any()) } returns emptyList()
manager =
MeshConfigFlowManagerImpl(
@@ -128,6 +132,25 @@ class MeshConfigFlowManagerImplTest {
verify { nodeManager.setMyNodeNum(myNodeNum) }
}
+ @Test
+ fun `handleMyInfo hex-encodes raw device_id bytes losslessly`() = testScope.runTest {
+ // device_id is raw hardware bytes, not text: a lossy utf8 decode would collapse distinct
+ // ids into the same replacement-character string. 0xFF bytes are invalid UTF-8 on purpose.
+ val rawId = byteArrayOf(0xFF.toByte(), 0x00, 0xA1.toByte(), 0xB2.toByte()).toByteString()
+ manager.handleMyInfo(protoMyNodeInfo.copy(device_id = rawId))
+ advanceUntilIdle()
+
+ verify { nodeManager.setMyDeviceId("ff00a1b2") }
+ }
+
+ @Test
+ fun `handleMyInfo reports an absent device_id as null`() = testScope.runTest {
+ manager.handleMyInfo(protoMyNodeInfo.copy(device_id = okio.ByteString.EMPTY))
+ advanceUntilIdle()
+
+ verify { nodeManager.setMyDeviceId(null) }
+ }
+
@Test
fun `handleMyInfo clears persisted radio config`() = testScope.runTest {
manager.handleMyInfo(protoMyNodeInfo)
@@ -737,7 +760,11 @@ class MeshConfigFlowManagerImplTest {
// async DB install entry point are observed. The order is the invariant under test.
val callOrder = mutableListOf()
every { connectionManager.onHandshakeComplete() } calls { callOrder.add("handshakeComplete") }
- everySuspend { nodeRepository.installConfig(any(), any()) } calls { callOrder.add("installConfig") }
+ everySuspend { nodeRepository.installConfig(any(), any()) } calls
+ {
+ callOrder.add("installConfig")
+ emptyList()
+ }
manager.handleMyInfo(protoMyNodeInfo)
advanceUntilIdle()
diff --git a/core/database/src/androidHostTest/kotlin/org/meshtastic/core/database/dao/NodeIdentityMigrationDaoTest.kt b/core/database/src/androidHostTest/kotlin/org/meshtastic/core/database/dao/NodeIdentityMigrationDaoTest.kt
new file mode 100644
index 000000000..64c3ccabb
--- /dev/null
+++ b/core/database/src/androidHostTest/kotlin/org/meshtastic/core/database/dao/NodeIdentityMigrationDaoTest.kt
@@ -0,0 +1,25 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.core.database.dao
+
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import org.junit.runner.RunWith
+import org.robolectric.annotation.Config
+
+@RunWith(AndroidJUnit4::class)
+@Config(sdk = [34])
+class NodeIdentityMigrationDaoTest : CommonNodeIdentityMigrationDaoTest()
diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/DatabaseConstants.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/DatabaseConstants.kt
index 589bceb77..bcc498495 100644
--- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/DatabaseConstants.kt
+++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/DatabaseConstants.kt
@@ -32,8 +32,11 @@ object DatabaseConstants {
const val LEGACY_DB_CLEANED_KEY: String = "legacy_db_cleaned"
- // Cross-transport unification: map a node / transport address to the DB file that node's data lives in.
- // Keys are dynamic (suffixed with the node num / normalized address), mirroring the `db_last_used:` pattern.
+ // Cross-transport unification: map a device / node / transport address to the DB file its data lives in.
+ // Keys are dynamic (suffixed with the hex device id / node num / normalized address), mirroring the
+ // `db_last_used:` pattern. The device-id key is preferred; the node-num key is the legacy fallback because
+ // firmware 2.8 made node numbers unstable (renumbered on upgrade/erase/re-key).
+ const val DEVICE_DB_FOR_PREFIX: String = "device_db_for:"
const val NODE_DB_FOR_PREFIX: String = "node_db_for:"
const val ADDR_DB_FOR_PREFIX: String = "addr_db_for:"
diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/DatabaseManager.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/DatabaseManager.kt
index ff17c479a..46c040b6f 100644
--- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/DatabaseManager.kt
+++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/DatabaseManager.kt
@@ -74,8 +74,6 @@ open class DatabaseManager(
private fun addrDbKey(address: String?) =
stringPreferencesKey("${DatabaseConstants.ADDR_DB_FOR_PREFIX}${normalizeAddress(address)}")
- private fun nodeDbKey(nodeNum: Int) = stringPreferencesKey("${DatabaseConstants.NODE_DB_FOR_PREFIX}$nodeNum")
-
private var backfillJob: Job? = null
@Volatile private var hasDelayedFirstDeviceBackfill = false
@@ -114,7 +112,7 @@ open class DatabaseManager(
/**
* Name of the currently active database. Tracked explicitly rather than recomputed from the address, because
- * cross-transport aliasing ([associateNode]) decouples the two: a secondary transport's address maps to the DB
+ * cross-transport aliasing ([associateDevice]) decouples the two: a secondary transport's address maps to the DB
* claimed by the first transport, which `buildDbName(address)` would never produce. Written under [mutex].
*/
@Volatile private var currentDbName: String = DatabaseConstants.DEFAULT_DB_NAME
@@ -136,7 +134,7 @@ open class DatabaseManager(
* Resolves the DB name to use for [address], honoring a cross-transport alias when one exists. A secondary
* transport (e.g. TCP) that has been unified with a node points at the DB the first transport (e.g. BLE) claimed;
* without an alias this falls back to the address-hashed name — today's default — for a first-time or primary
- * connection. See [associateNode].
+ * connection. See [associateDevice].
*/
private suspend fun resolveDbName(address: String?): String {
val fallback = buildDbName(address)
@@ -195,24 +193,41 @@ open class DatabaseManager(
}
@Suppress("TooGenericExceptionCaught")
- override suspend fun associateNode(nodeNum: Int) {
+ override suspend fun associateDevice(nodeNum: Int, deviceId: String?) {
mutex.withLock {
val sourceName = currentDbName
// Never claim or merge into the sentinel "no device" DB.
if (sourceName == DatabaseConstants.DEFAULT_DB_NAME) return@withLock
- val claimed = datastore.data.first()[nodeDbKey(nodeNum)]
+ // The device-id claim is the durable one (node numbers renumber under firmware 2.8); the
+ // node-num claim stays as the fallback for hardware without a device id, for lockdown
+ // sessions (device_id zeroed), and for claims written by older app versions. Writes always
+ // refresh both keys so either lookup path resolves on the next connection.
+ val deviceKey = validDeviceIdOrNull(deviceId)?.let(::deviceDbPrefKey)
+ val nodeKey = nodeDbPrefKey(nodeNum)
+ val prefs = datastore.data.first()
+ val claimed = resolveDbClaim(prefs, deviceKey, nodeKey)
+ suspend fun writeClaims(dbName: String) = datastore.edit {
+ deviceKey?.let { key -> it[key] = dbName }
+ it[nodeKey] = dbName
+ }
+
when {
claimed == null -> {
- // First transport to learn this node: its current DB becomes the node's canonical DB.
+ // First transport to learn this device: its current DB becomes the device's canonical DB.
// No address alias is needed — a primary connection already resolves to this DB via buildDbName.
- datastore.edit { it[nodeDbKey(nodeNum)] = sourceName }
+ writeClaims(sourceName)
Logger.i { "Claimed ${anonymizeDbName(sourceName)} as canonical DB for node $nodeNum" }
}
- claimed == sourceName -> Unit
-
- // Already unified — nothing to do.
+ claimed == sourceName -> {
+ // Already unified — just backfill/refresh any claim key that is missing or stale
+ // (e.g. the first connect after this device renumbered, or after an app update
+ // introduced device-id claims).
+ if ((deviceKey != null && prefs[deviceKey] != sourceName) || prefs[nodeKey] != sourceName) {
+ writeClaims(sourceName)
+ }
+ }
else -> {
// Secondary transport reached an already-known node: fold this DB into the canonical one,
@@ -243,6 +258,9 @@ open class DatabaseManager(
}
markLastUsed(claimed)
+ // Refresh both claim keys (migrates a legacy nodeNum-only claim forward to the
+ // device-id key) and alias this transport's address to the canonical DB.
+ writeClaims(claimed)
datastore.edit { it[addrDbKey(_currentAddress.value)] = claimed }
Logger.i {
"Unified ${anonymizeDbName(sourceName)} into ${anonymizeDbName(claimed)} for node $nodeNum"
diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/DatabaseMerger.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/DatabaseMerger.kt
index 630b7c142..41b107dd8 100644
--- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/DatabaseMerger.kt
+++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/DatabaseMerger.kt
@@ -22,7 +22,7 @@ import co.touchlab.kermit.Logger
/**
* Folds the contents of one device database ([source]) into another ([dest]) when both turn out to belong to the same
- * physical node reached over different transports (BLE / TCP / USB). Called once, by [DatabaseManager.associateNode],
+ * physical node reached over different transports (BLE / TCP / USB). Called once, by [DatabaseManager.associateDevice],
* the first time a secondary transport learns a `myNodeNum` that another transport already claimed.
*
* Every per-device table is unified so switching transport is seamless: messages (+FTS), reactions, contact mute/read
@@ -39,7 +39,7 @@ object DatabaseMerger {
suspend fun merge(source: MeshtasticDatabase, dest: MeshtasticDatabase) {
// All destination writes run in a single transaction so a crash or exception mid-merge rolls
// back cleanly instead of leaving `dest` half-merged. This also makes a retried merge safe: if
- // associateNode re-runs (e.g. a crash before the address alias is persisted), the rolled-back
+ // associateDevice re-runs (e.g. a crash before the address alias is persisted), the rolled-back
// partial writes never happened, so the fresh-id packet/discovery re-inserts can't duplicate.
// Reads from `source` use its own connection pool and don't participate in this transaction.
var packets = 0
diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/DeviceIdentity.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/DeviceIdentity.kt
new file mode 100644
index 000000000..09be38e6d
--- /dev/null
+++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/DeviceIdentity.kt
@@ -0,0 +1,57 @@
+/*
+ * 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.database
+
+import androidx.datastore.preferences.core.Preferences
+import androidx.datastore.preferences.core.stringPreferencesKey
+import okio.ByteString.Companion.encodeUtf8
+import org.meshtastic.core.database.entity.MyNodeEntity
+
+/** Ingestion hex-encodes the 16-byte `device_id`; require that shape so lossy legacy values can't be compared. */
+private val HEX_DEVICE_ID = Regex("[0-9a-fA-F]{16,64}")
+
+/**
+ * A device id usable for hardware-identity comparison; null when absent, blank, the legacy placeholder, or not in the
+ * hex form current ingestion produces (older app versions persisted a lossy utf8 decode of the raw bytes — those values
+ * can collide across devices, so they are treated as absent rather than compared). The id is the factory-burned silicon
+ * identifier from `MyNodeInfo.device_id` — stable across firmware upgrades, erases, and key changes — but not reported
+ * by all hardware (e.g. classic ESP32) and deliberately zeroed for unauthenticated clients in lockdown mode, so callers
+ * must always tolerate null.
+ */
+internal fun validDeviceIdOrNull(id: String?): String? =
+ id?.takeIf { it != MyNodeEntity.DEVICE_ID_UNKNOWN && it.matches(HEX_DEVICE_ID) }
+
+/** Datastore key mapping a node number to its canonical DB. Legacy: node numbers renumber under firmware 2.8. */
+internal fun nodeDbPrefKey(nodeNum: Int): Preferences.Key =
+ stringPreferencesKey("${DatabaseConstants.NODE_DB_FOR_PREFIX}$nodeNum")
+
+/**
+ * Datastore key mapping a factory-burned device id to its canonical DB. The id is hex-encoded: it originates as raw
+ * hardware bytes decoded lossily into a string, so normalize to printable key material.
+ */
+internal fun deviceDbPrefKey(deviceId: String): Preferences.Key =
+ stringPreferencesKey("${DatabaseConstants.DEVICE_DB_FOR_PREFIX}${deviceId.encodeUtf8().hex()}")
+
+/**
+ * Resolves which canonical DB the connected device already claimed: the hardware-stable device-id claim wins, the
+ * node-number claim is the fallback for hardware without a device id and for claims written by older app versions.
+ */
+internal fun resolveDbClaim(
+ prefs: Preferences,
+ deviceKey: Preferences.Key?,
+ nodeKey: Preferences.Key,
+): String? = deviceKey?.let { prefs[it] } ?: prefs[nodeKey]
diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/NodeInfoDao.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/NodeInfoDao.kt
index 45ca07f48..bac2ca4e4 100644
--- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/NodeInfoDao.kt
+++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/NodeInfoDao.kt
@@ -23,10 +23,13 @@ import androidx.room3.Transaction
import androidx.room3.Upsert
import kotlinx.coroutines.flow.Flow
import okio.ByteString
+import org.meshtastic.core.common.util.crc32
import org.meshtastic.core.database.entity.MetadataEntity
import org.meshtastic.core.database.entity.MyNodeEntity
import org.meshtastic.core.database.entity.NodeEntity
import org.meshtastic.core.database.entity.NodeWithRelations
+import org.meshtastic.core.database.validDeviceIdOrNull
+import org.meshtastic.core.model.NodeAddress
import org.meshtastic.core.model.mergePowerChannelLabel
import org.meshtastic.proto.HardwareModel
@@ -74,7 +77,12 @@ interface NodeInfoDao {
}
}
- /** Validates a new node before it is inserted into the database. */
+ /**
+ * Validates a new node before it is inserted into the database. This is the mesh-time (single upsert) path: anyone
+ * can broadcast a NodeInfo, so a known key arriving under a different num is conservatively mapped back to the
+ * existing identity — even when the num is the canonical crc32-derived one. Renumber migration happens only in
+ * [installConfig], where the entries come from the connected device's own vetted node DB over the local link.
+ */
private suspend fun handleNewNodeUpsertValidation(newNode: NodeEntity): NodeEntity {
// Check if the new node's public key (if present and not empty)
// is already claimed by another existing node.
@@ -90,6 +98,41 @@ interface NodeInfoDao {
return newNode
}
+ /**
+ * True when this node's num is the canonical pubkey-derived number introduced by firmware 2.8 (`my_node_num =
+ * crc32(public_key)`, see NodeDB::createNewIdentity). A canonical num proves the num/key pairing was not chosen
+ * independently of the key, so a same-key-new-num sighting is a renumber rather than a spoof. Only consulted for
+ * entries streamed by the connected device during a config install (trusted local link), never for mesh packets.
+ */
+ private fun NodeEntity.hasCanonicalNum(): Boolean {
+ val key = publicKey ?: user.public_key
+ return key.size == KEY_SIZE && key != NodeEntity.ERROR_BYTE_STRING && key.crc32().toInt() == num
+ }
+
+ /**
+ * Moves a node's identity from the [from] row to the incoming [to] entity after a legitimate renumber (the local
+ * device reconciled by [migrateLocalNodeIdentity], or a mesh peer whose new num is its canonical pubkey-derived
+ * number). Carries the app-local state the mesh doesn't broadcast, drops the stale row, and re-keys the DM thread
+ * so the conversation follows the node. The caller upserts [to] afterwards.
+ */
+ private suspend fun migrateNodeIdentity(from: NodeEntity, to: NodeEntity) {
+ if (to.notes.isBlank()) to.notes = from.notes
+ if (to.powerChannelLabels.isEmpty()) to.powerChannelLabels = from.powerChannelLabels
+ to.manuallyVerified = to.manuallyVerified || from.manuallyVerified
+ to.isFavorite = to.isFavorite || from.isFavorite
+ to.isIgnored = to.isIgnored || from.isIgnored
+ to.isMuted = to.isMuted || from.isMuted
+ deleteNode(from.num)
+ deleteMetadata(from.num)
+ val oldId = from.user.id.ifEmpty { NodeAddress.numToDefaultId(from.num) }
+ val newId = to.user.id.ifEmpty { NodeAddress.numToDefaultId(to.num) }
+ if (oldId != newId) {
+ remapDmContactKey(oldId, newId)
+ remapDmContactSettings(oldId, newId)
+ deleteDmContactSettings(oldId)
+ }
+ }
+
/**
* Resolves the public key for an existing node during an update.
*
@@ -141,9 +184,18 @@ interface NodeInfoDao {
* updating telemetry and status fields from the incoming packet.
* 2. **Update**: If it's a normal update, we validate the public key using [resolvePublicKey] to prevent conflicts
* or accidental key wiping, and then update the node.
+ *
+ * [trustIncomingKey] skips the mismatch check and accepts a valid incoming key as-is. Set only for the local node
+ * during a config install: the connected device is authoritative for its own key over the local link, and an
+ * erase-and-reflash legitimately re-keys it (a mismatch there would otherwise poison the local node with
+ * [NodeEntity.ERROR_BYTE_STRING] and break PKI traffic until app data is cleared).
*/
@Suppress("CyclomaticComplexMethod", "MagicNumber")
- private fun handleExistingNodeUpsertValidation(existingNode: NodeEntity, incomingNode: NodeEntity): NodeEntity {
+ private fun handleExistingNodeUpsertValidation(
+ existingNode: NodeEntity,
+ incomingNode: NodeEntity,
+ trustIncomingKey: Boolean = false,
+ ): NodeEntity {
val resolvedNotes = incomingNode.notes.ifBlank { existingNode.notes }
val resolvedPowerChannelLabels = incomingNode.powerChannelLabels.ifEmpty { existingNode.powerChannelLabels }
@@ -163,7 +215,12 @@ interface NodeInfoDao {
)
}
- val resolvedKey = resolvePublicKey(existingNode, incomingNode)
+ val resolvedKey =
+ if (trustIncomingKey && (incomingNode.publicKey?.size ?: 0) == KEY_SIZE) {
+ incomingNode.publicKey
+ } else {
+ resolvePublicKey(existingNode, incomingNode)
+ }
return incomingNode.copy(
user = incomingNode.user.copy(public_key = resolvedKey ?: ByteString.EMPTY),
@@ -337,9 +394,17 @@ interface NodeInfoDao {
/**
* Batch version of [getVerifiedNodeForUpsert]. Pre-fetches all existing nodes and public-key conflicts in two
* queries instead of N individual queries, then processes each node in memory.
+ *
+ * [selfNum] is the connected device's node number during a config install; its key updates are trusted (see
+ * [handleExistingNodeUpsertValidation]) and a key conflict against it always migrates. Node numbers whose rows were
+ * migrated away are appended to [removedNums].
*/
@Suppress("NestedBlockDepth")
- private suspend fun getVerifiedNodesForUpsert(incomingNodes: List): List {
+ private suspend fun getVerifiedNodesForUpsert(
+ incomingNodes: List,
+ selfNum: Int? = null,
+ removedNums: MutableList = mutableListOf(),
+ ): List {
// Prepare all incoming nodes (populate denormalized fields)
incomingNodes.forEach { node ->
node.publicKey = node.user.public_key
@@ -366,7 +431,7 @@ interface NodeInfoDao {
for (incoming in incomingNodes) {
val existing = existingNodesMap[incoming.num]
if (existing != null) {
- result.add(handleExistingNodeUpsertValidation(existing, incoming))
+ result.add(handleExistingNodeUpsertValidation(existing, incoming, incoming.num == selfNum))
} else {
newNodes.add(incoming)
}
@@ -388,7 +453,16 @@ interface NodeInfoDao {
if ((newNode.publicKey?.size ?: 0) > 0) {
val conflicting = pkConflicts[newNode.publicKey]
if (conflicting != null && conflicting.num != newNode.num) {
- result.add(conflicting)
+ // Same key under a different num. Migrate when this is the connected device itself
+ // (device-authoritative over the local link) or the num is the canonical crc32(key)
+ // number (firmware 2.8 renumber); otherwise keep the existing identity.
+ if (newNode.num == selfNum || newNode.hasCanonicalNum()) {
+ migrateNodeIdentity(from = conflicting, to = newNode)
+ removedNums += conflicting.num
+ result.add(newNode)
+ } else {
+ result.add(conflicting)
+ }
} else {
result.add(newNode)
}
@@ -397,14 +471,94 @@ interface NodeInfoDao {
}
}
+ // Drop batch entries for identities that were migrated away — a device mid-transition can
+ // still list the old num alongside the new one, and re-inserting it would resurrect the ghost.
+ result.removeAll { it.num in removedNums }
return result
}
+ @Query("SELECT * FROM my_node LIMIT 1")
+ suspend fun getMyNodeEntity(): MyNodeEntity?
+
+ /** Re-scopes stored messages from one owning node number to another (see `Packet.myNodeNum`). */
+ @Query("UPDATE packet SET myNodeNum = :newNum WHERE myNodeNum = :oldNum")
+ suspend fun remapPacketOwner(oldNum: Int, newNum: Int)
+
+ /** OR IGNORE: myNodeNum is part of the reactions PK; drop the rare duplicate instead of aborting. */
+ @Query("UPDATE OR IGNORE reactions SET myNodeNum = :newNum WHERE myNodeNum = :oldNum")
+ suspend fun remapReactionOwner(oldNum: Int, newNum: Int)
+
+ @Query("DELETE FROM reactions WHERE myNodeNum = :oldNum")
+ suspend fun deleteReactionsForOwner(oldNum: Int)
+
+ /** Contact keys are `"$channel$userId"`, so a suffix match rewrites just the DM threads of one node. */
+ @Query("UPDATE packet SET contact_key = replace(contact_key, :oldId, :newId) WHERE contact_key LIKE '%' || :oldId")
+ suspend fun remapDmContactKey(oldId: String, newId: String)
+
+ @Query(
+ "UPDATE OR IGNORE contact_settings SET contact_key = replace(contact_key, :oldId, :newId) " +
+ "WHERE contact_key LIKE '%' || :oldId",
+ )
+ suspend fun remapDmContactSettings(oldId: String, newId: String)
+
+ @Query("DELETE FROM contact_settings WHERE contact_key LIKE '%' || :oldId")
+ suspend fun deleteDmContactSettings(oldId: String)
+
+ /**
+ * Reconciles a change of the connected device's node number before a config install replaces `my_node`.
+ *
+ * This database was selected by transport address, so a new node number arriving at the same address is, by
+ * default, the same physical device under a new identity: a firmware 2.8 upgrade (`my_node_num` became
+ * `crc32(public_key)` instead of the MAC-derived number), an erase-and-reflash, a manual key regeneration or import
+ * — all of which renumber the device. Message history and app-local node state follow the device.
+ *
+ * The public key is deliberately NOT used to establish continuity here: keys can be regenerated or imported by the
+ * user, and under firmware 2.8 every key change is also a renumber. The only veto is a hardware one — when both
+ * sessions reported a factory-burned device id ([MyNodeEntity.deviceId]) and they differ, this is provably
+ * different hardware (e.g. a reused TCP address), so the old history stays scoped under the old number instead of
+ * leaking to the new device.
+ */
+ private suspend fun migrateLocalNodeIdentity(previous: MyNodeEntity, mi: MyNodeEntity, incomingSelf: NodeEntity?) {
+ val oldNum = previous.myNodeNum
+ val newNum = mi.myNodeNum
+ val oldDeviceId = validDeviceIdOrNull(previous.deviceId)
+ val newDeviceId = validDeviceIdOrNull(mi.deviceId)
+ val differentHardware = oldDeviceId != null && newDeviceId != null && oldDeviceId != newDeviceId
+
+ val oldSelf = getNodeByNum(oldNum)?.node
+ if (!differentHardware) {
+ remapPacketOwner(oldNum, newNum)
+ remapReactionOwner(oldNum, newNum)
+ deleteReactionsForOwner(oldNum)
+ }
+ if (!differentHardware && oldSelf != null && incomingSelf != null) {
+ migrateNodeIdentity(from = oldSelf, to = incomingSelf)
+ } else {
+ // Nothing to carry over — just drop the stale self row so it can't shadow the new identity.
+ deleteNode(oldNum)
+ deleteMetadata(oldNum)
+ }
+ }
+
+ /**
+ * Installs the config download from the connected device, reconciling any node-number change of the device itself
+ * (firmware 2.8 renumber, erase-and-reflash, manual re-key) before the new `my_node` row lands.
+ *
+ * @return node numbers whose rows were removed by identity migration, so callers can evict them from in-memory
+ * caches.
+ */
@Transaction
- suspend fun installConfig(mi: MyNodeEntity, nodes: List) {
+ suspend fun installConfig(mi: MyNodeEntity, nodes: List): List {
+ val removedNums = mutableListOf()
+ val previous = getMyNodeEntity()
+ if (previous != null && previous.myNodeNum != mi.myNodeNum) {
+ migrateLocalNodeIdentity(previous, mi, nodes.find { it.num == mi.myNodeNum })
+ removedNums += previous.myNodeNum
+ }
clearMyNodeInfo()
setMyNodeInfo(mi)
- putAll(getVerifiedNodesForUpsert(nodes))
+ putAll(getVerifiedNodesForUpsert(nodes, selfNum = mi.myNodeNum, removedNums = removedNums))
+ return removedNums
}
/**
diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/MyNodeEntity.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/MyNodeEntity.kt
index a55e2232f..28caf4645 100644
--- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/MyNodeEntity.kt
+++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/MyNodeEntity.kt
@@ -33,9 +33,14 @@ open class MyNodeEntity(
val minAppVersion: Int,
val maxChannels: Int,
val hasWifi: Boolean,
- val deviceId: String? = "unknown",
+ val deviceId: String? = DEVICE_ID_UNKNOWN,
val pioEnv: String? = null,
) {
+ companion object {
+ /** Placeholder for hardware that doesn't report a factory-burned device id. */
+ const val DEVICE_ID_UNKNOWN = "unknown"
+ }
+
/** A human readable description of the software/hardware version */
val firmwareString: String
get() = "$model $firmwareVersion"
diff --git a/core/database/src/commonTest/kotlin/org/meshtastic/core/database/DeviceIdentityTest.kt b/core/database/src/commonTest/kotlin/org/meshtastic/core/database/DeviceIdentityTest.kt
new file mode 100644
index 000000000..fcf394e71
--- /dev/null
+++ b/core/database/src/commonTest/kotlin/org/meshtastic/core/database/DeviceIdentityTest.kt
@@ -0,0 +1,85 @@
+/*
+ * 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.database
+
+import androidx.datastore.preferences.core.preferencesOf
+import org.meshtastic.core.database.entity.MyNodeEntity
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertNull
+import kotlin.test.assertTrue
+
+/** Covers the cross-transport DB-claim helpers behind [DatabaseManager.associateDevice]. */
+class DeviceIdentityTest {
+
+ @Test
+ fun validDeviceIdRejectsAbsentBlankPlaceholderAndNonHexForms() {
+ assertNull(validDeviceIdOrNull(null))
+ assertNull(validDeviceIdOrNull(""))
+ assertNull(validDeviceIdOrNull(" "))
+ assertNull(validDeviceIdOrNull(MyNodeEntity.DEVICE_ID_UNKNOWN))
+ // Legacy app versions persisted a lossy utf8 decode of the raw bytes — such values can
+ // collide across devices and must be treated as absent, not compared.
+ assertNull(validDeviceIdOrNull("��legacy id"))
+ assertNull(validDeviceIdOrNull("abcdef")) // too short to be a real 16-byte id
+ val hexId = "a1b2c3d4e5f60718a9b0c1d2e3f40516"
+ assertEquals(hexId, validDeviceIdOrNull(hexId))
+ }
+
+ @Test
+ fun deviceKeyIsDeterministicAndDistinctPerDevice() {
+ assertEquals(deviceDbPrefKey("hw-A"), deviceDbPrefKey("hw-A"))
+ assertTrue(deviceDbPrefKey("hw-A") != deviceDbPrefKey("hw-B"))
+ // Raw device-id bytes decode lossily to strings; the key must stay printable regardless.
+ val keyName = deviceDbPrefKey("\uFFFD id").name
+ assertTrue(keyName.startsWith(DatabaseConstants.DEVICE_DB_FOR_PREFIX))
+ assertTrue(keyName.drop(DatabaseConstants.DEVICE_DB_FOR_PREFIX.length).all { it.isLetterOrDigit() })
+ }
+
+ @Test
+ fun deviceClaimIsPreferredOverNodeClaim() {
+ val deviceKey = deviceDbPrefKey("hw-A")
+ val nodeKey = nodeDbPrefKey(100)
+ val prefs = preferencesOf(deviceKey to "db_device", nodeKey to "db_node")
+ assertEquals("db_device", resolveDbClaim(prefs, deviceKey, nodeKey))
+ }
+
+ @Test
+ fun nodeClaimIsTheFallback() {
+ val deviceKey = deviceDbPrefKey("hw-A")
+ val nodeKey = nodeDbPrefKey(100)
+ val legacyOnly = preferencesOf(nodeKey to "db_node")
+ // Device id available but only a legacy nodeNum claim exists (pre-device-id install).
+ assertEquals("db_node", resolveDbClaim(legacyOnly, deviceKey, nodeKey))
+ // No device id at all (classic ESP32, lockdown, old firmware).
+ assertEquals("db_node", resolveDbClaim(legacyOnly, deviceKey = null, nodeKey = nodeKey))
+ }
+
+ @Test
+ fun renumberedDeviceStillResolvesItsClaimViaDeviceKey() {
+ val deviceKey = deviceDbPrefKey("hw-A")
+ // Claims written before a firmware 2.8 renumber: device key + OLD node num key.
+ val prefs = preferencesOf(deviceKey to "db_claimed", nodeDbPrefKey(100) to "db_claimed")
+ // After the renumber the node key is new and unclaimed — the device key still resolves.
+ assertEquals("db_claimed", resolveDbClaim(prefs, deviceKey, nodeDbPrefKey(200)))
+ }
+
+ @Test
+ fun unclaimedDeviceResolvesNothing() {
+ assertNull(resolveDbClaim(preferencesOf(), deviceDbPrefKey("hw-A"), nodeDbPrefKey(100)))
+ }
+}
diff --git a/core/database/src/commonTest/kotlin/org/meshtastic/core/database/dao/CommonNodeIdentityMigrationDaoTest.kt b/core/database/src/commonTest/kotlin/org/meshtastic/core/database/dao/CommonNodeIdentityMigrationDaoTest.kt
new file mode 100644
index 000000000..17758b41c
--- /dev/null
+++ b/core/database/src/commonTest/kotlin/org/meshtastic/core/database/dao/CommonNodeIdentityMigrationDaoTest.kt
@@ -0,0 +1,283 @@
+/*
+ * 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.database.dao
+
+import kotlinx.coroutines.test.runTest
+import okio.ByteString
+import okio.ByteString.Companion.toByteString
+import org.meshtastic.core.common.util.crc32
+import org.meshtastic.core.database.MeshtasticDatabase
+import org.meshtastic.core.database.entity.MyNodeEntity
+import org.meshtastic.core.database.entity.NodeEntity
+import org.meshtastic.core.database.entity.Packet
+import org.meshtastic.core.database.entity.ReactionEntity
+import org.meshtastic.core.database.getInMemoryDatabaseBuilder
+import org.meshtastic.core.model.DataPacket
+import org.meshtastic.core.model.NodeAddress
+import org.meshtastic.proto.HardwareModel
+import org.meshtastic.proto.PortNum
+import org.meshtastic.proto.User
+import kotlin.test.AfterTest
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertNotNull
+import kotlin.test.assertNull
+import kotlin.test.assertTrue
+
+/**
+ * Covers the device-identity reconciliation in [NodeInfoDao.installConfig]: firmware 2.8 renumbers every device
+ * (`my_node_num = crc32(public_key)`), and erases/manual re-keys renumber again — the stale identity must be migrated
+ * (or vetoed for provably different hardware) instead of shadowing the new one.
+ *
+ * Abstract, per repo convention: the androidHostTest subclass supplies the Robolectric runner (Room's android target
+ * needs real `android.database` exception classes for its upsert fallback), and a jvmTest subclass runs it on the JVM
+ * target.
+ */
+abstract class CommonNodeIdentityMigrationDaoTest {
+
+ private lateinit var database: MeshtasticDatabase
+ private lateinit var dao: NodeInfoDao
+ private lateinit var packetDao: PacketDao
+
+ // Ingestion hex-encodes device_id, and validDeviceIdOrNull requires that shape.
+ private val deviceIdA = "a1b2c3d4e5f60718a9b0c1d2e3f40516"
+ private val deviceIdB = "ffeeddccbbaa99887766554433221100"
+
+ private val keyA = ByteArray(32) { 1 }.toByteString()
+ private val keyB = ByteArray(32) { 2 }.toByteString()
+
+ private val oldNum = 100
+ private val oldId = NodeAddress.numToDefaultId(oldNum)
+
+ private fun myNodeEntity(num: Int, deviceId: String? = MyNodeEntity.DEVICE_ID_UNKNOWN) = MyNodeEntity(
+ myNodeNum = num,
+ model = "TBEAM",
+ firmwareVersion = "2.8.0",
+ couldUpdate = false,
+ shouldUpdate = false,
+ currentPacketId = 1L,
+ messageTimeoutMsec = 300000,
+ minAppVersion = 1,
+ maxChannels = 8,
+ hasWifi = false,
+ deviceId = deviceId,
+ )
+
+ private fun nodeEntity(
+ num: Int,
+ key: ByteString,
+ longName: String = "Node $num",
+ notes: String = "",
+ isFavorite: Boolean = false,
+ ) = NodeEntity(
+ num = num,
+ user =
+ User(
+ id = NodeAddress.numToDefaultId(num),
+ long_name = longName,
+ short_name = longName.takeLast(4),
+ hw_model = HardwareModel.TBEAM,
+ public_key = key,
+ ),
+ notes = notes,
+ isFavorite = isFavorite,
+ lastHeard = 1000,
+ )
+
+ private fun packet(owner: Int, contactKey: String) = Packet(
+ uuid = 0L,
+ myNodeNum = owner,
+ port_num = PortNum.TEXT_MESSAGE_APP.value,
+ contact_key = contactKey,
+ received_time = 1000L,
+ read = true,
+ data =
+ DataPacket(
+ to = NodeAddress.ID_BROADCAST,
+ bytes = "hello".encodeToByteArray().toByteString(),
+ dataType = PortNum.TEXT_MESSAGE_APP.value,
+ ),
+ )
+
+ private suspend fun createDb(myNode: MyNodeEntity) {
+ database = getInMemoryDatabaseBuilder().build()
+ dao = database.nodeInfoDao()
+ packetDao = database.packetDao()
+ dao.setMyNodeInfo(myNode)
+ }
+
+ @AfterTest
+ fun closeDb() {
+ database.close()
+ }
+
+ @Test
+ fun renumberMigratesHistoryAndLocalState() = runTest {
+ createDb(myNodeEntity(oldNum))
+ dao.upsert(nodeEntity(oldNum, keyA, notes = "my notes", isFavorite = true))
+ packetDao.insert(packet(oldNum, contactKey = "0$oldId")) // note-to-self thread
+ packetDao.insert(packet(oldNum, contactKey = "1!deadbeef")) // DM with a peer
+ packetDao.insert(
+ ReactionEntity(myNodeNum = oldNum, replyId = 7, userId = "!deadbeef", emoji = "x", timestamp = 1L),
+ )
+
+ val newNum = 200
+ val newId = NodeAddress.numToDefaultId(newNum)
+ // Key changed too (manual regen / erase): continuity must not depend on the key.
+ val removed = dao.installConfig(myNodeEntity(newNum), listOf(nodeEntity(newNum, keyB)))
+
+ assertEquals(listOf(oldNum), removed)
+ assertEquals(newNum, dao.getMyNodeEntity()?.myNodeNum)
+ assertNull(dao.getNodeByNum(oldNum), "stale self row must be dropped")
+
+ val newSelf = assertNotNull(dao.getNodeByNum(newNum)).node
+ assertEquals("my notes", newSelf.notes, "app-local notes must follow the device")
+ assertTrue(newSelf.isFavorite, "favorite flag must follow the device")
+
+ val packets = packetDao.getAllPacketsSnapshot()
+ assertTrue(packets.isNotEmpty())
+ assertTrue(packets.all { it.myNodeNum == newNum }, "message history must be re-scoped to the new num")
+ assertTrue(packets.any { it.contact_key == "0$newId" }, "note-to-self thread must follow the new id")
+ assertTrue(packets.any { it.contact_key == "1!deadbeef" }, "peer DM threads must be untouched")
+ assertTrue(packetDao.getAllReactionsSnapshot().all { it.myNodeNum == newNum })
+ }
+
+ @Test
+ fun renumberDropsStaleSelfListedInSameBatch() = runTest {
+ createDb(myNodeEntity(oldNum))
+ dao.upsert(nodeEntity(oldNum, keyA))
+
+ val newNum = 200
+ // A device mid-transition can still stream its old identity alongside the new one.
+ val removed =
+ dao.installConfig(myNodeEntity(newNum), listOf(nodeEntity(oldNum, keyA), nodeEntity(newNum, keyA)))
+
+ assertEquals(listOf(oldNum), removed)
+ assertNull(dao.getNodeByNum(oldNum), "old identity in the same batch must not be resurrected")
+ assertNotNull(dao.getNodeByNum(newNum))
+ }
+
+ @Test
+ fun sameHardwareEraseMigratesAcrossKeyChange() = runTest {
+ createDb(myNodeEntity(oldNum, deviceId = deviceIdA))
+ dao.upsert(nodeEntity(oldNum, keyA, notes = "keep me"))
+ packetDao.insert(packet(oldNum, contactKey = "1!deadbeef"))
+
+ val newNum = 300
+ val removed = dao.installConfig(myNodeEntity(newNum, deviceId = deviceIdA), listOf(nodeEntity(newNum, keyB)))
+
+ assertEquals(listOf(oldNum), removed)
+ assertNull(dao.getNodeByNum(oldNum))
+ assertEquals("keep me", assertNotNull(dao.getNodeByNum(newNum)).node.notes)
+ assertTrue(packetDao.getAllPacketsSnapshot().all { it.myNodeNum == newNum })
+ }
+
+ @Test
+ fun differentHardwareVetoesHistoryMigration() = runTest {
+ createDb(myNodeEntity(oldNum, deviceId = deviceIdA))
+ dao.upsert(nodeEntity(oldNum, keyA, notes = "old device"))
+ packetDao.insert(packet(oldNum, contactKey = "1!deadbeef"))
+
+ val newNum = 300
+ val removed = dao.installConfig(myNodeEntity(newNum, deviceId = deviceIdB), listOf(nodeEntity(newNum, keyB)))
+
+ assertEquals(listOf(oldNum), removed)
+ assertNull(dao.getNodeByNum(oldNum), "stale self row is dropped even when history is not migrated")
+ val newSelf = assertNotNull(dao.getNodeByNum(newNum)).node
+ assertEquals("", newSelf.notes, "different hardware must not inherit app-local state")
+ assertTrue(
+ packetDao.getAllPacketsSnapshot().all { it.myNodeNum == oldNum },
+ "history of different hardware stays scoped under the old num",
+ )
+ }
+
+ @Test
+ fun sameNumInstallLeavesEverythingInPlace() = runTest {
+ createDb(myNodeEntity(oldNum))
+ dao.upsert(nodeEntity(oldNum, keyA, notes = "steady"))
+ packetDao.insert(packet(oldNum, contactKey = "1!deadbeef"))
+
+ val removed = dao.installConfig(myNodeEntity(oldNum), listOf(nodeEntity(oldNum, keyA)))
+
+ assertEquals(emptyList(), removed)
+ assertEquals("steady", assertNotNull(dao.getNodeByNum(oldNum)).node.notes)
+ assertTrue(packetDao.getAllPacketsSnapshot().all { it.myNodeNum == oldNum })
+ }
+
+ @Test
+ fun installTrustsDeviceReportedKeyChangeForSelf() = runTest {
+ createDb(myNodeEntity(oldNum))
+ dao.upsert(nodeEntity(oldNum, keyA))
+
+ // Same num, new key (2.7-line erase keeps the MAC-derived num). Without the install-time
+ // trust this would poison the local node's key to ERROR_BYTE_STRING and break PKI traffic.
+ dao.installConfig(myNodeEntity(oldNum), listOf(nodeEntity(oldNum, keyB)))
+
+ val self = assertNotNull(dao.getNodeByNum(oldNum)).node
+ assertEquals(keyB, self.publicKey)
+ }
+
+ @Test
+ fun canonicalNeighborRenumberMigratesInInstallBatch() = runTest {
+ createDb(myNodeEntity(oldNum))
+ val peerOldNum = 500
+ val peerOldId = NodeAddress.numToDefaultId(peerOldNum)
+ dao.upsert(nodeEntity(peerOldNum, keyB, notes = "peer note", isFavorite = true))
+ packetDao.insert(packet(oldNum, contactKey = "0$peerOldId"))
+
+ val peerNewNum = keyB.crc32().toInt() // the firmware 2.8 canonical num for this key
+ val peerNewId = NodeAddress.numToDefaultId(peerNewNum)
+ dao.installConfig(myNodeEntity(oldNum), listOf(nodeEntity(oldNum, keyA), nodeEntity(peerNewNum, keyB)))
+
+ assertNull(dao.getNodeByNum(peerOldNum), "peer's pre-2.8 row must be migrated away")
+ val migrated = assertNotNull(dao.getNodeByNum(peerNewNum)).node
+ assertEquals("peer note", migrated.notes)
+ assertTrue(migrated.isFavorite)
+ assertTrue(
+ packetDao.getAllPacketsSnapshot().any { it.contact_key == "0$peerNewId" },
+ "the DM thread must follow the peer's new id",
+ )
+ }
+
+ @Test
+ fun nonCanonicalKeyConflictKeepsExistingIdentity() = runTest {
+ createDb(myNodeEntity(oldNum))
+ val peerNum = 500
+ dao.upsert(nodeEntity(peerNum, keyB))
+
+ val impostorNum = 600 // not crc32(keyB): an arbitrary num claiming a known key
+ dao.installConfig(myNodeEntity(oldNum), listOf(nodeEntity(impostorNum, keyB)))
+
+ assertNotNull(dao.getNodeByNum(peerNum), "existing identity must be kept on a non-canonical conflict")
+ assertNull(dao.getNodeByNum(impostorNum), "non-canonical claimant must not be inserted")
+ }
+
+ @Test
+ fun singleUpsertNeverMigratesEvenForCanonicalNum() = runTest {
+ createDb(myNodeEntity(oldNum))
+ val peerOldNum = 500
+ dao.upsert(nodeEntity(peerOldNum, keyB, isFavorite = true))
+
+ // Mesh-time upserts are unauthenticated: even a canonical crc32(key) num must not migrate
+ // identity — that only happens through installConfig (trusted local link).
+ val peerNewNum = keyB.crc32().toInt()
+ dao.upsert(nodeEntity(peerNewNum, keyB))
+
+ assertNotNull(dao.getNodeByNum(peerOldNum), "existing identity must be kept on mesh-time conflicts")
+ assertNull(dao.getNodeByNum(peerNewNum), "mesh-time claimant must not be inserted")
+ }
+}
diff --git a/core/database/src/jvmTest/kotlin/org/meshtastic/core/database/dao/NodeIdentityMigrationDaoJvmTest.kt b/core/database/src/jvmTest/kotlin/org/meshtastic/core/database/dao/NodeIdentityMigrationDaoJvmTest.kt
new file mode 100644
index 000000000..62e2d59bf
--- /dev/null
+++ b/core/database/src/jvmTest/kotlin/org/meshtastic/core/database/dao/NodeIdentityMigrationDaoJvmTest.kt
@@ -0,0 +1,20 @@
+/*
+ * 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.database.dao
+
+/** JVM-target run of the common identity-migration suite (the abstract base has no runner of its own). */
+class NodeIdentityMigrationDaoJvmTest : CommonNodeIdentityMigrationDaoTest()
diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/NodeManager.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/NodeManager.kt
index 6bf214f23..833d2a5ef 100644
--- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/NodeManager.kt
+++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/NodeManager.kt
@@ -56,6 +56,16 @@ interface NodeManager : NodeIdLookup {
/** Sets the local node number. */
fun setMyNodeNum(num: Int?)
+ /**
+ * The connected device's factory-burned hardware id from MyNodeInfo, as a thread-safe [StateFlow]. Null when not
+ * connected or when the hardware/firmware doesn't report one. Unlike [myNodeNum] (which firmware 2.8 re-derives
+ * from the public key), this survives upgrades, erases, and key changes.
+ */
+ val myDeviceId: StateFlow
+
+ /** Sets the connected device's hardware id. */
+ fun setMyDeviceId(id: String?)
+
/** The firmware edition reported by the connected device. */
val firmwareEdition: StateFlow
diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/NodeRepository.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/NodeRepository.kt
index e29dd04c5..833f40836 100644
--- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/NodeRepository.kt
+++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/NodeRepository.kt
@@ -173,9 +173,14 @@ interface NodeRepository {
/**
* Installs initial configuration data (local info and remote nodes) into the database.
*
- * Used during the initial connection handshake.
+ * Used during the initial connection handshake. When the connected device's identity changed since the last session
+ * (firmware 2.8 derives the node number from the public key, and an erase-and-reflash mints new keys), the stale
+ * identity is migrated or removed as part of the install.
+ *
+ * @return node numbers whose rows were removed by that identity migration, so callers can evict them from in-memory
+ * caches.
*/
- suspend fun installConfig(mi: MyNodeInfo, nodes: List)
+ suspend fun installConfig(mi: MyNodeInfo, nodes: List): List
/**
* Persists hardware metadata for a node.
diff --git a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/RadioControllerImpl.kt b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/RadioControllerImpl.kt
index 2b7573760..c8187214d 100644
--- a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/RadioControllerImpl.kt
+++ b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/RadioControllerImpl.kt
@@ -91,14 +91,18 @@ class RadioControllerImpl(
QueryController by QueryControllerImpl(commandSender, nodeManager, uiPrefs) {
init {
- // Unify per-node databases across transports. When the handshake reports our node number, tell the
- // DatabaseManager to claim (or merge into) that node's canonical DB, so the same node reached over BLE, TCP,
- // or USB shares one stored history. Keyed on (address, node) so a second transport for the same node re-fires
- // even though the node number itself is unchanged.
+ // Unify per-device databases across transports. When the handshake reports our node number, tell the
+ // DatabaseManager to claim (or merge into) that device's canonical DB, so the same device reached over BLE,
+ // TCP, or USB shares one stored history. The hardware device id (when reported) is the durable claim key —
+ // firmware 2.8 renumbers devices (num = crc32(public_key)) on upgrade/erase/re-key — with the node number as
+ // fallback. Keyed on (address, node, device) so a second transport for the same device re-fires even though
+ // the identity itself is unchanged.
scope.launch {
- combine(meshPrefs.deviceAddress, nodeManager.myNodeNum) { address, nodeNum -> address to nodeNum }
+ combine(meshPrefs.deviceAddress, nodeManager.myNodeNum, nodeManager.myDeviceId) { address, nodeNum, devId ->
+ Triple(address, nodeNum, devId)
+ }
.distinctUntilChanged()
- .collect { (_, nodeNum) -> nodeNum?.let { databaseManager.associateNode(it) } }
+ .collect { (_, nodeNum, deviceId) -> nodeNum?.let { databaseManager.associateDevice(it, deviceId) } }
}
}
diff --git a/core/service/src/commonTest/kotlin/org/meshtastic/core/service/RadioControllerImplTest.kt b/core/service/src/commonTest/kotlin/org/meshtastic/core/service/RadioControllerImplTest.kt
index c29387a45..caa0c77af 100644
--- a/core/service/src/commonTest/kotlin/org/meshtastic/core/service/RadioControllerImplTest.kt
+++ b/core/service/src/commonTest/kotlin/org/meshtastic/core/service/RadioControllerImplTest.kt
@@ -89,6 +89,7 @@ class RadioControllerImplTest {
myNodeNum: Int? = 1234,
): RadioControllerImpl {
every { nodeManager.myNodeNum } returns MutableStateFlow(myNodeNum)
+ every { nodeManager.myDeviceId } returns MutableStateFlow(null)
every { meshPrefs.deviceAddress } returns MutableStateFlow(null)
return RadioControllerImpl(
serviceRepository = serviceRepository,
diff --git a/core/takserver/src/commonTest/kotlin/org/meshtastic/core/takserver/TAKMeshIntegrationTest.kt b/core/takserver/src/commonTest/kotlin/org/meshtastic/core/takserver/TAKMeshIntegrationTest.kt
index 65c404605..0cbdda725 100644
--- a/core/takserver/src/commonTest/kotlin/org/meshtastic/core/takserver/TAKMeshIntegrationTest.kt
+++ b/core/takserver/src/commonTest/kotlin/org/meshtastic/core/takserver/TAKMeshIntegrationTest.kt
@@ -322,7 +322,7 @@ class TAKMeshIntegrationTest {
override suspend fun upsert(node: Node) {}
- override suspend fun installConfig(mi: MyNodeInfo, nodes: List) {}
+ override suspend fun installConfig(mi: MyNodeInfo, nodes: List): List = emptyList()
override suspend fun insertMetadata(nodeNum: Int, metadata: DeviceMetadata) {}
diff --git a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeDatabaseManager.kt b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeDatabaseManager.kt
index 5929cebef..190efb97b 100644
--- a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeDatabaseManager.kt
+++ b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeDatabaseManager.kt
@@ -30,6 +30,7 @@ class FakeDatabaseManager :
var lastSwitchedAddress: String? = null
var lastAssociatedNode: Int? = null
+ var lastAssociatedDeviceId: String? = null
val existingDatabases = mutableSetOf()
init {
@@ -37,6 +38,7 @@ class FakeDatabaseManager :
_cacheLimit.value = DEFAULT_CACHE_LIMIT
lastSwitchedAddress = null
lastAssociatedNode = null
+ lastAssociatedDeviceId = null
existingDatabases.clear()
}
}
@@ -51,8 +53,9 @@ class FakeDatabaseManager :
lastSwitchedAddress = address
}
- override suspend fun associateNode(nodeNum: Int) {
+ override suspend fun associateDevice(nodeNum: Int, deviceId: String?) {
lastAssociatedNode = nodeNum
+ lastAssociatedDeviceId = deviceId
}
override fun hasDatabaseFor(address: String?): Boolean = address != null && existingDatabases.contains(address)
diff --git a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeNodeRepository.kt b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeNodeRepository.kt
index 15b4ad39e..3e9b01368 100644
--- a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeNodeRepository.kt
+++ b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeNodeRepository.kt
@@ -166,9 +166,10 @@ class FakeNodeRepository :
_nodeDBbyNum.value = _nodeDBbyNum.value + (node.num to node)
}
- override suspend fun installConfig(mi: MyNodeInfo, nodes: List) {
+ override suspend fun installConfig(mi: MyNodeInfo, nodes: List): List {
_myNodeInfo.value = mi
_nodeDBbyNum.value = nodes.associateBy { it.num }
+ return emptyList()
}
override suspend fun insertMetadata(nodeNum: Int, metadata: DeviceMetadata) {