fix(database): migrate device identity across firmware 2.8 renumbering (#6228)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
James Rich
2026-07-11 17:54:54 -05:00
committed by GitHub
parent 5b0d046d42
commit bc5719c8a7
26 changed files with 878 additions and 48 deletions

View File

@@ -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

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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)

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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())
}
}

View File

@@ -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<NodeEntity>)
/** @return node numbers removed by identity migration (see `NodeInfoDao.installConfig`). */
suspend fun installConfig(mi: MyNodeEntity, nodes: List<NodeEntity>): List<Int>
suspend fun clearNodeDB(preserveFavorites: Boolean)

View File

@@ -34,9 +34,14 @@ class SwitchingNodeInfoWriteDataSource(
withContext(dispatchers.io) { dbManager.withDb { it.nodeInfoDao().upsert(node) } }
}
override suspend fun installConfig(mi: MyNodeEntity, nodes: List<NodeEntity>) {
withContext(dispatchers.io) { dbManager.withDb { it.nodeInfoDao().installConfig(mi, nodes) } }
}
override suspend fun installConfig(mi: MyNodeEntity, nodes: List<NodeEntity>): List<Int> =
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) } }

View File

@@ -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 },
)
}

View File

@@ -124,6 +124,12 @@ class NodeManagerImpl(
myNodeNum.value = num
}
override val myDeviceId = MutableStateFlow<String?>(null)
override fun setMyDeviceId(id: String?) {
myDeviceId.value = id
}
override val firmwareEdition = MutableStateFlow<FirmwareEdition?>(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
}

View File

@@ -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<Node>) = withContext(dispatchers.io) {
override suspend fun installConfig(mi: MyNodeInfo, nodes: List<Node>): List<Int> = withContext(dispatchers.io) {
nodeInfoWriteDataSource.installConfig(mi.toEntity(), nodes.map { it.toEntity() })
}

View File

@@ -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<Int>, 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<String>()
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()

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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()

View File

@@ -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:"

View File

@@ -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"

View File

@@ -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

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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<String> =
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<String> =
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<String>?,
nodeKey: Preferences.Key<String>,
): String? = deviceKey?.let { prefs[it] } ?: prefs[nodeKey]

View File

@@ -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<NodeEntity>): List<NodeEntity> {
private suspend fun getVerifiedNodesForUpsert(
incomingNodes: List<NodeEntity>,
selfNum: Int? = null,
removedNums: MutableList<Int> = mutableListOf(),
): List<NodeEntity> {
// 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<NodeEntity>) {
suspend fun installConfig(mi: MyNodeEntity, nodes: List<NodeEntity>): List<Int> {
val removedNums = mutableListOf<Int>()
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
}
/**

View File

@@ -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"

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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("<EFBFBD><EFBFBD>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)))
}
}

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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")
}
}

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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()

View File

@@ -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<String?>
/** Sets the connected device's hardware id. */
fun setMyDeviceId(id: String?)
/** The firmware edition reported by the connected device. */
val firmwareEdition: StateFlow<FirmwareEdition?>

View File

@@ -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<Node>)
suspend fun installConfig(mi: MyNodeInfo, nodes: List<Node>): List<Int>
/**
* Persists hardware metadata for a node.

View File

@@ -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) } }
}
}

View File

@@ -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,

View File

@@ -322,7 +322,7 @@ class TAKMeshIntegrationTest {
override suspend fun upsert(node: Node) {}
override suspend fun installConfig(mi: MyNodeInfo, nodes: List<Node>) {}
override suspend fun installConfig(mi: MyNodeInfo, nodes: List<Node>): List<Int> = emptyList()
override suspend fun insertMetadata(nodeNum: Int, metadata: DeviceMetadata) {}

View File

@@ -30,6 +30,7 @@ class FakeDatabaseManager :
var lastSwitchedAddress: String? = null
var lastAssociatedNode: Int? = null
var lastAssociatedDeviceId: String? = null
val existingDatabases = mutableSetOf<String>()
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)

View File

@@ -166,9 +166,10 @@ class FakeNodeRepository :
_nodeDBbyNum.value = _nodeDBbyNum.value + (node.num to node)
}
override suspend fun installConfig(mi: MyNodeInfo, nodes: List<Node>) {
override suspend fun installConfig(mi: MyNodeInfo, nodes: List<Node>): List<Int> {
_myNodeInfo.value = mi
_nodeDBbyNum.value = nodes.associateBy { it.num }
return emptyList()
}
override suspend fun insertMetadata(nodeNum: Int, metadata: DeviceMetadata) {