mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-07-12 22:32:23 -04:00
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:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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) } }
|
||||
|
||||
@@ -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 },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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() })
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
@@ -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:"
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)))
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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?>
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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) } }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user