diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/datasource/NodeInfoReadDataSource.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/datasource/NodeInfoReadDataSource.kt index c9438ebbf6..ea62007689 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/datasource/NodeInfoReadDataSource.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/datasource/NodeInfoReadDataSource.kt @@ -37,4 +37,11 @@ interface NodeInfoReadDataSource { suspend fun getNodesOlderThan(lastHeard: Int): List suspend fun getUnknownNodes(): List + + /** + * One-shot snapshot of every node row in the currently selected database. Bypasses the process-wide + * [nodeDBbyNumFlow] cache so callers see the live DB state at invocation time rather than a `stateIn` value that + * may belong to a previous transport. + */ + suspend fun getNodeDbSnapshot(): Map } diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/datasource/SwitchingNodeInfoReadDataSource.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/datasource/SwitchingNodeInfoReadDataSource.kt index 1182888777..7fdd69c2b7 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/datasource/SwitchingNodeInfoReadDataSource.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/datasource/SwitchingNodeInfoReadDataSource.kt @@ -55,4 +55,7 @@ class SwitchingNodeInfoReadDataSource(private val dbManager: DatabaseProvider) : override suspend fun getUnknownNodes(): List = dbManager.withReadDb { it.nodeInfoDao().getUnknownNodes() } + + override suspend fun getNodeDbSnapshot(): Map = + dbManager.withReadDb { it.nodeInfoDao().nodeDBbyNumSnapshot() } } diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConfigFlowManagerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConfigFlowManagerImpl.kt index e4f8e752ba..7640e2285a 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConfigFlowManagerImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConfigFlowManagerImpl.kt @@ -296,7 +296,7 @@ class MeshConfigFlowManagerImpl( if (!isActiveSession(session)) return if (removedNums.isNotEmpty()) { Logger.i { "Config install migrated ${removedNums.size} stale node identit(y/ies)" } - removedNums.forEach(nodeManager::removeByNodenum) + nodeManager.applyTrustedIdentityMigrations(removedNums) } val published = diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/NodeManagerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/NodeManagerImpl.kt index 78a9b09f57..4b286c02b0 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/NodeManagerImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/NodeManagerImpl.kt @@ -20,17 +20,19 @@ import co.touchlab.kermit.Logger import kotlinx.atomicfu.atomic import kotlinx.atomicfu.update import kotlinx.collections.immutable.PersistentMap +import kotlinx.collections.immutable.PersistentSet import kotlinx.collections.immutable.persistentMapOf +import kotlinx.collections.immutable.persistentSetOf import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.first import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import okio.ByteString import org.koin.core.annotation.Named import org.koin.core.annotation.Single import org.meshtastic.core.common.util.clampTimestampToNow +import org.meshtastic.core.common.util.crc32 import org.meshtastic.core.common.util.handledLaunch import org.meshtastic.core.model.MyNodeInfo import org.meshtastic.core.model.Node @@ -57,8 +59,29 @@ import kotlinx.coroutines.flow.update as updateStateFlow import org.meshtastic.proto.NodeInfo as ProtoNodeInfo import org.meshtastic.proto.Position as ProtoPosition +/** + * Resolves a validated public-key correlation hint from a raw [ByteString]. Returns null unless the key is exactly + * [Node.PUBLIC_KEY_SIZE] bytes and is not [Node.ERROR_BYTE_STRING]. The hint is used only for in-memory correlation; it + * is not a trusted hardware identity and never authorizes durable node migration or deletion. + */ +private fun resolveValidatedPublicKeyHint(key: ByteString?): ByteString? { + if (key == null || key.size != Node.PUBLIC_KEY_SIZE || key == Node.ERROR_BYTE_STRING) return null + return key +} + +/** Single source of truth for stored-node key precedence: the primary field wins over the embedded [User] field. */ +private fun nodePublicKeyHint(node: Node): ByteString? = + resolveValidatedPublicKeyHint(node.publicKey) ?: resolveValidatedPublicKeyHint(node.user.public_key) + +/** Short one-way diagnostic value that cannot disclose a raw public-key prefix. */ +private const val KEY_FINGERPRINT_HEX_LENGTH = 8 + +internal fun publicKeyLogFingerprint(key: ByteString): String = key.sha256().hex().take(KEY_FINGERPRINT_HEX_LENGTH) + +private val DEFAULT_NODE_NAME_REGEX = Regex("^Meshtastic [0-9a-fA-F]{4}$") + /** Implementation of [NodeManager] that maintains an in-memory database of the mesh. */ -@Suppress("LongParameterList", "TooManyFunctions", "CyclomaticComplexMethod") +@Suppress("LongParameterList", "TooManyFunctions", "CyclomaticComplexMethod", "LargeClass") @Single(binds = [NodeManager::class, NodeIdLookup::class]) class NodeManagerImpl( private val nodeRepository: NodeRepository, @@ -67,59 +90,172 @@ class NodeManagerImpl( @Named("ServiceScope") private val scope: CoroutineScope, ) : NodeManager { - // Fixed stripes bound mutex lifetime while preserving same-node ordering; hash collisions only reduce parallelism. + // Fixed stripes bound mutex lifetime while preserving same-node persistence ordering. Hash collisions only reduce + // parallelism; they never weaken ordering for a node. private val nodePersistenceLanes = List(NODE_PERSISTENCE_LANE_COUNT) { Mutex() } private fun persistenceLane(nodeNum: Int): Mutex = nodePersistenceLanes[(nodeNum.toLong() and Int.MAX_VALUE.toLong()).toInt() % nodePersistenceLanes.size] - // Two indices over the same node set: byNum is the canonical store (mesh-level identifier), byId is a secondary - // O(1) lookup for the user-facing hex string. Both are held in a single atomic ref so updates are observed - // consistently — concurrent readers never see an entry present in one index but not the other. - private data class NodeIndex( + /** + * Resolves a validated public-key correlation hint from a stored [Node], preferring [Node.publicKey] and falling + * back to [User.public_key] only when the primary field is not itself a valid hint. + */ + internal fun resolveNodePublicKeyHint(node: Node): ByteString? = nodePublicKeyHint(node) + + // byNum is the canonical in-memory store. Candidate-number reverse sets make normal put/remove and keyed packet + // handling proportional to the affected ID/key candidates rather than the entire mesh. byId stores the selected + // deterministic representative for O(1) user-ID lookup. + internal data class NodeIndex( val byNum: PersistentMap = persistentMapOf(), val byId: PersistentMap = persistentMapOf(), + val candidateNumsById: PersistentMap> = persistentMapOf(), + val candidateNumsByPublicKey: PersistentMap> = persistentMapOf(), ) { - fun put(num: Int, node: Node): NodeIndex { + fun put(num: Int, node: Node, preferredNum: Int? = null): NodeIndex { val previous = byNum[num] - var nextById = byId - // If the user.id changed (e.g. firmware reassigned the hex id) drop the stale id entry. - if (previous != null && previous.user.id.isNotEmpty() && previous.user.id != node.user.id) { - nextById = nextById.removing(previous.user.id) + val nextByNum = byNum.putting(num, node) + var nextCandidatesById = candidateNumsById + var nextCandidatesByPublicKey = candidateNumsByPublicKey + + previous + ?.user + ?.id + ?.takeIf { it.isNotEmpty() } + ?.let { id -> nextCandidatesById = nextCandidatesById.removingCandidate(id, num) } + previous + ?.let { nodePublicKeyHint(it) } + ?.let { key -> nextCandidatesByPublicKey = nextCandidatesByPublicKey.removingCandidate(key, num) } + node.user.id + .takeIf { it.isNotEmpty() } + ?.let { id -> nextCandidatesById = nextCandidatesById.addingCandidate(id, num) } + nodePublicKeyHint(node)?.let { key -> + nextCandidatesByPublicKey = nextCandidatesByPublicKey.addingCandidate(key, num) } - if (node.user.id.isNotEmpty()) { - nextById = nextById.putting(node.user.id, node) + + var nextById = byId + val affectedIds = setOfNotNull(previous?.user?.id, node.user.id).filter { it.isNotEmpty() } + for (id in affectedIds) { + val representative = chooseRepresentativeNum(nextCandidatesById[id].orEmpty(), nextByNum, preferredNum) + nextById = + if (representative == null) { + nextById.removing(id) + } else { + nextById.putting(id, checkNotNull(nextByNum[representative])) + } } - return NodeIndex(byNum = byNum.putting(num, node), byId = nextById) + return NodeIndex(nextByNum, nextById, nextCandidatesById, nextCandidatesByPublicKey) } - fun remove(num: Int): NodeIndex { + /** + * Removes [num] from both indices. When the removed node's user ID was the [byId] representative and another + * surviving node shares that ID, [preferredNum] wins when present; otherwise deterministic ordering selects the + * replacement. + */ + fun remove(num: Int, preferredNum: Int? = null): NodeIndex { val previous = byNum[num] ?: return this - return NodeIndex( - byNum = byNum.removing(num), - byId = if (previous.user.id.isNotEmpty()) byId.removing(previous.user.id) else byId, - ) + val newByNum = byNum.removing(num) + var newCandidatesById = candidateNumsById + var newCandidatesByPublicKey = candidateNumsByPublicKey + previous.user.id + .takeIf { it.isNotEmpty() } + ?.let { id -> newCandidatesById = newCandidatesById.removingCandidate(id, num) } + nodePublicKeyHint(previous)?.let { key -> + newCandidatesByPublicKey = newCandidatesByPublicKey.removingCandidate(key, num) + } + var newById = byId + if (previous.user.id.isNotEmpty()) { + val survivor = + chooseRepresentativeNum(newCandidatesById[previous.user.id].orEmpty(), newByNum, preferredNum) + newById = + if (survivor != null) { + newById.putting(previous.user.id, checkNotNull(newByNum[survivor])) + } else { + newById.removing(previous.user.id) + } + } + return NodeIndex(newByNum, newById, newCandidatesById, newCandidatesByPublicKey) } companion object { + internal fun isDefaultIdentityPlaceholder(node: Node): Boolean = nodePublicKeyHint(node) == null && + node.user.hw_model == HardwareModel.UNSET && + node.user.id == NodeAddress.numToDefaultId(node.num) && + node.user.long_name.matches(DEFAULT_NODE_NAME_REGEX) + + /** + * Selects a deterministic representative number from [candidates] sharing one user ID. If [preferredNum] is + * present it wins; otherwise an established key/hardware identity wins, then the lower node number. + */ + internal fun chooseRepresentativeNum( + candidates: Set, + nodes: Map, + preferredNum: Int?, + ): Int? { + if (preferredNum != null && preferredNum in candidates) return preferredNum + return candidates.minWithOrNull( + compareByDescending { num -> + nodes[num]?.let { node -> + nodePublicKeyHint(node) != null || node.user.hw_model != HardwareModel.UNSET + } == true + } + .thenBy { it }, + ) + } + fun fromByNum(nodes: Map): NodeIndex { - var byNum = persistentMapOf() - var byId = persistentMapOf() - for ((num, node) in nodes) { - byNum = byNum.putting(num, node) - if (node.user.id.isNotEmpty()) byId = byId.putting(node.user.id, node) - } - return NodeIndex(byNum, byId) + var result = NodeIndex() + nodes.forEach { (num, node) -> result = result.put(num, node) } + return result + } + + private fun PersistentMap>.addingCandidate( + key: K, + num: Int, + ): PersistentMap> = putting(key, (this[key] ?: persistentSetOf()).adding(num)) + + private fun PersistentMap>.removingCandidate( + key: K, + num: Int, + ): PersistentMap> { + val remaining = this[key]?.removing(num) ?: return this + return if (remaining.isEmpty()) removing(key) else putting(key, remaining) } } } - private val nodeIndex = atomic(NodeIndex()) + private data class NodeState( + val index: NodeIndex = NodeIndex(), + val localNodeNum: Int? = null, + val retiredNodeNums: PersistentSet = persistentSetOf(), + /** + * Validated public-key hint for each retired number, captured before index removal. Used to suppress same-key + * replays even when no canonical candidate currently holds the key in the in-memory index. + */ + val retiredKeyHints: PersistentMap = persistentMapOf(), + /** + * Monotonic counter bumped on every live mutation to [index] or [localNodeNum]. Captured by [loadCachedNodeDB] + * before its suspending snapshot read so the commit step can detect concurrent live mutations and merge instead + * of overwriting them. + */ + val revision: Long = 0L, + ) { + fun isRetiredAbsent(nodeNum: Int): Boolean = nodeNum in retiredNodeNums && nodeNum !in index.byNum + } + + private val nodeState = atomic(NodeState()) + + /** + * Session generation, bumped by [clear]. Captured by [loadCachedNodeDB] before its suspending snapshot read so a + * load launched in one session can be discarded completely if [clear] started a new session before the load + * committed. + */ + private val sessionGeneration = atomic(0L) override val nodeDBbyNodeNum: Map - get() = nodeIndex.value.byNum + get() = nodeState.value.index.byNum - override fun getNodeById(id: String): Node? = nodeIndex.value.byId[id] + override fun getNodeById(id: String): Node? = nodeState.value.index.byId[id] override val isNodeDbReady = MutableStateFlow(false) override val allowNodeDbWrites = MutableStateFlow(false) @@ -135,6 +271,7 @@ class NodeManagerImpl( override val myNodeNum = MutableStateFlow(null) override fun setMyNodeNum(num: Int?) { + nodeState.update { it.copy(localNodeNum = num, revision = it.revision + 1) } myNodeNum.value = num } @@ -169,25 +306,72 @@ class NodeManagerImpl( companion object { private const val NODE_PERSISTENCE_LANE_COUNT = 64 - private const val TIME_MS_TO_S = 1000L + private const val GENERATED_NODE_NAME_SUFFIX_LENGTH = 4 } override fun loadCachedNodeDB() { - // NOTE: this method intentionally does NOT touch _connectionIdentity. The connection-session identity is - // only populated by a fresh MyNodeInfo handshake (see publishConnectionIdentity); restoring it from cache - // would re-introduce the stale-identity association bug across transport switches. + // NOTE: this method intentionally does NOT touch _connectionIdentity. The connection-session identity is only + // populated by a fresh MyNodeInfo handshake; restoring it from cache would re-introduce a stale-identity + // association across transport switches. scope.handledLaunch { - val nodes = nodeRepository.nodeDBbyNum.first() - nodeIndex.value = NodeIndex.fromByNum(nodes) - if (myNodeNum.value == null) { - myNodeNum.value = nodeRepository.myNodeInfo.value?.myNodeNum + // Capture the session generation and live revision BEFORE the suspending DB read. After the read returns + // we re-check both: a generation mismatch means clear() started a new session while we were reading, so + // the result is discarded completely; a revision mismatch means a live packet mutated the index while we + // were reading, so we merge instead of overwriting (otherwise a node received during the load window would + // be silently dropped). + val generation = sessionGeneration.value + val revisionAtStart = nodeState.value.revision + // Read from the CURRENTLY SELECTED database through DatabaseProvider.withReadDb, not from the + // process-wide nodeDBbyNum StateFlow. The StateFlow is a stateIn cache over SharingStarted.Eagerly and + // can briefly retain the PREVIOUS database's map after a currentDb switch, which would resurrect retired + // or stale rows on the new session. + val snapshot = nodeRepository.getNodeDbSnapshot() + val persistedLocalNum = nodeRepository.myNodeInfo.value?.myNodeNum + nodeState.update { state -> + if (generation != sessionGeneration.value) { + // Session was reset while the load was in flight; drop the result entirely. + state + } else { + val retiredNums = state.retiredNodeNums + // Retirement always wins: rows for currently retired numbers never re-enter the live index from + // the snapshot, even if the cache still holds them. + val filteredSnapshot = snapshot.filterKeys { it !in retiredNums } + if (state.revision == revisionAtStart) { + // No live mutation since capture: install the filtered snapshot directly. Preserve any + // local-node number learned during the session; fall back to the persisted value only when + // we have none yet. + state.copy( + index = NodeIndex.fromByNum(filteredSnapshot), + localNodeNum = state.localNodeNum ?: persistedLocalNum, + ) + } else { + // Live state changed during the load window. Merge: current nodes (with their fresher + // fields) win over snapshot rows at the same num; snapshot rows only fill slots that the + // live index does not already carry. Live local-node info and retirement state remain + // authoritative; a missing local number receives the same persisted fallback as a direct load. + val liveByNum = state.index.byNum + val merged = filteredSnapshot.toMutableMap() + liveByNum.forEach { (num, node) -> merged[num] = node } + state.copy( + index = NodeIndex.fromByNum(merged), + localNodeNum = state.localNodeNum ?: persistedLocalNum, + ) + } + } } + // Keep the published myNodeNum consistent with whatever state survived the commit above. If the load was + // discarded, the published value is whatever clear()/setMyNodeNum() left behind. + myNodeNum.value = nodeState.value.localNodeNum } } override fun clear() { - nodeIndex.value = NodeIndex() + // Bump the session generation FIRST so any in-flight loadCachedNodeDB whose suspend read returns after this + // point sees a mismatch and discards its result instead of installing stale state into the freshly cleared + // index. + sessionGeneration.incrementAndGet() + nodeState.value = NodeState() isNodeDbReady.value = false allowNodeDbWrites.value = false myNodeNum.value = null @@ -198,7 +382,7 @@ class NodeManagerImpl( override fun getMyNodeInfo(): MyNodeInfo? { val mi = nodeRepository.myNodeInfo.value ?: return null - val myNode = nodeIndex.value.byNum[mi.myNodeNum] + val myNode = nodeState.value.index.byNum[mi.myNodeNum] return MyNodeInfo( myNodeNum = mi.myNodeNum, hasGPS = (myNode?.position?.latitude_i ?: 0) != 0, @@ -218,41 +402,70 @@ class NodeManagerImpl( } override fun getMyId(): String { - val num = myNodeNum.value ?: nodeRepository.myNodeInfo.value?.myNodeNum ?: return "" - return nodeIndex.value.byNum[num]?.user?.id ?: "" + val num = nodeState.value.localNodeNum ?: nodeRepository.myNodeInfo.value?.myNodeNum ?: return "" + return nodeState.value.index.byNum[num]?.user?.id ?: "" } override fun removeByNodenum(nodeNum: Int) { - nodeIndex.update { it.remove(nodeNum) } - } - - internal fun getOrCreateNode(n: Int, channel: Int = 0): Node = nodeIndex.value.byNum[n] - ?: run { - val userId = NodeAddress.numToDefaultId(n) - val defaultUser = - User( - id = userId, - long_name = "Meshtastic ${userId.takeLast(n = 4)}", - short_name = userId.takeLast(n = 4), - hw_model = HardwareModel.UNSET, - ) + nodeState.update { state -> state.copy(index = state.index.remove(nodeNum), revision = state.revision + 1) } + } - Node(num = n, user = defaultUser, channel = channel) + override fun applyTrustedIdentityMigrations(removedNums: Collection) { + if (removedNums.isEmpty()) return + var committedHints: Map = emptyMap() + var committedPresentNums: Set = emptySet() + nodeState.update { state -> + // Capture validated public-key hints from the current index state before removal. A number that is already + // absent (e.g. re-applying the same migration after a previous retirement) has no current node to capture + // a key from — its previously captured hint MUST be preserved so same-key replay stays suppressed and a + // later genuinely different-keyed device can still claim the slot under the intended contract. + committedPresentNums = removedNums.filterTo(mutableSetOf()) { it in state.index.byNum } + committedHints = + removedNums.associateWith { num -> + state.index.byNum[num]?.let(::resolveNodePublicKeyHint) ?: state.retiredKeyHints[num] + } + state.copy( + index = removedNums.fold(state.index) { index, nodeNum -> index.remove(nodeNum) }, + retiredNodeNums = state.retiredNodeNums.addingAll(removedNums), + // Only ADD hints here; never remove a prior hint for an absent number. A subsequent migration that + // re-applies the same retirement must preserve the original hint so same-key replay stays suppressed + // and a later genuinely different-keyed device can still claim the slot. + retiredKeyHints = + state.retiredKeyHints.puttingAll( + committedHints.mapNotNull { (num, key) -> key?.let { num to it } }.toMap(), + ), + revision = state.revision + 1, + ) + } + // Commit retirement first. A dispatch already in progress will fail its final state revalidation; one that + // completed before the commit is removed by this cancellation. Side effects run once, outside the CAS loop. + removedNums.forEach { num -> + notificationManager.cancel(num) + val keyDescription = + committedHints[num]?.let(::publicKeyLogFingerprint) + ?: if (num in committedPresentNums) "none" else "absent" + Logger.d { + "[NodeIdentity] trusted-migration removed=$num" + " key=$keyDescription notificationCancelled=true" + } } + } + + internal fun getOrCreateNode(n: Int, channel: Int = 0): Node = + nodeState.value.index.byNum[n] ?: createDefaultNode(n, channel) private data class NodeStateChange(val previous: Node, val next: Node) - private fun updateNodeState(nodeNum: Int, channel: Int, transform: (Node) -> Node): NodeStateChange { - // Compute the transform against one immutable snapshot and publish it with CAS. The transform may be retried, - // so it must remain side-effect free; callers perform notifications or other effects only after this returns. - while (true) { - val index = nodeIndex.value - val current = index.byNum[nodeNum] ?: getOrCreateNode(nodeNum, channel) + private fun updateNodeState(nodeNum: Int, channel: Int, transform: (Node) -> Node): NodeStateChange? { + var change: NodeStateChange? = null + nodeState.update { state -> + change = null + if (state.isRetiredAbsent(nodeNum)) return@update state + val current = state.index.byNum[nodeNum] ?: createDefaultNode(nodeNum, channel) val next = transform(current) - if (nodeIndex.compareAndSet(index, index.put(nodeNum, next))) { - return NodeStateChange(previous = current, next = next) - } + change = NodeStateChange(previous = current, next = next) + state.copy(index = state.index.put(nodeNum, next), revision = state.revision + 1) } + return change } private fun shouldPersist(node: Node): Boolean = @@ -263,8 +476,8 @@ class NodeManagerImpl( channel: Int, session: RadioSessionContext? = null, transform: (Node) -> Node, - ): NodeStateChange = updateNodeState(nodeNum, channel, transform).also { change -> - if (shouldPersist(change.next)) { + ): NodeStateChange? = updateNodeState(nodeNum, channel, transform).also { change -> + if (change != null && shouldPersist(change.next)) { radioInterfaceService.launchSessionWork(scope, session) { persistLatestNode(nodeNum) } } } @@ -283,16 +496,13 @@ class NodeManagerImpl( } override suspend fun updateNodeAndPersist(nodeNum: Int, channel: Int, transform: (Node) -> Node) { - val result = updateNodeState(nodeNum, channel, transform).next + val result = updateNodeState(nodeNum, channel, transform)?.next ?: return if (shouldPersist(result)) persistLatestNode(nodeNum) } - /** - * Serializes persistence per node and reads the latest in-memory value inside that lane. A delayed earlier update - * therefore cannot overwrite a newer state after its repository write resumes. - */ + /** Serializes persistence per node and reads the latest in-memory value inside that lane. */ private suspend fun persistLatestNode(nodeNum: Int) = persistenceLane(nodeNum).withLock { - val latest = nodeIndex.value.byNum[nodeNum] ?: return@withLock + val latest = nodeState.value.index.byNum[nodeNum] ?: return@withLock if (shouldPersist(latest)) nodeRepository.upsert(latest) } @@ -303,41 +513,42 @@ class NodeManagerImpl( manuallyVerified: Boolean, session: RadioSessionContext?, ) { - val change = - updateNodeAndSchedulePersistence(fromNum, channel, session) { node -> - val shouldPreserve = shouldPreserveExistingUser(node.user, p) - if (shouldPreserve) { - node.copy(channel = channel, manuallyVerified = manuallyVerified) - } else { - val keyMatch = !node.hasPKC || node.user.public_key == p.public_key - val newUser = if (keyMatch) p else p.copy(public_key = ByteString.EMPTY) - node.copy( - user = newUser, - publicKey = newUser.public_key, - channel = channel, - manuallyVerified = manuallyVerified, - ) - } - } - val shouldNotify = - change.previous.isUnknownUser && - p.hw_model != HardwareModel.UNSET && - !shouldPreserveExistingUser(change.previous.user, p) - if (shouldNotify) { - val node = change.next - radioInterfaceService.launchSessionWork(scope, session) { - notificationManager.dispatch( - Notification( - title = getStringSuspend(Res.string.new_node_seen, node.user.short_name), - message = node.user.long_name, - category = Notification.Category.NodeEvent, - id = node.num, - // Path format must stay in sync with DEEP_LINK_BASE_URI + DeepLinkRouter - // in core/navigation (avoided as a Gradle dep here to keep core/data free - // of Compose Navigation libs). - deepLinkUri = "meshtastic://meshtastic/nodes/${node.num}", - ), + while (true) { + val before = nodeState.value + val transition = + reduceReceivedUser( + before.index, + fromNum, + p, + channel, + manuallyVerified, + before.localNodeNum, + before.isRetiredAbsent(fromNum), + retiredKeyHint = before.retiredKeyHints[fromNum], ) + receivedUserReductionHook?.invoke() + val after = + before.copy( + index = transition.after, + retiredNodeNums = + transition.unretireNodeNum?.let { before.retiredNodeNums.removing(it) } + ?: before.retiredNodeNums, + retiredKeyHints = + transition.unretireNodeNum?.let { before.retiredKeyHints.removing(it) } + ?: before.retiredKeyHints, + revision = before.revision + 1, + ) + if (nodeState.compareAndSet(before, after)) { + Logger.d { + val keyStr = resolveValidatedPublicKeyHint(p.public_key)?.let(::publicKeyLogFingerprint) ?: "none" + val canonicalNum = resolveValidatedPublicKeyHint(p.public_key)?.let { it.crc32().toInt() } + "[NodeIdentity] user from=$fromNum local=${before.localNodeNum}" + + " retired=${before.isRetiredAbsent(fromNum)}" + + " key=$keyStr canonical=$canonicalNum" + + " decision=${transition.decision} notify=${transition.notifyNode != null}" + } + applyReceivedUserEffects(transition, session) + return } } } @@ -413,8 +624,7 @@ class NodeManagerImpl( node.copy(nodeStatus = status?.takeIf { it.isNotEmpty() }) override fun installNodeInfo(info: ProtoNodeInfo) { - // Stage-2 configuration installation persists the full node snapshot through NodeRepository.installConfig. - // Keep this primitive in-memory so that handshake installation does not issue one redundant write per node. + // Stage-2 configuration installation persists the complete node snapshot through installConfig. updateNodeState(info.num, channel = 0) { node -> applyNodeInfo(node, info) } } @@ -449,19 +659,407 @@ class NodeManagerImpl( } override fun insertMetadata(nodeNum: Int, metadata: DeviceMetadata, session: RadioSessionContext?) { - radioInterfaceService.launchSessionWork(scope, session) { nodeRepository.insertMetadata(nodeNum, metadata) } + if (nodeState.value.isRetiredAbsent(nodeNum)) return + radioInterfaceService.launchSessionWork(scope, session) { + if (!nodeState.value.isRetiredAbsent(nodeNum)) nodeRepository.insertMetadata(nodeNum, metadata) + } } private fun shouldPreserveExistingUser(existing: User, incoming: User): Boolean { - val isDefaultName = (incoming.long_name).matches(Regex("^Meshtastic [0-9a-fA-F]{4}$")) + val isDefaultName = incoming.long_name.matches(DEFAULT_NODE_NAME_REGEX) val isDefaultHwModel = incoming.hw_model == HardwareModel.UNSET - val hasExistingUser = (existing.id).isNotEmpty() && existing.hw_model != HardwareModel.UNSET + val hasCustomIdentity = + existing.id != incoming.id || + existing.long_name != incoming.long_name || + existing.short_name != incoming.short_name + val hasExistingUser = + existing.id.isNotEmpty() && (existing.hw_model != HardwareModel.UNSET || hasCustomIdentity) return hasExistingUser && isDefaultName && isDefaultHwModel } + /** + * Classification of one received-user reduction. Returned by [reduceReceivedUser] so callers can emit a single + * identity-routing log line per committed transition. + */ + private enum class ReceivedUserDecision { + LOCAL_UPDATE, + KNOWN_SAME_NUMBER, + GENUINE_NEW_NODE, + RETIRED_NO_KEY_SUPPRESSED, + RETIRED_WITHOUT_HINT_SUPPRESSED, + RETIRED_SAME_KEY_SUPPRESSED, + RETIRED_KEY_ALREADY_REPRESENTED, + RETIRED_DIFFERENT_KEY_REACTIVATED, + CANONICAL_DUPLICATE_RECONCILED, + STALE_PRESENTATION_REMOVED, + AMBIGUOUS_DUPLICATE_UPDATED, + IDENTITY_CONFLICT_IGNORED, + } + + /** + * Result of classifying one [User] packet against an in-memory [NodeIndex] snapshot. The reducer is pure: it only + * reads [before] and the packet fields, and produces the next index plus the side effects to apply once the CAS + * commits. Safe to re-evaluate on every retry. + */ + private data class ReceivedUserTransition( + val after: NodeIndex, + /** Ordinary same-number upsert to apply after CAS; duplicate correlation never supplies this value. */ + val upsertNode: Node?, + /** Node to fire a "new node seen" notification for (null if not a genuine new node). */ + val notifyNode: Node?, + /** Retired number to reactivate after a validated, genuinely new identity claims the vacant slot. */ + val unretireNodeNum: Int? = null, + val decision: ReceivedUserDecision, + ) + + /** + * Pure reducer that classifies an incoming [User] packet against the [before] snapshot and returns the next index + * plus queued side effects. No logging, no DB calls, no coroutine launches — deterministic and safe to re-run on + * CAS retry. + * + * A validated public key is only an in-memory correlation hint. It can choose/suppress duplicate presentations but + * cannot authorize repository deletion, metadata removal, DM remapping, or durable node-number migration. Those + * operations remain exclusively in the trusted config-install DAO path. + */ + @Suppress("CyclomaticComplexMethod", "ReturnCount", "LongMethod") + private fun reduceReceivedUser( + before: NodeIndex, + fromNum: Int, + p: User, + channel: Int, + manuallyVerified: Boolean, + myNum: Int?, + retiredAbsent: Boolean, + retiredKeyHint: ByteString? = null, + ): ReceivedUserTransition { + val resolvedKey = resolveValidatedPublicKeyHint(p.public_key) + val existing = before.byNum[fromNum] + + if (retiredAbsent) { + // Suppress same-key replay even when canonical representative hasn't yet appeared in + // the in-memory index. The retiredKeyHint was captured from the node as it was retired. + if (resolvedKey != null && retiredKeyHint != null && resolvedKey == retiredKeyHint) { + return ReceivedUserTransition( + after = before, + upsertNode = null, + notifyNode = null, + decision = ReceivedUserDecision.RETIRED_SAME_KEY_SUPPRESSED, + ) + } + // A packet with no valid key is suppressed for a retired number. + if (resolvedKey == null) { + return ReceivedUserTransition( + after = before, + upsertNode = null, + notifyNode = null, + decision = ReceivedUserDecision.RETIRED_NO_KEY_SUPPRESSED, + ) + } + // A retired slot without a trusted old-key hint has no evidence that a different-looking packet is a + // legitimate reuse rather than the stale identity. Keep it retired for this process session. + if (retiredKeyHint == null) { + return ReceivedUserTransition( + after = before, + upsertNode = null, + notifyNode = null, + decision = ReceivedUserDecision.RETIRED_WITHOUT_HINT_SUPPRESSED, + ) + } + // Key is already represented elsewhere — stale replay, suppress. + val keyAlreadyRepresented = + resolvedKey.let { key -> before.candidateNumsByPublicKey[key].orEmpty().isNotEmpty() } + if (keyAlreadyRepresented) { + return ReceivedUserTransition( + after = before, + upsertNode = null, + notifyNode = null, + decision = ReceivedUserDecision.RETIRED_KEY_ALREADY_REPRESENTED, + ) + } + // Different valid, unrepresented key — legitimate number reuse. + return sameNumberUserTransition( + before = before, + fromNum = fromNum, + existing = createDefaultNode(fromNum, channel), + incoming = p, + channel = channel, + manuallyVerified = manuallyVerified, + persist = true, + allowNotification = true, + unretireNodeNum = fromNum, + ) + .copy(decision = ReceivedUserDecision.RETIRED_DIFFERENT_KEY_REACTIVATED) + } + + if (fromNum == myNum) { + // Local-link data may persist an ordinary update at its current number. Duplicate cleanup is still + // in-memory only; trusted installConfig migration owns durable renumbering and app-local state transfer. + val otherSameKeyNums = + resolvedKey + ?.let { key -> before.candidateNumsByPublicKey[key].orEmpty().filter { it != fromNum } } + .orEmpty() + val afterRemovals = otherSameKeyNums.fold(before) { idx, num -> idx.remove(num) } + val localNode = afterRemovals.byNum[fromNum] ?: createDefaultNode(fromNum, channel) + val transformed = transformUserNode(localNode, p, channel, manuallyVerified) + return ReceivedUserTransition( + after = afterRemovals.put(fromNum, transformed, preferredNum = fromNum), + upsertNode = transformed, + notifyNode = null, + decision = ReceivedUserDecision.LOCAL_UPDATE, + ) + } + + if (resolvedKey != null) { + val canonicalNum = resolvedKey.crc32().toInt() + val existingKey = existing?.let(::resolveNodePublicKeyHint) + val otherSameKeyNums = before.candidateNumsByPublicKey[resolvedKey].orEmpty().filter { it != fromNum } + + // Fast path: a known same-number presentation needs no candidate reconciliation when it is already at the + // canonical number or has no duplicate. A canonical duplicate remains in-memory-only because an ordinary + // DAO upsert could remap the row through same-key verification. + if (existingKey == resolvedKey && (fromNum == canonicalNum || otherSameKeyNums.isEmpty())) { + return sameNumberUserTransition( + before, + fromNum, + checkNotNull(existing), + p, + channel, + manuallyVerified, + persist = otherSameKeyNums.isEmpty(), + allowNotification = false, + ) + } + + if (fromNum == canonicalNum && otherSameKeyNums.isNotEmpty()) { + val slotAcceptsCanonical = + existing == null || existingKey == resolvedKey || NodeIndex.isDefaultIdentityPlaceholder(existing) + if (!slotAcceptsCanonical) { + return ReceivedUserTransition( + after = before, + upsertNode = null, + notifyNode = null, + decision = ReceivedUserDecision.IDENTITY_CONFLICT_IGNORED, + ) + } + + // Canonical presentation may replace its own default placeholder while preserving all non-user fields. + // Same-key noncanonical representatives are removed from memory only. + val afterRemovals = otherSameKeyNums.fold(before) { index, num -> index.remove(num) } + val baseNode = afterRemovals.byNum[fromNum] ?: createDefaultNode(fromNum, channel) + val transformed = + transformUserNode(baseNode, p, channel, manuallyVerified).let { updated -> + if (existing != null && NodeIndex.isDefaultIdentityPlaceholder(existing)) { + updated.copy(channel = existing.channel) + } else { + updated + } + } + return ReceivedUserTransition( + after = afterRemovals.put(fromNum, transformed, preferredNum = fromNum), + upsertNode = null, + notifyNode = null, + decision = ReceivedUserDecision.CANONICAL_DUPLICATE_RECONCILED, + ) + } + + if (canonicalNum in otherSameKeyNums) { + // A stale remote presentation yields only when its own slot already carries the same validated hint. + // An absent slot, no-key telemetry/position placeholder, or different identity is preserved. + val after = + if (existingKey == resolvedKey) before.remove(fromNum, preferredNum = canonicalNum) else before + return ReceivedUserTransition( + after = after, + upsertNode = null, + notifyNode = null, + decision = ReceivedUserDecision.STALE_PRESENTATION_REMOVED, + ) + } + + if (otherSameKeyNums.isNotEmpty()) { + // Direction is ambiguous among noncanonical presentations. Accept an empty/default fromNum slot so a + // legitimate pre-2.8 or fixed-number peer can move between noncanonical numbers without becoming + // locked out behind a telemetry/position placeholder. Preserve established different-key identities. + val slotAcceptsAmbiguous = + existing == null || existingKey == resolvedKey || NodeIndex.isDefaultIdentityPlaceholder(existing) + if (!slotAcceptsAmbiguous) { + return ReceivedUserTransition( + after = before, + upsertNode = null, + notifyNode = null, + decision = ReceivedUserDecision.IDENTITY_CONFLICT_IGNORED, + ) + } + + // Public keys are broadcast, unauthenticated correlation hints. Installing the presentation here is + // deliberately in-memory-only: it must never authorize persistence, migration, or deletion. Trusted + // config installation remains the sole authority for durable identity changes. + val baseNode = existing ?: createDefaultNode(fromNum, channel) + val transformed = transformUserNode(baseNode, p, channel, manuallyVerified) + return ReceivedUserTransition( + after = before.put(fromNum, transformed, preferredNum = fromNum), + upsertNode = null, + notifyNode = null, + decision = ReceivedUserDecision.AMBIGUOUS_DUPLICATE_UPDATED, + ) + } + } + + return sameNumberUserTransition( + before, + fromNum, + existing ?: createDefaultNode(fromNum, channel), + p, + channel, + manuallyVerified, + persist = true, + allowNotification = true, + ) + } + + private fun sameNumberUserTransition( + before: NodeIndex, + fromNum: Int, + existing: Node, + incoming: User, + channel: Int, + manuallyVerified: Boolean, + persist: Boolean, + allowNotification: Boolean, + unretireNodeNum: Int? = null, + ): ReceivedUserTransition { + val isNewNode = existing.isUnknownUser && incoming.hw_model != HardwareModel.UNSET + val shouldPreserve = shouldPreserveExistingUser(existing.user, incoming) + val transformed = transformUserNode(existing, incoming, channel, manuallyVerified) + val notify = if (allowNotification && isNewNode && !shouldPreserve) transformed else null + val decision = + when { + allowNotification && isNewNode && !shouldPreserve -> ReceivedUserDecision.GENUINE_NEW_NODE + else -> ReceivedUserDecision.KNOWN_SAME_NUMBER + } + return ReceivedUserTransition( + after = before.put(fromNum, transformed), + upsertNode = transformed.takeIf { persist }, + notifyNode = notify, + unretireNodeNum = unretireNodeNum, + decision = decision, + ) + } + + /** Pure factory for a placeholder [Node] at [num]. Kept side-effect-free so the reducer can call it safely. */ + private fun createDefaultNode(num: Int, channel: Int): Node { + val userId = NodeAddress.numToDefaultId(num) + return Node( + num = num, + user = + User( + id = userId, + long_name = "Meshtastic ${userId.takeLast(GENERATED_NODE_NAME_SUFFIX_LENGTH)}", + short_name = userId.takeLast(GENERATED_NODE_NAME_SUFFIX_LENGTH), + hw_model = HardwareModel.UNSET, + ), + channel = channel, + ) + } + + /** + * Pure transform of an existing [node] under an incoming [User] packet (extracted from the legacy updateNode path). + */ + private fun transformUserNode(node: Node, p: User, channel: Int, manuallyVerified: Boolean): Node { + val shouldPreserve = shouldPreserveExistingUser(node.user, p) + return if (shouldPreserve) { + node.copy(channel = channel, manuallyVerified = manuallyVerified) + } else { + val incomingKey = resolveValidatedPublicKeyHint(p.public_key) + val sanitizedUser = if (incomingKey == null) p.copy(public_key = ByteString.EMPTY) else p + // Prefer node.publicKey when valid (the authoritative stored key); fall back to node.user.public_key. + val existingKey = resolveNodePublicKeyHint(node) + val keyMatch = existingKey == null || existingKey == incomingKey + val newUser = if (keyMatch) sanitizedUser else sanitizedUser.copy(public_key = ByteString.EMPTY) + node.copy( + user = newUser, + publicKey = newUser.public_key, + channel = channel, + manuallyVerified = manuallyVerified, + ) + } + } + + /** Applies ordinary same-number persistence and notification side effects once after the reducer CAS commits. */ + private fun applyReceivedUserEffects(transition: ReceivedUserTransition, session: RadioSessionContext?) { + transition.upsertNode?.let { node -> + if (shouldPersist(node)) { + radioInterfaceService.launchSessionWork(scope, session) { persistLatestNode(node.num) } + } + } + transition.notifyNode?.let { node -> + radioInterfaceService.launchSessionWork(scope, session) { + // Resolve the display title before validation so the suspending compose-resources call does + // not create a window where state can change after identity checks pass. + val title = notificationTitleFormatter(node.user.short_name) + + // Revalidate immediately before dispatch: the number may have been retired, cleared, or + // replaced by a different identity between the reducer CAS and this coroutine dispatch. + val currentState = nodeState.value + if (currentState.isRetiredAbsent(node.num)) { + Logger.d { "[NodeIdentity] notification-skip num=${node.num} reason=retired" } + return@launchSessionWork + } + val currentNode = currentState.index.byNum[node.num] + if (currentNode == null) { + Logger.d { "[NodeIdentity] notification-skip num=${node.num} reason=absent" } + return@launchSessionWork + } + if (currentState.localNodeNum == node.num) { + Logger.d { "[NodeIdentity] notification-skip num=${node.num} reason=local-node" } + return@launchSessionWork + } + // Identity comparison: prefer validated public-key equality; fall back to user.id. + // Reject if one snapshot has a key and the other does not — that is an identity change. + val nodeKey = resolveNodePublicKeyHint(node) + val currentKey = resolveNodePublicKeyHint(currentNode) + val sameIdentity = + when { + nodeKey != null && currentKey != null -> nodeKey == currentKey + + nodeKey == null && currentKey == null -> + node.user.id.isNotEmpty() && + currentNode.user.id.isNotEmpty() && + node.user.id == currentNode.user.id + + else -> false + } + if (!sameIdentity) { + Logger.d { "[NodeIdentity] notification-skip num=${node.num} reason=identity-changed" } + return@launchSessionWork + } + Logger.d { "[NodeIdentity] notification-dispatch num=${node.num}" } + notificationManager.dispatch( + Notification( + title = title, + message = node.user.long_name, + category = Notification.Category.NodeEvent, + id = node.num, + deepLinkUri = "meshtastic://meshtastic/nodes/${node.num}", + ), + ) + } + } + } + + /** + * Test seam over the notification title; production resolves compose-resources lazily inside the dispatch + * coroutine. + */ + internal var notificationTitleFormatter: suspend (String) -> String = { shortName -> + getStringSuspend(Res.string.new_node_seen, shortName) + } + + /** Test seam invoked after reduction but before CAS to deterministically race a local-node-number update. */ + internal var receivedUserReductionHook: (() -> Unit)? = null + override fun toNodeID(nodeNum: Int): String = if (nodeNum == NodeAddress.NODENUM_BROADCAST) { NodeAddress.ID_BROADCAST } else { - nodeIndex.value.byNum[nodeNum]?.user?.id ?: NodeAddress.numToDefaultId(nodeNum) + nodeState.value.index.byNum[nodeNum]?.user?.id ?: NodeAddress.numToDefaultId(nodeNum) } } diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/NodeRepositoryImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/NodeRepositoryImpl.kt index 34e56c2145..fee66a126a 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/NodeRepositoryImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/NodeRepositoryImpl.kt @@ -227,6 +227,9 @@ class NodeRepositoryImpl( override suspend fun getUnknownNodes(): List = withContext(dispatchers.io) { nodeInfoReadDataSource.getUnknownNodes().map { it.toModel() } } + override suspend fun getNodeDbSnapshot(): Map = + withContext(dispatchers.io) { nodeInfoReadDataSource.getNodeDbSnapshot().mapValues { (_, it) -> it.toModel() } } + /** Persists hardware metadata for a node. */ override suspend fun insertMetadata(nodeNum: Int, metadata: DeviceMetadata) = withContext(dispatchers.io) { nodeInfoWriteDataSource.upsert(MetadataEntity(nodeNum, metadata)) } diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshConfigFlowManagerImplTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshConfigFlowManagerImplTest.kt index c79bf87f95..4d227c9a40 100644 --- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshConfigFlowManagerImplTest.kt +++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshConfigFlowManagerImplTest.kt @@ -626,6 +626,37 @@ class MeshConfigFlowManagerImplTest { verifySuspend { connectionManager.onNodeDbReady() } } + @Test + fun `Stage 2 applies trusted migrations before readiness and replay`() = testScope.runTest { + val retiredNum = 456 + val callOrder = mutableListOf() + everySuspend { nodeRepository.installConfig(any(), any()) } calls + { + callOrder.add("installConfig") + listOf(retiredNum) + } + every { nodeManager.applyTrustedIdentityMigrations(any()) } calls { callOrder.add("applyMigrations") } + every { nodeManager.setNodeDbReady(true) } calls { callOrder.add("nodeDbReady") } + every { nodeManager.setAllowNodeDbWrites(true) } calls { callOrder.add("writesReady") } + everySuspend { connectionManager.onNodeDbReady() } calls { callOrder.add("replayReady") } + + handleMyInfo(protoMyNodeInfo) + advanceUntilIdle() + manager.handleLocalMetadata(metadata) + advanceUntilIdle() + manager.handleConfigComplete(HandshakeConstants.CONFIG_NONCE) + advanceTimeBy(STAGE_TRANSITION_ADVANCE_MS) + runCurrent() + manager.handleConfigComplete(HandshakeConstants.NODE_INFO_NONCE) + advanceUntilIdle() + + assertEquals( + listOf("installConfig", "applyMigrations", "nodeDbReady", "writesReady", "replayReady"), + callOrder, + ) + verify { nodeManager.applyTrustedIdentityMigrations(listOf(retiredNum)) } + } + @Test fun `Stage 2 complete id ignored when not in ReceivingNodeInfo state`() = testScope.runTest { manager.handleConfigComplete(HandshakeConstants.NODE_INFO_NONCE) diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/NodeManagerConnectionIdentityTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/NodeManagerConnectionIdentityTest.kt index 8140f24a5f..d440f8ac40 100644 --- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/NodeManagerConnectionIdentityTest.kt +++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/NodeManagerConnectionIdentityTest.kt @@ -19,6 +19,7 @@ package org.meshtastic.core.data.manager import dev.mokkery.MockMode import dev.mokkery.answering.returns import dev.mokkery.every +import dev.mokkery.everySuspend import dev.mokkery.mock import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch @@ -55,6 +56,7 @@ class NodeManagerConnectionIdentityTest { @BeforeTest fun setUp() { + everySuspend { nodeRepository.getNodeDbSnapshot() } returns emptyMap() nodeManager = NodeManagerImpl(nodeRepository, notificationManager, radioInterfaceService, testScope) } diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/NodeManagerImplTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/NodeManagerImplTest.kt index fb5f9621ec..74e48fefc5 100644 --- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/NodeManagerImplTest.kt +++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/NodeManagerImplTest.kt @@ -22,7 +22,10 @@ import dev.mokkery.answering.returns import dev.mokkery.every import dev.mokkery.everySuspend import dev.mokkery.matcher.any +import dev.mokkery.matcher.capture.capture import dev.mokkery.mock +import dev.mokkery.verify +import dev.mokkery.verify.VerifyMode import dev.mokkery.verify.VerifyMode.Companion.exactly import dev.mokkery.verifySuspend import kotlinx.coroutines.CompletableDeferred @@ -30,20 +33,26 @@ import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.async import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import okio.ByteString import okio.ByteString.Companion.toByteString +import org.meshtastic.core.common.util.crc32 import org.meshtastic.core.model.MyNodeInfo import org.meshtastic.core.model.Node import org.meshtastic.core.model.NodeAddress import org.meshtastic.core.repository.NodeRepository +import org.meshtastic.core.repository.Notification import org.meshtastic.core.repository.NotificationManager import org.meshtastic.core.repository.RadioInterfaceService import org.meshtastic.core.repository.RadioSessionContext +import org.meshtastic.proto.DeviceMetadata import org.meshtastic.proto.DeviceMetrics import org.meshtastic.proto.EnvironmentMetrics import org.meshtastic.proto.HardwareModel +import org.meshtastic.proto.Paxcount +import org.meshtastic.proto.StatusMessage import org.meshtastic.proto.Telemetry import org.meshtastic.proto.User import kotlin.test.BeforeTest @@ -68,6 +77,10 @@ class NodeManagerImplTest { @BeforeTest fun setUp() { nodeManager = NodeManagerImpl(nodeRepository, notificationManager, radioInterfaceService, testScope) + // Override the compose-resources formatter so notification dispatch is deterministic in the + // plain-JVM test env (getStringSuspend does not resolve here). Tests that assert "no dispatch" + // still hold: the override only changes the title, not whether dispatch fires. + nodeManager.notificationTitleFormatter = { shortName -> "New node seen: $shortName" } } @Test @@ -548,4 +561,1850 @@ class NodeManagerImplTest { val result = nodeManager.getMyId() assertEquals("!mynode42", result) } + + // ---------- Atomic identity reconciliation ---------- + // + // Covers the CAS-loop reducer that replaced the legacy read-then-mutate sequence. Each test asserts typed + // in-memory state and the permitted side effects (ordinary same-number upsert and notification dispatch). + + private val validPk = ByteArray(32) { (it + 1).toByte() }.toByteString() + + private fun ByteString.canonicalNum(): Int = crc32().toInt() + + private fun ByteString.noncanonicalNum(preferred: Int): Int = + if (preferred != canonicalNum()) preferred else preferred + 1 + + private fun makeKnownNode(num: Int, pk: ByteString, name: String = "Known"): Node = Node( + num = num, + user = + User( + id = "!${num.toString(16)}", + long_name = name, + short_name = name.take(3), + hw_model = HardwareModel.TLORA_V2, + public_key = pk, + ), + publicKey = pk, + ) + + private fun enableDbWrites() { + nodeManager.setNodeDbReady(true) + nodeManager.setAllowNodeDbWrites(true) + } + + private fun verifyNoRepositoryDeletion() { + verifySuspend(mode = VerifyMode.not) { nodeRepository.deleteNode(any()) } + verifySuspend(mode = VerifyMode.not) { nodeRepository.deleteNodes(any()) } + } + + // ---------- Trusted migration retirement ---------- + + @Test + fun `retired number rejects delayed field sequence without persistence`() { + val retiredNum = validPk.noncanonicalNum(4100) + nodeManager.updateNode(retiredNum) { makeKnownNode(retiredNum, validPk, "Pre-migration") } + nodeManager.applyTrustedIdentityMigrations(listOf(retiredNum)) + enableDbWrites() + + nodeManager.handleReceivedTelemetry(retiredNum, Telemetry(device_metrics = DeviceMetrics(battery_level = 50))) + nodeManager.handleReceivedPosition( + retiredNum, + myNodeNum = 9999, + p = ProtoPosition(latitude_i = 123, longitude_i = 456), + defaultTime = 1000L, + ) + nodeManager.handleReceivedPaxcounter(retiredNum, Paxcount(wifi = 10, ble = 5, uptime = 1000)) + nodeManager.handleReceivedNodeStatus(retiredNum, StatusMessage(status = "stale")) + nodeManager.installNodeInfo( + ProtoNodeInfo( + num = retiredNum, + user = + User( + id = "!retired", + long_name = "Retired", + short_name = "RET", + hw_model = HardwareModel.TLORA_V2, + public_key = validPk, + ), + ), + ) + nodeManager.insertMetadata(retiredNum, DeviceMetadata(firmware_version = "2.7.0")) + testScope.advanceUntilIdle() + + assertNull(nodeManager.nodeDBbyNodeNum[retiredNum]) + verifySuspend(mode = VerifyMode.not) { nodeRepository.upsert(any()) } + verifySuspend(mode = VerifyMode.not) { nodeRepository.insertMetadata(any(), any()) } + verifySuspend(mode = VerifyMode.not) { notificationManager.dispatch(any()) } + } + + @Test + fun `retired number suppresses keyless and represented-key user replays`() { + val retiredNum = validPk.noncanonicalNum(4200) + val canonicalNum = validPk.canonicalNum() + nodeManager.updateNode(canonicalNum) { makeKnownNode(canonicalNum, validPk, "Canonical") } + nodeManager.updateNode(retiredNum) { makeKnownNode(retiredNum, validPk, "Retired") } + nodeManager.applyTrustedIdentityMigrations(listOf(retiredNum)) + enableDbWrites() + + nodeManager.handleReceivedUser( + retiredNum, + User(id = "!keyless", long_name = "Keyless replay", short_name = "KEY", hw_model = HardwareModel.TLORA_V2), + ) + nodeManager.handleReceivedUser( + retiredNum, + User( + id = "!invalid", + long_name = "Invalid-key replay", + short_name = "INV", + hw_model = HardwareModel.TLORA_V2, + public_key = byteArrayOf(1, 2, 3).toByteString(), + ), + ) + nodeManager.handleReceivedUser( + retiredNum, + User( + id = "!represented", + long_name = "Represented replay", + short_name = "REP", + hw_model = HardwareModel.TLORA_V2, + public_key = validPk, + ), + ) + testScope.advanceUntilIdle() + + assertNull(nodeManager.nodeDBbyNodeNum[retiredNum]) + assertEquals("Canonical", nodeManager.nodeDBbyNodeNum[canonicalNum]?.user?.long_name) + verifySuspend(mode = VerifyMode.not) { nodeRepository.upsert(any()) } + verifySuspend(mode = VerifyMode.not) { notificationManager.dispatch(any()) } + } + + @Test + fun `retired number accepts validated new-key reuse and un-retires the slot`() { + // Seed a trusted old identity at the number FIRST, so the migration retires it with a trusted key hint. Without + // the hint, a later key packet would be suppressed as RETIRED_WITHOUT_HINT_SUPPRESSED and the slot would stay + // retired for the session (see reduceReceivedUser). + val retiredNum = validPk.noncanonicalNum(4300) + val replacementKey = ByteArray(32) { (it + 80).toByte() }.toByteString() + nodeManager.updateNode(retiredNum) { makeKnownNode(retiredNum, validPk, "Old") } + testScope.advanceUntilIdle() + nodeManager.applyTrustedIdentityMigrations(listOf(retiredNum)) + enableDbWrites() + + nodeManager.handleReceivedUser( + retiredNum, + User( + id = "!replacement", + long_name = "Replacement", + short_name = "NEW", + hw_model = HardwareModel.TLORA_V2, + public_key = replacementKey, + ), + ) + testScope.advanceUntilIdle() + + val replacement = nodeManager.nodeDBbyNodeNum[retiredNum] + assertNotNull(replacement) + assertEquals(replacementKey, replacement.publicKey) + assertEquals("Replacement", replacement.user.long_name) + verifySuspend(mode = VerifyMode.exactly(1)) { nodeRepository.upsert(any()) } + verifySuspend(mode = VerifyMode.exactly(1)) { notificationManager.dispatch(any()) } + } + + @Test + fun `loadCachedNodeDB preserves retirement and suppresses reloaded stale replay`() { + // The repo still holds the retired node's row; a cache reload must keep the trusted retirement and must NOT + // resurrect the retired number into the live index, and a later stale replay at that number stays suppressed. + val retiredNum = validPk.noncanonicalNum(4500) + val retiredNode = makeKnownNode(retiredNum, validPk, "Retired") + every { nodeRepository.nodeDBbyNum } returns MutableStateFlow(mapOf(retiredNum to retiredNode)) + everySuspend { nodeRepository.getNodeDbSnapshot() } returns mapOf(retiredNum to retiredNode) + every { nodeRepository.myNodeInfo } returns MutableStateFlow(null) + + nodeManager.applyTrustedIdentityMigrations(listOf(retiredNum)) + enableDbWrites() + + nodeManager.loadCachedNodeDB() + testScope.advanceUntilIdle() + + // Retirement survived the reload AND the reloaded row was filtered out of the live index. + assertNull(nodeManager.nodeDBbyNodeNum[retiredNum]) + + // A stale keyless replay at the retired number is still suppressed after the reload. + nodeManager.handleReceivedUser( + retiredNum, + User(id = "!stale", long_name = "Stale replay", short_name = "STL", hw_model = HardwareModel.TLORA_V2), + ) + testScope.advanceUntilIdle() + + assertNull(nodeManager.nodeDBbyNodeNum[retiredNum]) + verifySuspend(mode = VerifyMode.not) { nodeRepository.upsert(any()) } + verifySuspend(mode = VerifyMode.not) { notificationManager.dispatch(any()) } + } + + @Test + fun `clear starts a new session without retired-number suppression`() { + val retiredNum = validPk.noncanonicalNum(4400) + nodeManager.applyTrustedIdentityMigrations(listOf(retiredNum)) + nodeManager.handleReceivedTelemetry(retiredNum, Telemetry(device_metrics = DeviceMetrics(battery_level = 10))) + assertNull(nodeManager.nodeDBbyNodeNum[retiredNum]) + + nodeManager.clear() + nodeManager.handleReceivedTelemetry(retiredNum, Telemetry(device_metrics = DeviceMetrics(battery_level = 90))) + + assertEquals(90, nodeManager.nodeDBbyNodeNum[retiredNum]?.deviceMetrics?.battery_level) + } + + // 1. Established same-key duplicate + + @Test + fun `established same-key stale replay is removed in memory without repository mutation`() { + val oldNum = validPk.noncanonicalNum(1000) + val newNum = validPk.canonicalNum() + nodeManager.updateNode(newNum) { makeKnownNode(newNum, validPk, "Migrated") } + nodeManager.updateNode(oldNum) { makeKnownNode(oldNum, validPk, "Stale Established") } + enableDbWrites() + + val staleUser = + User( + id = "!old", + long_name = "Stale", + short_name = "STL", + hw_model = HardwareModel.TLORA_V2, + public_key = validPk, + ) + nodeManager.handleReceivedUser(oldNum, staleUser) + testScope.advanceUntilIdle() + + assertNull(nodeManager.nodeDBbyNodeNum[oldNum]) + val canonical = nodeManager.nodeDBbyNodeNum[newNum] + assertNotNull(canonical) + assertEquals("Migrated", canonical!!.user.long_name) + assertEquals(validPk, canonical.publicKey) + verifyNoRepositoryDeletion() + verifySuspend(mode = VerifyMode.not) { nodeRepository.upsert(any()) } + verifySuspend(mode = VerifyMode.not) { notificationManager.dispatch(any()) } + } + + // 2. Placeholder duplicate + + @Test + fun `stale replay preserves no-key telemetry placeholder and canonical identity`() { + val oldNum = validPk.noncanonicalNum(1000) + val newNum = validPk.canonicalNum() + nodeManager.updateNode(newNum) { makeKnownNode(newNum, validPk, "Migrated") } + val placeholderKey = ByteString.EMPTY + val defaultId = NodeAddress.numToDefaultId(oldNum) + nodeManager.updateNode(oldNum) { + Node( + num = oldNum, + user = + User( + id = defaultId, + long_name = "Meshtastic ${defaultId.takeLast(4)}", + short_name = defaultId.takeLast(4), + hw_model = HardwareModel.UNSET, + public_key = placeholderKey, + ), + publicKey = placeholderKey, + position = ProtoPosition(latitude_i = 123, longitude_i = 456), + channel = 7, + ) + } + enableDbWrites() + + val staleUser = + User( + id = "!old", + long_name = "Stale", + short_name = "STL", + hw_model = HardwareModel.TLORA_V2, + public_key = validPk, + ) + nodeManager.handleReceivedUser(oldNum, staleUser) + testScope.advanceUntilIdle() + + val placeholder = nodeManager.nodeDBbyNodeNum[oldNum] + assertNotNull(placeholder) + assertEquals(defaultId, placeholder.user.id) + assertEquals(123, placeholder.position.latitude_i) + assertEquals(456, placeholder.position.longitude_i) + assertEquals(7, placeholder.channel) + val canonical = nodeManager.nodeDBbyNodeNum[newNum] + assertNotNull(canonical) + assertEquals("Migrated", canonical!!.user.long_name) + verifyNoRepositoryDeletion() + verifySuspend(mode = VerifyMode.not) { nodeRepository.upsert(any()) } + verifySuspend(mode = VerifyMode.not) { notificationManager.dispatch(any()) } + } + + // 3. Different-key conflict + + @Test + fun `different-key conflict preserves both nodes and emits no side effects`() { + val fromNum = 1000 + val otherNum = 2000 + val differentPk = ByteArray(32) { (it + 50).toByte() }.toByteString() + nodeManager.updateNode(otherNum) { makeKnownNode(otherNum, validPk, "Other") } + nodeManager.updateNode(fromNum) { makeKnownNode(fromNum, differentPk, "Established") } + enableDbWrites() + + val incomingUser = + User( + id = "!stale", + long_name = "Stale", + short_name = "STL", + hw_model = HardwareModel.TLORA_V2, + public_key = validPk, + ) + nodeManager.handleReceivedUser(fromNum, incomingUser) + testScope.advanceUntilIdle() + + val fromNode = nodeManager.nodeDBbyNodeNum[fromNum] + assertNotNull(fromNode) + assertEquals("Established", fromNode!!.user.long_name) + assertEquals(differentPk, fromNode.publicKey) + val otherNode = nodeManager.nodeDBbyNodeNum[otherNum] + assertNotNull(otherNode) + assertEquals("Other", otherNode!!.user.long_name) + verifyNoRepositoryDeletion() + verifySuspend(mode = VerifyMode.not) { nodeRepository.upsert(any()) } + verifySuspend(mode = VerifyMode.not) { notificationManager.dispatch(any()) } + } + + // 4. Multiple same-key matches + + @Test + fun `ambiguous noncanonical duplicates update from entry and preserve every candidate`() { + val nodeA = validPk.noncanonicalNum(1000) + val nodeB = validPk.noncanonicalNum(2000) + val fromNum = validPk.noncanonicalNum(3000) + nodeManager.updateNode(nodeA) { makeKnownNode(nodeA, validPk, "Alpha") } + nodeManager.updateNode(nodeB) { makeKnownNode(nodeB, validPk, "Bravo") } + nodeManager.updateNode(fromNum) { makeKnownNode(fromNum, validPk, "Before") } + enableDbWrites() + + val incomingUser = + User( + id = "!incoming", + long_name = "Incoming", + short_name = "INC", + hw_model = HardwareModel.TLORA_V2, + public_key = validPk, + ) + nodeManager.handleReceivedUser(fromNum, incomingUser) + testScope.advanceUntilIdle() + + // No candidate is removed, but future packets keep updating their own established fromNum entry. + assertNotNull(nodeManager.nodeDBbyNodeNum[nodeA]) + assertNotNull(nodeManager.nodeDBbyNodeNum[nodeB]) + assertEquals("Incoming", nodeManager.nodeDBbyNodeNum[fromNum]?.user?.long_name) + verifyNoRepositoryDeletion() + verifySuspend(mode = VerifyMode.not) { nodeRepository.upsert(any()) } + verifySuspend(mode = VerifyMode.not) { notificationManager.dispatch(any()) } + } + + @Test + fun `ambiguous noncanonical renumber installs identity into empty slot in memory`() { + val previousNum = validPk.noncanonicalNum(3100) + val fromNum = validPk.noncanonicalNum(3200) + nodeManager.updateNode(previousNum) { makeKnownNode(previousNum, validPk, "Previous") } + enableDbWrites() + + nodeManager.handleReceivedUser( + fromNum, + User( + id = "!incoming", + long_name = "Incoming", + short_name = "INC", + hw_model = HardwareModel.TLORA_V2, + public_key = validPk, + ), + ) + testScope.advanceUntilIdle() + + assertNotNull(nodeManager.nodeDBbyNodeNum[previousNum]) + val incoming = assertNotNull(nodeManager.nodeDBbyNodeNum[fromNum]) + assertEquals("Incoming", incoming.user.long_name) + assertEquals(validPk, incoming.publicKey) + verifyNoRepositoryDeletion() + verifySuspend(mode = VerifyMode.not) { nodeRepository.upsert(any()) } + verifySuspend(mode = VerifyMode.not) { notificationManager.dispatch(any()) } + } + + @Test + fun `ambiguous noncanonical renumber replaces telemetry placeholder without session lockout`() { + val previousNum = validPk.noncanonicalNum(3300) + val fromNum = validPk.noncanonicalNum(3400) + nodeManager.updateNode(previousNum) { makeKnownNode(previousNum, validPk, "Previous") } + nodeManager.handleReceivedPosition( + fromNum = fromNum, + myNodeNum = 9999, + p = ProtoPosition(latitude_i = 123, longitude_i = 456), + defaultTime = 1000L, + ) + enableDbWrites() + + nodeManager.handleReceivedUser( + fromNum, + User( + id = "!incoming", + long_name = "First Identity", + short_name = "ONE", + hw_model = HardwareModel.TLORA_V2, + public_key = validPk, + ), + ) + nodeManager.handleReceivedUser( + fromNum, + User( + id = "!incoming", + long_name = "Updated Identity", + short_name = "TWO", + hw_model = HardwareModel.TLORA_V2, + public_key = validPk, + ), + ) + testScope.advanceUntilIdle() + + assertNotNull(nodeManager.nodeDBbyNodeNum[previousNum]) + val incoming = assertNotNull(nodeManager.nodeDBbyNodeNum[fromNum]) + assertEquals("Updated Identity", incoming.user.long_name) + assertEquals(validPk, incoming.publicKey) + assertEquals(123, incoming.position.latitude_i) + assertEquals(456, incoming.position.longitude_i) + verifyNoRepositoryDeletion() + verifySuspend(mode = VerifyMode.not) { nodeRepository.upsert(any()) } + verifySuspend(mode = VerifyMode.not) { notificationManager.dispatch(any()) } + } + + // 5. Local authoritative renumber + + @Test + fun `local authoritative renumber removes old same-key entries in memory upserts local no notification`() { + val localNum = 5000 + val otherNum = 6000 + nodeManager.setMyNodeNum(localNum) + nodeManager.updateNode(otherNum) { makeKnownNode(otherNum, validPk, "Old Same-Key") } + enableDbWrites() + + val localUser = + User( + id = "!local", + long_name = "Local Node", + short_name = "LCL", + hw_model = HardwareModel.TLORA_V2, + public_key = validPk, + ) + nodeManager.handleReceivedUser(localNum, localUser) + testScope.advanceUntilIdle() + + assertNull(nodeManager.nodeDBbyNodeNum[otherNum]) + val localNode = nodeManager.nodeDBbyNodeNum[localNum] + assertNotNull(localNode) + assertEquals("Local Node", localNode!!.user.long_name) + assertEquals(validPk, localNode.publicKey) + verifyNoRepositoryDeletion() + verifySuspend(mode = VerifyMode.exactly(1)) { nodeRepository.upsert(any()) } + verifySuspend(mode = VerifyMode.not) { notificationManager.dispatch(any()) } + } + + // 6. Genuine new node + + @Test + fun `genuine new node fires exactly one upsert and exactly one notification`() { + val newNodeNum = 3000 + val newPk = ByteArray(32) { (it + 100).toByte() }.toByteString() + val newUser = + User( + id = "!newnode", + long_name = "New Node", + short_name = "NEW", + hw_model = HardwareModel.TLORA_V2, + public_key = newPk, + ) + enableDbWrites() + + val captured = mutableListOf() + everySuspend { notificationManager.dispatch(capture(captured)) } returns true + + nodeManager.handleReceivedUser(newNodeNum, newUser) + testScope.advanceUntilIdle() + + val result = nodeManager.nodeDBbyNodeNum[newNodeNum] + assertNotNull(result) + assertEquals("New Node", result!!.user.long_name) + assertEquals(newPk, result.publicKey) + verifyNoRepositoryDeletion() + verifySuspend(mode = VerifyMode.exactly(1)) { nodeRepository.upsert(any()) } + // Strengthen: capture the dispatched Notification and verify payload + routing fields, not just the call count. + assertEquals(1, captured.size) + val n = captured.first() + assertEquals(newNodeNum, n.id) + assertEquals("New Node", n.message) + assertEquals(Notification.Category.NodeEvent, n.category) + assertEquals("meshtastic://meshtastic/nodes/$newNodeNum", n.deepLinkUri) + } + + // 7. Invalid / malformed keys (null, empty, ERROR) are treated as NoMatch + + @Test + fun `invalid public keys skip identity matching and update normally`() { + val existingNum = 7000 + val existingPk = ByteArray(32) { (it + 1).toByte() }.toByteString() + nodeManager.updateNode(existingNum) { makeKnownNode(existingNum, existingPk, "Canonical") } + enableDbWrites() + + // Three packets at a different number without a validated public-key correlation hint. + // None of them should match the canonical node at 7000 or trigger stale cleanup. + val targetNum = 8000 + + // (a) null key + nodeManager.handleReceivedUser( + targetNum, + User(id = "!a", long_name = "A", short_name = "A", hw_model = HardwareModel.TLORA_V2), + ) + testScope.advanceUntilIdle() + + // (b) empty key + nodeManager.handleReceivedUser( + targetNum, + User( + id = "!b", + long_name = "B", + short_name = "B", + hw_model = HardwareModel.TLORA_V2, + public_key = ByteString.EMPTY, + ), + ) + testScope.advanceUntilIdle() + + // (c) ERROR key + nodeManager.handleReceivedUser( + targetNum, + User( + id = "!c", + long_name = "C", + short_name = "C", + hw_model = HardwareModel.TLORA_V2, + public_key = Node.ERROR_BYTE_STRING, + ), + ) + testScope.advanceUntilIdle() + + // Canonical node untouched (no stale cleanup). + assertNotNull(nodeManager.nodeDBbyNodeNum[existingNum]) + } + + // 8. Same number same key is a normal update, not a cross-number stale + + @Test + fun `same number same key updates normally without stale classification`() { + val nodeNum = 9000 + nodeManager.updateNode(nodeNum) { makeKnownNode(nodeNum, validPk, "Original") } + enableDbWrites() + + val updatedUser = + User( + id = "!${nodeNum.toString(16)}", + long_name = "Updated", + short_name = "UPD", + hw_model = HardwareModel.TLORA_V2, + public_key = validPk, + ) + nodeManager.handleReceivedUser(nodeNum, updatedUser) + testScope.advanceUntilIdle() + + val result = nodeManager.nodeDBbyNodeNum[nodeNum] + assertNotNull(result) + assertEquals("Updated", result!!.user.long_name) + assertEquals(validPk, result.publicKey) + verifyNoRepositoryDeletion() + verifySuspend(mode = VerifyMode.exactly(1)) { nodeRepository.upsert(any()) } + } + + // 9. Packet-driven duplicate cleanup stays entirely in memory. + + @Test + fun `stale duplicate never deletes or upserts a repository row`() { + val oldNum = validPk.noncanonicalNum(1000) + val newNum = validPk.canonicalNum() + nodeManager.updateNode(newNum) { makeKnownNode(newNum, validPk, "Canonical") } + nodeManager.updateNode(oldNum) { makeKnownNode(oldNum, validPk, "Stale Dup") } + enableDbWrites() + + val staleUser = + User( + id = "!old", + long_name = "Stale Packet", + short_name = "STL", + hw_model = HardwareModel.TLORA_V2, + public_key = validPk, + ) + nodeManager.handleReceivedUser(oldNum, staleUser) + testScope.advanceUntilIdle() + + assertNull(nodeManager.nodeDBbyNodeNum[oldNum]) + verifyNoRepositoryDeletion() + verifySuspend(mode = VerifyMode.not) { nodeRepository.upsert(any()) } + } + + // 10. No notification for stale / conflict / local outcomes + + @Test + fun `notification never fires for stale conflict or local outcomes`() { + // Stale: stale packet at old number with established canonical elsewhere. + val staleOld = validPk.noncanonicalNum(1000) + val staleCanonical = validPk.canonicalNum() + nodeManager.updateNode(staleCanonical) { makeKnownNode(staleCanonical, validPk, "Canonical") } + nodeManager.updateNode(staleOld) { makeKnownNode(staleOld, validPk, "Old") } + nodeManager.handleReceivedUser( + staleOld, + User(id = "!s", long_name = "S", short_name = "S", hw_model = HardwareModel.TLORA_V2, public_key = validPk), + ) + + // Conflict: different established identity at fromNum. + val conflictFrom = 3000 + val conflictOther = 4000 + val conflictPk = ByteArray(32) { (it + 50).toByte() }.toByteString() + nodeManager.updateNode(conflictOther) { makeKnownNode(conflictOther, validPk, "Other") } + nodeManager.updateNode(conflictFrom) { makeKnownNode(conflictFrom, conflictPk, "Established") } + nodeManager.handleReceivedUser( + conflictFrom, + User(id = "!c", long_name = "C", short_name = "C", hw_model = HardwareModel.TLORA_V2, public_key = validPk), + ) + + // Local: fromNum == myNodeNum, local-link authoritative update. + val localNum = 5000 + val localGhost = 6000 + nodeManager.setMyNodeNum(localNum) + nodeManager.updateNode(localGhost) { makeKnownNode(localGhost, validPk, "Ghost") } + nodeManager.handleReceivedUser( + localNum, + User(id = "!l", long_name = "L", short_name = "L", hw_model = HardwareModel.TLORA_V2, public_key = validPk), + ) + + testScope.advanceUntilIdle() + + // No notification fired for any of the three outcomes. + verifySuspend(mode = VerifyMode.not) { notificationManager.dispatch(any()) } + } + + // 11. byId consistency after stale removal — when stale and canonical share a user ID, + // removing the stale node must not orphan the canonical node in the byId index. + + @Test + fun `byId index points to canonical survivor after stale removal shares user id`() { + val oldNum = validPk.noncanonicalNum(1000) + val newNum = validPk.canonicalNum() + val sharedUserId = "!shared" + nodeManager.updateNode(newNum) { + Node( + num = newNum, + user = + User( + id = sharedUserId, + long_name = "Canonical", + short_name = "CAN", + hw_model = HardwareModel.TLORA_V2, + public_key = validPk, + ), + publicKey = validPk, + ) + } + nodeManager.updateNode(oldNum) { + Node( + num = oldNum, + user = + User( + id = sharedUserId, + long_name = "Stale", + short_name = "STL", + hw_model = HardwareModel.TLORA_V2, + public_key = validPk, + ), + publicKey = validPk, + ) + } + enableDbWrites() + + val staleUser = + User( + id = sharedUserId, + long_name = "Stale", + short_name = "STL", + hw_model = HardwareModel.TLORA_V2, + public_key = validPk, + ) + nodeManager.handleReceivedUser(oldNum, staleUser) + testScope.advanceUntilIdle() + + assertNull(nodeManager.nodeDBbyNodeNum[oldNum]) + val survivor = nodeManager.getNodeById(sharedUserId) + assertNotNull(survivor) + assertEquals(newNum, survivor!!.num) + } + + // ── Additional identity reconciliation tests ──────────────────────────────── + + // 12. Malformed key lengths (1, 31, 33) are rejected + + @Test + fun `malformed key lengths 1 31 33 are rejected and skip identity matching`() { + val nodeNum = 1234 + val shortKey = ByteArray(1) { 1 }.toByteString() + val almostKey = ByteArray(31) { 2 }.toByteString() + val longKey = ByteArray(33) { 3 }.toByteString() + + // All malformed keys should be treated as no-key (normal update path). + nodeManager.handleReceivedUser( + nodeNum, + User( + id = "!short", + long_name = "S", + short_name = "S", + hw_model = HardwareModel.TLORA_V2, + public_key = shortKey, + ), + ) + var result = nodeManager.nodeDBbyNodeNum[nodeNum] + assertEquals(ByteString.EMPTY, result!!.publicKey) + + nodeManager.handleReceivedUser( + nodeNum, + User( + id = "!almost", + long_name = "A", + short_name = "A", + hw_model = HardwareModel.TLORA_V2, + public_key = almostKey, + ), + ) + result = nodeManager.nodeDBbyNodeNum[nodeNum] + assertEquals(ByteString.EMPTY, result!!.publicKey) + + nodeManager.handleReceivedUser( + nodeNum, + User( + id = "!long", + long_name = "L", + short_name = "L", + hw_model = HardwareModel.TLORA_V2, + public_key = longKey, + ), + ) + result = nodeManager.nodeDBbyNodeNum[nodeNum] + assertEquals(ByteString.EMPTY, result!!.publicKey) + } + + // 13. Invalid Node.publicKey falls back to valid User.public_key + + @Test + fun `invalid Node publicKey falls back to valid User public_key`() { + val nodeNum = 1234 + val validUserKey = ByteArray(32) { 42 }.toByteString() + val invalidNodeKey = ByteArray(16) { 1 }.toByteString() // Wrong size + + nodeManager.handleReceivedUser( + nodeNum, + User( + id = "!test", + long_name = "Test", + short_name = "T", + hw_model = HardwareModel.TLORA_V2, + public_key = validUserKey, + ), + ) + // Manually set an invalid Node.publicKey + nodeManager.updateNode(nodeNum) { it.copy(publicKey = invalidNodeKey) } + + // Public-key correlation should fall back to User.public_key. + val node = nodeManager.nodeDBbyNodeNum[nodeNum] + val resolvedKey = nodeManager.resolveNodePublicKeyHint(node!!) + assertEquals(validUserKey, resolvedKey) + } + + // 14. UNSET hardware with a different valid key is preserved (conflict) + + @Test + fun `UNSET hardware with different valid key is preserved as conflict`() { + val canonicalNum = 1000 + val conflictNum = 2000 + val canonicalKey = ByteArray(32) { 10 }.toByteString() + val conflictKey = ByteArray(32) { 20 }.toByteString() + + // Canonical node with established identity + nodeManager.updateNode(canonicalNum) { makeKnownNode(canonicalNum, canonicalKey, "Canonical") } + + // Conflict node with UNSET hardware but different valid key + nodeManager.updateNode(conflictNum) { + Node( + num = conflictNum, + user = + User( + id = "!conflict", + long_name = "Conflict", + short_name = "CON", + hw_model = HardwareModel.UNSET, + public_key = conflictKey, + ), + publicKey = conflictKey, + ) + } + + // Packet at conflictNum with canonicalKey should be treated as conflict (preserve both) + nodeManager.handleReceivedUser( + conflictNum, + User( + id = "!c", + long_name = "C", + short_name = "C", + hw_model = HardwareModel.TLORA_V2, + public_key = canonicalKey, + ), + ) + + // Both nodes should still exist + assertNotNull(nodeManager.nodeDBbyNodeNum[canonicalNum]) + assertNotNull(nodeManager.nodeDBbyNodeNum[conflictNum]) + assertEquals(canonicalKey, nodeManager.nodeDBbyNodeNum[canonicalNum]!!.publicKey) + assertEquals(conflictKey, nodeManager.nodeDBbyNodeNum[conflictNum]!!.publicKey) + } + + // 15. Custom incomplete identity is preserved + + @Test + fun `custom incomplete identity is preserved over default incoming`() { + val nodeNum = 1234 + val customUser = + User( + id = "!custom", + long_name = "My Custom Name", + short_name = "MCN", + hw_model = HardwareModel.UNSET, // Incomplete hardware + ) + nodeManager.updateNode(nodeNum) { it.copy(user = customUser) } + + // Incoming default user should not overwrite custom identity + val defaultUser = + User( + id = NodeAddress.numToDefaultId(nodeNum), + long_name = "Meshtastic ${nodeNum.toHex().takeLast(4)}", + short_name = nodeNum.toHex().takeLast(4), + hw_model = HardwareModel.UNSET, + ) + nodeManager.handleReceivedUser(nodeNum, defaultUser) + + val result = nodeManager.nodeDBbyNodeNum[nodeNum] + assertEquals("My Custom Name", result!!.user.long_name) + assertEquals("MCN", result.user.short_name) + } + + // 16. Three nodes sharing one user ID + + @Test + fun `three nodes sharing one user ID selects deterministic representative`() { + val userId = "!shared" + val node1 = 1000 + val node2 = 2000 + val node3 = 3000 + + // All three have the same user ID but different node numbers + nodeManager.updateNode(node1) { + Node( + num = node1, + user = User(id = userId, long_name = "N1", short_name = "N1", hw_model = HardwareModel.UNSET), + ) + } + nodeManager.updateNode(node2) { + Node( + num = node2, + user = User(id = userId, long_name = "N2", short_name = "N2", hw_model = HardwareModel.TLORA_V2), + ) + } + nodeManager.updateNode(node3) { + Node( + num = node3, + user = User(id = userId, long_name = "N3", short_name = "N3", hw_model = HardwareModel.UNSET), + ) + } + + // byId should point to node2 (non-placeholder, lowest among non-placeholders) + val representative = nodeManager.getNodeById(userId) + assertNotNull(representative) + assertEquals(node2, representative!!.num) + } + + // 17. Old-ID survivor restoration after put + + @Test + fun `old ID survivor restored after put changes user ID`() { + val nodeNum = 1234 + val oldUserId = "!old" + val newUserId = "!new" + val survivorNum = 5678 + + // Two nodes with old user ID + nodeManager.updateNode(nodeNum) { + Node( + num = nodeNum, + user = User(id = oldUserId, long_name = "Old", short_name = "OLD", hw_model = HardwareModel.TLORA_V2), + ) + } + nodeManager.updateNode(survivorNum) { + Node( + num = survivorNum, + user = + User(id = oldUserId, long_name = "Survivor", short_name = "SUR", hw_model = HardwareModel.TLORA_V2), + ) + } + + // Change nodeNum's user ID + nodeManager.updateNode(nodeNum) { + it.copy(user = it.user.copy(id = newUserId, long_name = "New", short_name = "NEW")) + } + + // byId for oldUserId should now point to survivorNum + val survivor = nodeManager.getNodeById(oldUserId) + assertNotNull(survivor) + assertEquals(survivorNum, survivor!!.num) + + // byId for newUserId should point to nodeNum + val newNode = nodeManager.getNodeById(newUserId) + assertNotNull(newNode) + assertEquals(nodeNum, newNode!!.num) + } + + // 18. fromByNum insertion-order independence + + @Test + fun `fromByNum is independent of insertion order`() { + val userId = "!shared" + val node1 = 1000 + val node2 = 2000 + + val nodes12 = + mapOf( + node1 to + Node( + num = node1, + user = User(id = userId, long_name = "N1", short_name = "N1", hw_model = HardwareModel.UNSET), + ), + node2 to + Node( + num = node2, + user = User( + id = userId, + long_name = "N2", + short_name = "N2", + hw_model = HardwareModel.TLORA_V2, + ), + ), + ) + val nodes21 = + mapOf( + node2 to + Node( + num = node2, + user = User( + id = userId, + long_name = "N2", + short_name = "N2", + hw_model = HardwareModel.TLORA_V2, + ), + ), + node1 to + Node( + num = node1, + user = User(id = userId, long_name = "N1", short_name = "N1", hw_model = HardwareModel.UNSET), + ), + ) + + val index12 = NodeManagerImpl.NodeIndex.fromByNum(nodes12) + val index21 = NodeManagerImpl.NodeIndex.fromByNum(nodes21) + + // Both should produce the same byId representative (node2, non-placeholder) + assertEquals(index12.byId[userId]!!.num, index21.byId[userId]!!.num) + assertEquals(node2, index12.byId[userId]!!.num) + } + + // 19. Preferred canonical mapping after stale removal + + @Test + fun `preferred canonical remains mapped after stale removal`() { + val userId = "!shared" + val canonicalKey = ByteArray(32) { 41 }.toByteString() + val competingKey = ByteArray(32) { 42 }.toByteString() + val preferredNum = canonicalKey.canonicalNum() + val competingNum = 500 + val staleNum = canonicalKey.noncanonicalNum(1000) + + nodeManager.updateNode(competingNum) { + Node( + num = competingNum, + user = + User( + id = userId, + long_name = "Fallback Winner", + short_name = "FBK", + hw_model = HardwareModel.TLORA_V2, + public_key = competingKey, + ), + publicKey = competingKey, + ) + } + nodeManager.updateNode(preferredNum) { + Node( + num = preferredNum, + user = + User( + id = userId, + long_name = "Preferred", + short_name = "PRE", + hw_model = HardwareModel.TLORA_V2, + public_key = canonicalKey, + ), + publicKey = canonicalKey, + ) + } + nodeManager.updateNode(staleNum) { + Node( + num = staleNum, + user = + User( + id = userId, + long_name = "Stale", + short_name = "STL", + hw_model = HardwareModel.TLORA_V2, + public_key = canonicalKey, + ), + publicKey = canonicalKey, + ) + } + + nodeManager.handleReceivedUser( + staleNum, + User( + id = userId, + long_name = "Stale Replay", + short_name = "STL", + hw_model = HardwareModel.TLORA_V2, + public_key = canonicalKey, + ), + ) + + assertNull(nodeManager.nodeDBbyNodeNum[staleNum]) + assertNotNull(nodeManager.nodeDBbyNodeNum[competingNum]) + assertNotNull(nodeManager.nodeDBbyNodeNum[preferredNum]) + val representative = nodeManager.getNodeById(userId) + assertNotNull(representative) + assertEquals(preferredNum, representative!!.num) + } + + // 20. Accepted local node remains the byId representative + + @Test + fun `accepted local node remains byId representative`() { + val userId = "!shared" + val remoteNum = 1000 + val localNum = 2000 + + nodeManager.setMyNodeNum(localNum) + nodeManager.updateNode(localNum) { + Node( + num = localNum, + user = User(id = userId, long_name = "Local", short_name = "LOC", hw_model = HardwareModel.TLORA_V2), + ) + } + nodeManager.updateNode(remoteNum) { + Node( + num = remoteNum, + user = User(id = userId, long_name = "Remote", short_name = "REM", hw_model = HardwareModel.TLORA_V2), + ) + } + + assertEquals(remoteNum, nodeManager.getNodeById(userId)!!.num) + nodeManager.handleReceivedUser( + localNum, + User(id = userId, long_name = "Local Updated", short_name = "LOC", hw_model = HardwareModel.TLORA_V2), + ) + + assertNotNull(nodeManager.nodeDBbyNodeNum[remoteNum]) + val representative = nodeManager.getNodeById(userId) + assertNotNull(representative) + assertEquals(localNum, representative!!.num) + } + + // 21. Exact notification title, message, ID, category, and deep link + + @Test + fun `notification has exact title message ID category and deep link`() { + enableDbWrites() + val nodeNum = 1234 + val user = User(id = "!test", long_name = "Test User", short_name = "TST", hw_model = HardwareModel.TLORA_V2) + + nodeManager.handleReceivedUser(nodeNum, user) + testScope.advanceUntilIdle() + + verifySuspend { + notificationManager.dispatch( + Notification( + title = "New node seen: TST", + message = "Test User", + category = Notification.Category.NodeEvent, + id = nodeNum, + deepLinkUri = "meshtastic://meshtastic/nodes/$nodeNum", + ), + ) + } + } + + // 22. Incoming at the canonical num reconciles in memory only and preserves its placeholder history. + + @Test + fun `incoming canonical replaces own placeholder and removes noncanonical in memory only`() { + val canonicalKey = ByteArray(32) { (it + 7).toByte() }.toByteString() + val canonicalNum = canonicalKey.canonicalNum() + val staleNum = canonicalKey.noncanonicalNum(7777) + // Pre-existing entry at the noncanonical num carries the same key. + nodeManager.updateNode(staleNum) { makeKnownNode(staleNum, canonicalKey, "Ghost") } + // Placeholder at the canonical num so the incoming slot is "open" for reconciliation. + val placeholderId = NodeAddress.numToDefaultId(canonicalNum) + nodeManager.updateNode(canonicalNum) { + Node( + num = canonicalNum, + user = + User( + id = placeholderId, + long_name = "Meshtastic ${placeholderId.takeLast(4)}", + short_name = placeholderId.takeLast(4), + hw_model = HardwareModel.UNSET, + ), + publicKey = ByteString.EMPTY, + position = ProtoPosition(latitude_i = 111, longitude_i = 222), + channel = 4, + ) + } + enableDbWrites() + + nodeManager.handleReceivedUser( + canonicalNum, + User( + id = "!canonical", + long_name = "Canonical", + short_name = "CAN", + hw_model = HardwareModel.TLORA_V2, + public_key = canonicalKey, + ), + ) + testScope.advanceUntilIdle() + + assertNull(nodeManager.nodeDBbyNodeNum[staleNum]) + val canonical = nodeManager.nodeDBbyNodeNum[canonicalNum] + assertNotNull(canonical) + assertEquals("Canonical", canonical!!.user.long_name) + assertEquals(canonicalKey, canonical.publicKey) + assertEquals(111, canonical.position.latitude_i) + assertEquals(222, canonical.position.longitude_i) + assertEquals(4, canonical.channel) + verifyNoRepositoryDeletion() + verifySuspend(mode = VerifyMode.not) { nodeRepository.upsert(any()) } + verifySuspend(mode = VerifyMode.not) { notificationManager.dispatch(any()) } + } + + // 23. Incoming canonical absent from index entirely creates from packet, removes noncanonical, no notify. + + @Test + fun `incoming canonical absent from index creates from packet removes noncanonical no notify`() { + val canonicalKey = ByteArray(32) { (it + 17).toByte() }.toByteString() + val canonicalNum = canonicalKey.canonicalNum() + val staleNum = canonicalKey.noncanonicalNum(8888) + nodeManager.updateNode(staleNum) { makeKnownNode(staleNum, canonicalKey, "Stale Noncanonical") } + enableDbWrites() + + nodeManager.handleReceivedUser( + canonicalNum, + User( + id = "!fresh", + long_name = "Fresh Canonical", + short_name = "FRS", + hw_model = HardwareModel.TLORA_V2, + public_key = canonicalKey, + ), + ) + testScope.advanceUntilIdle() + + assertNull(nodeManager.nodeDBbyNodeNum[staleNum]) + val canonical = nodeManager.nodeDBbyNodeNum[canonicalNum] + assertNotNull(canonical) + assertEquals("Fresh Canonical", canonical!!.user.long_name) + assertEquals(canonicalKey, canonical.publicKey) + verifyNoRepositoryDeletion() + verifySuspend(mode = VerifyMode.not) { nodeRepository.upsert(any()) } + verifySuspend(mode = VerifyMode.not) { notificationManager.dispatch(any()) } + } + + // 24. Neither num canonical preserves both with no side effects. + + @Test + fun `neither num canonical preserves both no persistence no notification`() { + val key = ByteArray(32) { (it + 23).toByte() }.toByteString() + // Pick two nums guaranteed distinct from the canonical num and from each other. + val canonicalNum = key.canonicalNum() + val a = if (canonicalNum != 1111) 1111 else 1112 + val b = if (canonicalNum != 2222) 2222 else 2223 + + nodeManager.updateNode(a) { makeKnownNode(a, key, "Alpha") } + nodeManager.updateNode(b) { makeKnownNode(b, key, "Bravo") } + enableDbWrites() + + nodeManager.handleReceivedUser( + a, + User( + id = "!a", + long_name = "Alpha Packet", + short_name = "ALA", + hw_model = HardwareModel.TLORA_V2, + public_key = key, + ), + ) + testScope.advanceUntilIdle() + + // Both are preserved; the incoming packet still updates its own established same-key slot in memory. + val alpha = nodeManager.nodeDBbyNodeNum[a] + val bravo = nodeManager.nodeDBbyNodeNum[b] + assertNotNull(alpha) + assertNotNull(bravo) + assertEquals("Alpha Packet", alpha!!.user.long_name) + assertEquals("Bravo", bravo!!.user.long_name) + verifyNoRepositoryDeletion() + verifySuspend(mode = VerifyMode.not) { nodeRepository.upsert(any()) } + verifySuspend(mode = VerifyMode.not) { notificationManager.dispatch(any()) } + } + + // 25. Different established valid key at the canonical num is preserved as a conflict. + + @Test + fun `different established key at canonical num preserved as conflict`() { + val canonicalKey = ByteArray(32) { (it + 31).toByte() }.toByteString() + val establishedKey = ByteArray(32) { (it + 32).toByte() }.toByteString() + val canonicalNum = canonicalKey.canonicalNum() + val noncanonicalNum = canonicalKey.noncanonicalNum(3333) + nodeManager.updateNode(noncanonicalNum) { makeKnownNode(noncanonicalNum, canonicalKey, "Noncanonical Holder") } + // Canonical num is occupied by a node with a DIFFERENT valid key. + nodeManager.updateNode(canonicalNum) { makeKnownNode(canonicalNum, establishedKey, "Established") } + enableDbWrites() + + nodeManager.handleReceivedUser( + canonicalNum, + User( + id = "!c", + long_name = "Claimant", + short_name = "CLM", + hw_model = HardwareModel.TLORA_V2, + public_key = canonicalKey, + ), + ) + testScope.advanceUntilIdle() + + // Conflict: both preserved with their original keys; no persistence, no notification. + val atCanonical = nodeManager.nodeDBbyNodeNum[canonicalNum] + val atNoncanonical = nodeManager.nodeDBbyNodeNum[noncanonicalNum] + assertNotNull(atCanonical) + assertNotNull(atNoncanonical) + assertEquals(establishedKey, atCanonical!!.publicKey) + assertEquals("Established", atCanonical.user.long_name) + assertEquals(canonicalKey, atNoncanonical!!.publicKey) + verifyNoRepositoryDeletion() + verifySuspend(mode = VerifyMode.not) { nodeRepository.upsert(any()) } + verifySuspend(mode = VerifyMode.not) { notificationManager.dispatch(any()) } + } + + // 26. Local classification and the node index share one atomic CAS state. + + @Test + fun `local number change during User reduction retries classification atomically`() { + val key = ByteArray(32) { (it + 37).toByte() }.toByteString() + val previousNum = key.canonicalNum() + val newLocalNum = key.noncanonicalNum(9999) + nodeManager.updateNode(previousNum) { makeKnownNode(previousNum, key, "Previous") } + enableDbWrites() + nodeManager.receivedUserReductionHook = { + nodeManager.receivedUserReductionHook = null + nodeManager.setMyNodeNum(newLocalNum) + } + + nodeManager.handleReceivedUser( + newLocalNum, + User( + id = "!local9999", + long_name = "Current Local", + short_name = "LOC", + hw_model = HardwareModel.TLORA_V2, + public_key = key, + ), + ) + testScope.advanceUntilIdle() + + assertNull(nodeManager.nodeDBbyNodeNum[previousNum]) + assertEquals("Current Local", nodeManager.nodeDBbyNodeNum[newLocalNum]?.user?.long_name) + assertEquals(newLocalNum, nodeManager.myNodeNum.value) + verifyNoRepositoryDeletion() + verifySuspend(mode = VerifyMode.exactly(1)) { nodeRepository.upsert(any()) } + verifySuspend(mode = VerifyMode.not) { notificationManager.dispatch(any()) } + } + + @Test + fun `reverse ID and public-key indexes stay consistent across put replacement and remove`() { + val keyA = ByteArray(32) { 11 }.toByteString() + val keyB = ByteArray(32) { 12 }.toByteString() + val sharedId = "!shared" + val replacementId = "!replacement" + var index = NodeManagerImpl.NodeIndex() + index = index.put(1, makeKnownNode(1, keyA).copy(user = makeKnownNode(1, keyA).user.copy(id = sharedId))) + index = index.put(2, makeKnownNode(2, keyA).copy(user = makeKnownNode(2, keyA).user.copy(id = sharedId))) + + assertEquals?>(setOf(1, 2), index.candidateNumsById[sharedId]) + assertEquals?>(setOf(1, 2), index.candidateNumsByPublicKey[keyA]) + assertEquals(1, index.byId[sharedId]?.num) + + val replacement = makeKnownNode(1, keyB).copy(user = makeKnownNode(1, keyB).user.copy(id = replacementId)) + index = index.put(1, replacement) + assertEquals?>(setOf(2), index.candidateNumsById[sharedId]) + assertEquals?>(setOf(1), index.candidateNumsById[replacementId]) + assertEquals?>(setOf(2), index.candidateNumsByPublicKey[keyA]) + assertEquals?>(setOf(1), index.candidateNumsByPublicKey[keyB]) + assertEquals(2, index.byId[sharedId]?.num) + assertEquals(1, index.byId[replacementId]?.num) + + index = index.remove(2) + assertNull(index.candidateNumsById[sharedId]) + assertNull(index.candidateNumsByPublicKey[keyA]) + assertNull(index.byId[sharedId]) + assertEquals(1, index.byId[replacementId]?.num) + } + + private fun Int.toHex(): String = this.toString(16).padStart(8, '0') + + private fun userWithKey(key: ByteString, longName: String, shortName: String): User = User( + id = key.hex(), + long_name = longName, + short_name = shortName, + hw_model = HardwareModel.TLORA_V2, + public_key = key, + ) + + // ---------- Retired-node notification behavior ---------- + + @Test + fun `applyTrustedIdentityMigrations cancels notifications for each retired number`() = testScope.runTest { + // Setup: create nodes with distinct identities + val num1 = 1230588578 + val num2 = 1111111111 + val key1 = ByteArray(32) { 0x01 }.toByteString() + val key2 = ByteArray(32) { 0x02 }.toByteString() + nodeManager.handleReceivedUser(num1, userWithKey(key1, "Node1", "N1"), manuallyVerified = false) + nodeManager.handleReceivedUser(num2, userWithKey(key2, "Node2", "N2"), manuallyVerified = false) + advanceUntilIdle() + + // Act + nodeManager.applyTrustedIdentityMigrations(listOf(num1, num2)) + advanceUntilIdle() + + // Assert + verify(VerifyMode.atLeast(1)) { notificationManager.cancel(num1) } + verify(VerifyMode.atLeast(1)) { notificationManager.cancel(num2) } + } + + @Test + fun `trusted migration commits retirement before notification cancellation`() = testScope.runTest { + val num = 310000000 + val key = ByteArray(32) { 0x12 }.toByteString() + nodeManager.handleReceivedUser(num, userWithKey(key, "Retiring", "RT"), manuallyVerified = false) + advanceUntilIdle() + var nodePresentAtCancellation: Boolean? = null + every { notificationManager.cancel(num) } calls + { + nodePresentAtCancellation = num in nodeManager.nodeDBbyNodeNum + } + + nodeManager.applyTrustedIdentityMigrations(listOf(num)) + + assertEquals(false, nodePresentAtCancellation, "retirement must commit before cancellation side effects") + assertNull(nodeManager.nodeDBbyNodeNum[num]) + verify(VerifyMode.exactly(1)) { notificationManager.cancel(num) } + } + + @Test + fun `notification skipped when number retired between select and dispatch`() = testScope.runTest { + val num = 4000000000.toInt() + val key = ByteArray(32) { 0x03 }.toByteString() + val titleStarted = CompletableDeferred() + val releaseTitle = CompletableDeferred() + val dispatched = mutableListOf() + everySuspend { notificationManager.dispatch(capture(dispatched)) } returns true + nodeManager.notificationTitleFormatter = { shortName -> + titleStarted.complete(Unit) + releaseTitle.await() + "New node seen: $shortName" + } + + nodeManager.handleReceivedUser(num, userWithKey(key, "NewNode", "NN"), manuallyVerified = false) + titleStarted.await() + nodeManager.applyTrustedIdentityMigrations(listOf(num)) + releaseTitle.complete(Unit) + advanceUntilIdle() + + assertTrue(dispatched.none { it.id == num && it.message == "NewNode" }) + } + + @Test + fun `notification skipped when number becomes local node before dispatch`() = testScope.runTest { + val num = 5000000000.toInt() + val key = ByteArray(32) { 0x04 }.toByteString() + val titleStarted = CompletableDeferred() + val releaseTitle = CompletableDeferred() + val dispatched = mutableListOf() + everySuspend { notificationManager.dispatch(capture(dispatched)) } returns true + nodeManager.notificationTitleFormatter = { shortName -> + titleStarted.complete(Unit) + releaseTitle.await() + "New node seen: $shortName" + } + + nodeManager.handleReceivedUser(num, userWithKey(key, "NewNode", "NN"), manuallyVerified = false) + titleStarted.await() + nodeManager.setMyNodeNum(num) + releaseTitle.complete(Unit) + advanceUntilIdle() + + assertTrue(dispatched.none { it.id == num && it.message == "NewNode" }) + } + + @Test + fun `notification skipped when identity changed before dispatch`() = testScope.runTest { + val num = 6000000000.toInt() + val key1 = ByteArray(32) { 0x05 }.toByteString() + val key2 = ByteArray(32) { 0x06 }.toByteString() + val titleStarted = CompletableDeferred() + val releaseTitle = CompletableDeferred() + val dispatched = mutableListOf() + everySuspend { notificationManager.dispatch(capture(dispatched)) } returns true + nodeManager.notificationTitleFormatter = { shortName -> + titleStarted.complete(Unit) + releaseTitle.await() + "New node seen: $shortName" + } + + nodeManager.handleReceivedUser(num, userWithKey(key1, "First", "F1"), manuallyVerified = false) + titleStarted.await() + nodeManager.updateNode(num) { makeKnownNode(num, key2, "Other") } + releaseTitle.complete(Unit) + advanceUntilIdle() + + val node = nodeManager.nodeDBbyNodeNum[num] + assertEquals("Other", node?.user?.long_name) + assertTrue(dispatched.none { it.message == "First" }, "notification for 'First' must be suppressed") + } + + @Test + fun `retired number with same key suppressed even without canonical key candidate`() = testScope.runTest { + val oldNum = 7000000000.toInt() + val key = ByteArray(32) { 0x07 }.toByteString() + // Create node at oldNum with key, then retire it via migration + nodeManager.handleReceivedUser(oldNum, userWithKey(key, "OldNode", "ON"), manuallyVerified = false) + advanceUntilIdle() + nodeManager.applyTrustedIdentityMigrations(listOf(oldNum)) + advanceUntilIdle() + val replayDispatches = mutableListOf() + everySuspend { notificationManager.dispatch(capture(replayDispatches)) } returns true + // Now replay: the canonical number (crc32(key)) has NOT appeared yet + nodeManager.handleReceivedUser(oldNum, userWithKey(key, "Replay", "RP"), manuallyVerified = false) + advanceUntilIdle() + // The replayed node should NOT appear in nodeDBbyNodeNum at oldNum + assertNull(nodeManager.nodeDBbyNodeNum[oldNum]) + assertTrue(replayDispatches.none { it.id == oldNum }) + } + + @Test + fun `keyless retired number rejects an unrepresented valid key for the session`() = testScope.runTest { + val num = 7100000000.toInt() + val incomingKey = ByteArray(32) { 0x17 }.toByteString() + nodeManager.applyTrustedIdentityMigrations(listOf(num)) + + nodeManager.handleReceivedUser( + num, + userWithKey(incomingKey, "Untrusted Reuse", "UR"), + manuallyVerified = false, + ) + advanceUntilIdle() + + assertNull(nodeManager.nodeDBbyNodeNum[num]) + verifySuspend(mode = VerifyMode.exactly(0)) { notificationManager.dispatch(any()) } + } + + @Test + fun `retired number with different valid key can be legitimately reused`() = testScope.runTest { + val num = 8000000000.toInt() + val oldKey = ByteArray(32) { 0x08 }.toByteString() + val newKey = ByteArray(32) { 0x09 }.toByteString() + // Create and retire with old key + nodeManager.handleReceivedUser(num, userWithKey(oldKey, "Old", "OD"), manuallyVerified = false) + advanceUntilIdle() + nodeManager.applyTrustedIdentityMigrations(listOf(num)) + advanceUntilIdle() + // Capture dispatches to verify the reuse notification specifically, + // not the initial sighting from the setup above. + val dispatched = mutableListOf() + everySuspend { notificationManager.dispatch(capture(dispatched)) } returns true + // Replay with different key — should be allowed as legitimate reuse + nodeManager.handleReceivedUser(num, userWithKey(newKey, "New", "NW"), manuallyVerified = false) + advanceUntilIdle() + val newNode = nodeManager.nodeDBbyNodeNum[num] + assertNotNull(newNode) + assertEquals("New", newNode.user.long_name) + assertEquals( + 1, + dispatched.count { it.id == num && it.message == "New" }, + "exactly one notification for the reuse at $num", + ) + } + + @Test + fun `legitimate reuse clears prior retirement hint before a later keyless retirement`() = testScope.runTest { + val num = 8100000000.toInt() + val oldKey = ByteArray(32) { 0x18 }.toByteString() + val reuseKey = ByteArray(32) { 0x19 }.toByteString() + val untrustedKey = ByteArray(32) { 0x1a }.toByteString() + nodeManager.handleReceivedUser(num, userWithKey(oldKey, "Old", "OD"), manuallyVerified = false) + advanceUntilIdle() + nodeManager.applyTrustedIdentityMigrations(listOf(num)) + nodeManager.handleReceivedUser(num, userWithKey(reuseKey, "Reuse", "RE"), manuallyVerified = false) + advanceUntilIdle() + assertEquals("Reuse", nodeManager.nodeDBbyNodeNum[num]?.user?.long_name) + + nodeManager.removeByNodenum(num) + nodeManager.applyTrustedIdentityMigrations(listOf(num)) + nodeManager.handleReceivedUser(num, userWithKey(untrustedKey, "Untrusted", "UN"), manuallyVerified = false) + advanceUntilIdle() + + assertNull(nodeManager.nodeDBbyNodeNum[num], "an absent trusted migration must not retain an obsolete hint") + } + + @Test + fun `public key diagnostics use a one-way fingerprint`() { + val key = ByteArray(32) { it.toByte() }.toByteString() + val fingerprint = publicKeyLogFingerprint(key) + + assertEquals(key.sha256().hex().take(8), fingerprint) + assertFalse(fingerprint == key.hex().take(8), "diagnostics must not expose a raw public-key prefix") + } + + @Test + fun `complete 2_7 to 2_8 renumbering produces no old number notification`() = testScope.runTest { + val oldNum = -297114191 + val newNum = 1230588578 + val key = ByteArray(32) { 0x0a }.toByteString() + // Simulate: old 2.7 local node identity + nodeManager.setMyNodeNum(oldNum) + nodeManager.handleReceivedUser(oldNum, userWithKey(key, "OldLocal", "OL"), manuallyVerified = false) + advanceUntilIdle() + // Trusted migration retires old number, canonical identity moves to newNum + nodeManager.setMyNodeNum(newNum) + nodeManager.handleReceivedUser(newNum, userWithKey(key, "NewLocal", "NL"), manuallyVerified = false) + nodeManager.applyTrustedIdentityMigrations(listOf(oldNum)) + advanceUntilIdle() + // Early replay of old number User packet — should be suppressed + val dispatchedBefore = mutableListOf() + everySuspend { notificationManager.dispatch(capture(dispatchedBefore)) } returns true + nodeManager.handleReceivedUser(oldNum, userWithKey(key, "Replay", "RP"), manuallyVerified = false) + advanceUntilIdle() + // The old number should NOT be in nodeDB + assertNull(nodeManager.nodeDBbyNodeNum[oldNum]) + // No notification should have been dispatched for the old number replay + // (the notification dispatch was captured; check none is for oldNum) + val oldNumNotifications = dispatchedBefore.filter { it.id == oldNum } + assertTrue(oldNumNotifications.isEmpty(), "No notification should be dispatched for retired oldNum") + } + + @Test + fun `genuine remote first sighting emits exactly one notification`() = testScope.runTest { + val num = 9000000000.toInt() + val key = ByteArray(32) { 0x0b }.toByteString() + nodeManager.setMyNodeNum(1230588578) + val dispatched = mutableListOf() + everySuspend { notificationManager.dispatch(capture(dispatched)) } returns true + nodeManager.handleReceivedUser(num, userWithKey(key, "Genuine Remote", "GR"), manuallyVerified = false) + advanceUntilIdle() + val numNotifications = dispatched.filter { it.id == num } + assertEquals(1, numNotifications.size, "Exactly one notification for genuine new node") + } + + // ── Phase 8 — session-safe node cache loading & trusted-hint survival ───────── + + @Test + fun `direct snapshot returns only the newly selected DB after a currentDb switch`() = testScope.runTest { + // Simulate the race that motivated Phase 6: the process-wide nodeDBbyNum StateFlow (a stateIn cache) still + // holds the PREVIOUS database's node map right after a currentDb switch, but the direct snapshot read + // returns + // only the newly selected DB's nodes. + val staleNum = 1001 + val freshNum = 2002 + val staleNode = makeKnownNode(staleNum, validPk, "Old DB") + val freshNode = makeKnownNode(freshNum, validPk, "New DB") + + // Stale StateFlow cache (old DB still reflected) vs. the direct snapshot (new DB only). + every { nodeRepository.nodeDBbyNum } returns MutableStateFlow(mapOf(staleNum to staleNode)) + everySuspend { nodeRepository.getNodeDbSnapshot() } returns mapOf(freshNum to freshNode) + every { nodeRepository.myNodeInfo } returns MutableStateFlow(null) + + nodeManager.loadCachedNodeDB() + advanceUntilIdle() + + // The cache loader consumed the direct snapshot, so staleNum never entered the live index and freshNum did. + assertNull(nodeManager.nodeDBbyNodeNum[staleNum], "stale-DB row must not leak into the live index") + val loaded = nodeManager.nodeDBbyNodeNum[freshNum] + assertNotNull(loaded) + assertEquals("New DB", loaded!!.user.long_name) + } + + @Test + fun `loadCachedNodeDB result is discarded when clear starts a new session mid-load`() = testScope.runTest { + // Pause the load after the snapshot read so we can race a clear() against it before commit. We use the + // repository snapshot call as the suspension point. + val oldNum = 3003 + val oldNode = makeKnownNode(oldNum, validPk, "Old") + val snapshotRead = CompletableDeferred() + val snapshotRelease = CompletableDeferred() + everySuspend { nodeRepository.getNodeDbSnapshot() } calls + { + snapshotRead.complete(Unit) + snapshotRelease.await() + mapOf(oldNum to oldNode) + } + every { nodeRepository.myNodeInfo } returns MutableStateFlow(null) + + nodeManager.loadCachedNodeDB() + snapshotRead.await() + // While the load is paused after its read, a new session starts. This must discard the old snapshot. + nodeManager.clear() + // Add a node AFTER clear so we can verify the discarded load did not wipe it on commit. + val liveNum = 4004 + nodeManager.updateNode(liveNum) { makeKnownNode(liveNum, validPk, "Live After Clear") } + snapshotRelease.complete(Unit) + advanceUntilIdle() + + // The old-DB snapshot was discarded completely: oldNum is absent, liveNum survives. + assertNull( + nodeManager.nodeDBbyNodeNum[oldNum], + "stale snapshot from the previous session must be discarded", + ) + val live = nodeManager.nodeDBbyNodeNum[liveNum] + assertNotNull(live) + assertEquals("Live After Clear", live!!.user.long_name) + } + + @Test + fun `live node update during the load window is not overwritten by the snapshot`() = testScope.runTest { + val num = 5005 + val staleSnapshotNode = makeKnownNode(num, validPk, "Stale Snapshot").copy(lastHeard = 100) + val liveUpdatedNode = makeKnownNode(num, validPk, "Live Updated").copy(lastHeard = 200) + + val snapshotRead = CompletableDeferred() + val snapshotRelease = CompletableDeferred() + everySuspend { nodeRepository.getNodeDbSnapshot() } calls + { + snapshotRead.complete(Unit) + snapshotRelease.await() + mapOf(num to staleSnapshotNode) + } + every { nodeRepository.myNodeInfo } returns MutableStateFlow(null) + + nodeManager.loadCachedNodeDB() + snapshotRead.await() + // A live update arrives between the snapshot capture and its commit. The live value (with fresher fields) + // must win over the snapshot row at the same num. + nodeManager.updateNode(num) { liveUpdatedNode } + snapshotRelease.complete(Unit) + advanceUntilIdle() + + val result = nodeManager.nodeDBbyNodeNum[num] + assertNotNull(result) + assertEquals("Live Updated", result!!.user.long_name) + assertEquals(200, result.lastHeard, "live mutation field must not be overwritten by the snapshot") + } + + @Test + fun `trusted migration during the load window retires absent number and suppresses replay`() = testScope.runTest { + val num = 6006 + val snapshotNode = makeKnownNode(num, validPk, "Pre-migration") + + val snapshotRead = CompletableDeferred() + val snapshotRelease = CompletableDeferred() + everySuspend { nodeRepository.getNodeDbSnapshot() } calls + { + snapshotRead.complete(Unit) + snapshotRelease.await() + mapOf(num to snapshotNode) + } + every { nodeRepository.myNodeInfo } returns MutableStateFlow(null) + + nodeManager.loadCachedNodeDB() + snapshotRead.await() + // Trusted migration runs while the load is suspended. The migrated number is absent at this point, so its + // hint is captured as "absent" — but the retirement must still survive and suppress a stale replay even + // after + // the load commits. + nodeManager.applyTrustedIdentityMigrations(listOf(num)) + snapshotRelease.complete(Unit) + advanceUntilIdle() + + assertNull( + nodeManager.nodeDBbyNodeNum[num], + "retired number absent at migration time stays retired post-load", + ) + + // Stale same-key replay still suppressed, no notification dispatched for the retired old number. + val dispatched = mutableListOf() + everySuspend { notificationManager.dispatch(capture(dispatched)) } returns true + nodeManager.handleReceivedUser(num, userWithKey(validPk, "Replay", "RP"), manuallyVerified = false) + advanceUntilIdle() + + assertNull(nodeManager.nodeDBbyNodeNum[num]) + assertTrue(dispatched.none { it.id == num }, "no notification for retired-number replay") + } + + @Test + fun `persisted local node fills null identity when live nodes change during load`() = testScope.runTest { + val persistedNum = 7007 + val liveNum = 8008 + val liveKey = ByteArray(32) { (it + 90).toByte() }.toByteString() + val persistedInfo = + MyNodeInfo( + myNodeNum = persistedNum, + hasGPS = false, + model = null, + firmwareVersion = "2.5.0", + couldUpdate = false, + shouldUpdate = false, + currentPacketId = 0L, + messageTimeoutMsec = 0, + minAppVersion = 0, + maxChannels = 0, + hasWifi = false, + channelUtilization = 0f, + airUtilTx = 0f, + deviceId = null, + ) + val snapshotRead = CompletableDeferred() + val snapshotRelease = CompletableDeferred() + everySuspend { nodeRepository.getNodeDbSnapshot() } calls + { + snapshotRead.complete(Unit) + snapshotRelease.await() + mapOf(persistedNum to makeKnownNode(persistedNum, validPk, "Persisted")) + } + every { nodeRepository.myNodeInfo } returns MutableStateFlow(persistedInfo) + + nodeManager.loadCachedNodeDB() + snapshotRead.await() + nodeManager.updateNode(liveNum) { makeKnownNode(liveNum, liveKey, "Live") } + snapshotRelease.complete(Unit) + advanceUntilIdle() + + assertEquals( + persistedNum, + nodeManager.myNodeNum.value, + "the merge path must restore persisted local identity", + ) + assertNotNull(nodeManager.nodeDBbyNodeNum[liveNum], "the concurrent live update must also survive") + } + + @Test + fun `local-node identity learned during the load window survives snapshot commit`() = testScope.runTest { + val persistedNum = 7007 + val persistedInfo = + MyNodeInfo( + myNodeNum = persistedNum, + hasGPS = false, + model = null, + firmwareVersion = "2.5.0", + couldUpdate = false, + shouldUpdate = false, + currentPacketId = 0L, + messageTimeoutMsec = 0, + minAppVersion = 0, + maxChannels = 0, + hasWifi = false, + channelUtilization = 0f, + airUtilTx = 0f, + deviceId = null, + ) + val liveLearnedNum = 8008 + + val snapshotRead = CompletableDeferred() + val snapshotRelease = CompletableDeferred() + everySuspend { nodeRepository.getNodeDbSnapshot() } calls + { + snapshotRead.complete(Unit) + snapshotRelease.await() + // Persisted DB still references the old persistedNum row. + mapOf(persistedNum to makeKnownNode(persistedNum, validPk, "Persisted")) + } + every { nodeRepository.myNodeInfo } returns MutableStateFlow(persistedInfo) + + nodeManager.loadCachedNodeDB() + snapshotRead.await() + // The live session has learned a DIFFERENT local node number during the load window. The persisted value + // must NOT clobber it on commit. + nodeManager.setMyNodeNum(liveLearnedNum) + snapshotRelease.complete(Unit) + advanceUntilIdle() + + assertEquals( + liveLearnedNum, + nodeManager.myNodeNum.value, + "live local-node info wins over persisted snapshot", + ) + } + + @Test + fun `repeated trusted migration preserves original hint and still permits distinct-key reuse`() = + testScope.runTest { + val num = 9009 + val oldKey = ByteArray(32) { 0x21 }.toByteString() + val reuseKey = ByteArray(32) { 0x22 }.toByteString() + nodeManager.handleReceivedUser(num, userWithKey(oldKey, "Old", "OD"), manuallyVerified = false) + advanceUntilIdle() + + // Apply the same migration twice. The original hint must survive the second pass because the node is + // already + // retired and absent; the prior hint is preserved rather than dropped. + nodeManager.applyTrustedIdentityMigrations(listOf(num)) + nodeManager.applyTrustedIdentityMigrations(listOf(num)) + advanceUntilIdle() + + // Same-key replay still suppressed after the repeated migration. + val suppressedDispatches = mutableListOf() + everySuspend { notificationManager.dispatch(capture(suppressedDispatches)) } returns true + nodeManager.handleReceivedUser(num, userWithKey(oldKey, "Replay", "RP"), manuallyVerified = false) + advanceUntilIdle() + assertNull( + nodeManager.nodeDBbyNodeNum[num], + "same-key replay after repeated migration must stay suppressed", + ) + assertTrue(suppressedDispatches.none { it.id == num }) + + // Distinct valid unrepresented key is still accepted as a legitimate reuse, clearing retirement + hint and + // emitting exactly one replacement notification. + val reuseDispatches = mutableListOf() + everySuspend { notificationManager.dispatch(capture(reuseDispatches)) } returns true + nodeManager.handleReceivedUser(num, userWithKey(reuseKey, "Replacement", "NP"), manuallyVerified = false) + advanceUntilIdle() + + val reused = nodeManager.nodeDBbyNodeNum[num] + assertNotNull(reused) + assertEquals("Replacement", reused!!.user.long_name) + assertEquals(reuseKey, reused.publicKey) + assertEquals(1, reuseDispatches.count { it.id == num && it.message == "Replacement" }) + } } diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/NodeInfoDao.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/NodeInfoDao.kt index bac2ca4e4e..3d29453e07 100644 --- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/NodeInfoDao.kt +++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/NodeInfoDao.kt @@ -257,6 +257,28 @@ interface NodeInfoDao { >, > + /** + * One-shot snapshot of every node row in the CURRENTLY SELECTED database (not the process-wide stateIn cache). Used + * by session-safe cache loads (see NodeManagerImpl.loadCachedNodeDB) so a transport switch can't deliver a stale + * pre-switch map. + */ + @Query( + """ + SELECT * FROM nodes + ORDER BY CASE + WHEN num = (SELECT myNodeNum FROM my_node LIMIT 1) THEN 0 + ELSE 1 + END, + last_heard DESC + """, + ) + @Transaction + suspend fun nodeDBbyNumSnapshot(): Map< + @MapColumn(columnName = "num") + Int, + NodeWithRelations, + > + @Query( """ WITH OurNode AS ( diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Node.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Node.kt index b866da6748..15a67bd32c 100644 --- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Node.kt +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Node.kt @@ -189,7 +189,10 @@ data class Node( private const val DEFAULT_ID_SUFFIX_LENGTH = 4 private const val RELAY_NODE_SUFFIX_MASK = 0xFF - val ERROR_BYTE_STRING: ByteString = ByteArray(32) { 0 }.toByteString() + /** Size (in bytes) of a Curve25519 public key as used by meshtastic firmware. */ + const val PUBLIC_KEY_SIZE: Int = 32 + + val ERROR_BYTE_STRING: ByteString = ByteArray(PUBLIC_KEY_SIZE) { 0 }.toByteString() fun getRelayNode(relayNodeId: Int, nodes: List, ourNodeNum: Int?): Node? { val relayNodeIdSuffix = relayNodeId and RELAY_NODE_SUFFIX_MASK diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/NodeManager.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/NodeManager.kt index 58bd8cfa3c..71d1265c92 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/NodeManager.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/NodeManager.kt @@ -183,6 +183,12 @@ interface NodeManager : NodeIdLookup { /** Removes a node from the in-memory database by its number. */ fun removeByNodenum(nodeNum: Int) + /** + * Applies node-number removals returned by the trusted configuration install. Removed numbers stay retired for the + * current device/database session so delayed field packets cannot recreate migrated identities. + */ + fun applyTrustedIdentityMigrations(removedNums: Collection) + /** * Installs node information in memory. Callers that require an immediate standalone write use * [installNodeInfoAndPersist]; configuration handshakes batch the resulting snapshot through the repository. diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/NodeRepository.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/NodeRepository.kt index 833f408361..80992d6ac9 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/NodeRepository.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/NodeRepository.kt @@ -121,6 +121,13 @@ interface NodeRepository { /** Returns all nodes with unknown hardware models. */ suspend fun getUnknownNodes(): List + /** + * One-shot snapshot of every node in the CURRENTLY SELECTED database (not the process-wide [nodeDBbyNum] `stateIn` + * cache, which can briefly retain the previous transport's map after a switch). Used by session-safe cache loads + * that need to read the live DB at invocation time. + */ + suspend fun getNodeDbSnapshot(): Map + /** * Deletes all nodes from the database. * diff --git a/core/takserver/src/commonTest/kotlin/org/meshtastic/core/takserver/TAKMeshIntegrationTest.kt b/core/takserver/src/commonTest/kotlin/org/meshtastic/core/takserver/TAKMeshIntegrationTest.kt index f5d79f40ec..a8d590b197 100644 --- a/core/takserver/src/commonTest/kotlin/org/meshtastic/core/takserver/TAKMeshIntegrationTest.kt +++ b/core/takserver/src/commonTest/kotlin/org/meshtastic/core/takserver/TAKMeshIntegrationTest.kt @@ -286,6 +286,9 @@ class TAKMeshIntegrationTest { override val myId: StateFlow = MutableStateFlow(null) override val localStats: StateFlow = MutableStateFlow(LocalStats()) override val nodeDBbyNum: StateFlow> = MutableStateFlow(emptyMap()) + + override suspend fun getNodeDbSnapshot(): Map = nodeDBbyNum.value + override val onlineNodeCount: Flow = MutableStateFlow(0) override val totalNodeCount: Flow = MutableStateFlow(0) diff --git a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeNodeRepository.kt b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeNodeRepository.kt index 3e9b013688..9b05018319 100644 --- a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeNodeRepository.kt +++ b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeNodeRepository.kt @@ -131,6 +131,8 @@ class FakeNodeRepository : override suspend fun getUnknownNodes(): List = _nodeDBbyNum.value.values.filter { it.isUnknownUser } + override suspend fun getNodeDbSnapshot(): Map = _nodeDBbyNum.value + override suspend fun clearNodeDB(preserveFavorites: Boolean) { if (preserveFavorites) { _nodeDBbyNum.value = _nodeDBbyNum.value.filter { it.value.isFavorite }