diff --git a/.skills/compose-ui/strings-index.txt b/.skills/compose-ui/strings-index.txt index 59667819e7a..e3de2500c0d 100644 --- a/.skills/compose-ui/strings-index.txt +++ b/.skills/compose-ui/strings-index.txt @@ -1239,6 +1239,8 @@ node_list_long_click_label node_number node_request_send_failed node_restarting +node_saved_on_phone +node_saved_on_phone_description node_sort_alpha node_sort_button node_sort_channel 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 bf94b5df59e..a055643b421 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 @@ -298,8 +298,13 @@ class MeshConfigFlowManagerImpl( nodeManager.applyTrustedIdentityMigrations(removedNums) } + // Exactly what this session's Stage 2 handshake downloaded (+ the local node) — the UI's "is this node part + // of the connected radio's own NodeDB right now" signal (#6263). Published before setNodeDbReady(true) so no + // reader can observe "ready" without an up-to-date snapshot to compare against. + val currentSessionNodeNums = entities.mapTo(mutableSetOf()) { it.num }.apply { add(info.myNodeNum) } val published = runForSession(session) { + nodeManager.publishCurrentSessionNodeNums(session.generation, currentSessionNodeNums) nodeManager.setNodeDbReady(true) nodeManager.setAllowNodeDbWrites(true) serviceStateWriter.setConnectionState(ConnectionState.Connected) 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 7dfd70f9c7e..bc2c07dee3a 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 @@ -317,18 +317,60 @@ class NodeManagerImpl( override fun clearConnectionIdentity() { _connectionIdentity.value = null + currentSessionNodeNumsGeneration.value = NO_SESSION_NODE_NUMS_GENERATION + _currentSessionNodeNums.value = null } override fun clearStaleConnectionIdentity(activeSessionGeneration: Long) { _connectionIdentity.updateStateFlow { identity -> identity?.takeIf { it.sessionGeneration == activeSessionGeneration } } + // Same reconciliation as connectionIdentity: a snapshot already published for the active generation must + // survive a delayed boundary collector from that same generation (RadioControllerImpl's sessionGeneration + // collector can fire after installAndPublishNodeDatabase already published for the new session). + if (currentSessionNodeNumsGeneration.value != activeSessionGeneration) { + _currentSessionNodeNums.value = null + } } override fun publishConnectionIdentity(sessionGeneration: Long, address: String, nodeNum: Int, deviceId: String?) { _connectionIdentity.value = ConnectionIdentity(sessionGeneration, address, nodeNum, deviceId) } + private val currentSessionNodeNumsGeneration = atomic(NO_SESSION_NODE_NUMS_GENERATION) + private val _currentSessionNodeNums = MutableStateFlow?>(null) + override val currentSessionNodeNums: StateFlow?> = _currentSessionNodeNums + + override fun publishCurrentSessionNodeNums(sessionGeneration: Long, nodeNums: Set) { + currentSessionNodeNumsGeneration.value = sessionGeneration + _currentSessionNodeNums.value = nodeNums + } + + /** + * Extends the current session's membership set with [nodeNum] after the radio forwarded live traffic for it, so a + * node that announces itself *after* the Stage 2 snapshot is not mistaken for locally-retained history (#6263). + * + * A null [session] means the mutation did not come from the radio at all (an optimistic admin projection, a + * shared-contact import, a locally applied fixed position) and must not claim session membership. Everything else + * is decided inside the [updateStateFlow] lambda so a concurrent session boundary makes this a no-op rather than + * resurrecting a number into the wrong session's set: + * - no snapshot published yet → nothing to extend (leaving it null keeps every row unbadged, per the contract); + * - the published snapshot belongs to a different generation → this packet is from a superseded session; + * - already a member → return the same instance, so the hot inbound path neither copies a set of up to + * [MAX_IN_MEMORY_NODES] entries nor emits a conflated no-change update. + */ + private fun noteHeardInSession(nodeNum: Int, session: RadioSessionContext?) { + if (session == null) return + _currentSessionNodeNums.updateStateFlow { current -> + when { + current == null -> null + currentSessionNodeNumsGeneration.value != session.generation -> current + nodeNum in current -> current + else -> current + nodeNum + } + } + } + override val firmwareEdition = MutableStateFlow(null) override fun setFirmwareEdition(edition: FirmwareEdition?) { @@ -401,6 +443,9 @@ class NodeManagerImpl( * legitimately busy mesh never reaches it and only sustained novel-`from` traffic does. */ const val MAX_IN_MEMORY_NODES = 2_000 + + /** Sentinel for [currentSessionNodeNumsGeneration] meaning "no snapshot published yet this process". */ + private const val NO_SESSION_NODE_NUMS_GENERATION = -1L } override fun loadCachedNodeDB() { @@ -475,6 +520,8 @@ class NodeManagerImpl( myDeviceId.value = null firmwareEdition.value = null _connectionIdentity.value = null + currentSessionNodeNumsGeneration.value = NO_SESSION_NODE_NUMS_GENERATION + _currentSessionNodeNums.value = null } override fun getMyNodeInfo(): MyNodeInfo? { @@ -590,7 +637,13 @@ class NodeManagerImpl( session: RadioSessionContext? = null, transform: (Node) -> Node, ): NodeStateChange? = updateNodeState(nodeNum, channel, transform).also { change -> - if (change != null && shouldPersist(change.next)) { + if (change == null) return@also + // A committed session-scoped update means the radio just forwarded traffic for this node — position, + // telemetry, node status, PaxCounter, admin reply — so it belongs to this session's membership set even if + // Stage 2 never listed it (#6263). A null change means the update was refused (retired-absent number) and + // proves nothing. Retries are impossible here: updateNodeState commits at most once. + noteHeardInSession(nodeNum, session) + if (shouldPersist(change.next)) { radioInterfaceService.launchSessionWork(scope, session) { persistLatestNode(nodeNum) } } } @@ -668,6 +721,9 @@ class NodeManagerImpl( " key=$keyStr canonical=$canonicalNum" + " decision=${transition.decision} notify=${transition.notifyNode != null}" } + // Only the winning CAS may claim session membership: a packet that lost the race breaks out of this + // loop and is logged as discarded, and an entry-point add would have already polluted the set (#6263). + transition.sessionMemberNodeNum?.let { noteHeardInSession(it, session) } applyReceivedUserEffects(transition, session) return } @@ -835,6 +891,14 @@ class NodeManagerImpl( val notifyNode: Node?, /** Retired number to reactivate after a validated, genuinely new identity claims the vacant slot. */ val unretireNodeNum: Int? = null, + /** + * Number under which [after] actually keys the node this packet was attributed to, to be recorded as a member + * of the live connection session (#6263). Deliberately not always `fromNum`: a stale noncanonical presentation + * yields to the canonical row, so the *canonical* number is the row the traffic evidences. Null when the + * reduction suppressed or ignored the packet — a suppressed replay proves nothing about session membership, and + * claiming `fromNum` there would badge a slot held by an entirely different identity. + */ + val sessionMemberNodeNum: Int? = null, val decision: ReceivedUserDecision, ) @@ -932,6 +996,7 @@ class NodeManagerImpl( after = afterRemovals.put(fromNum, transformed, preferredNum = fromNum), upsertNode = transformed, notifyNode = null, + sessionMemberNodeNum = fromNum, decision = ReceivedUserDecision.LOCAL_UPDATE, ) } @@ -985,6 +1050,7 @@ class NodeManagerImpl( after = afterRemovals.put(fromNum, transformed, preferredNum = fromNum), upsertNode = null, notifyNode = null, + sessionMemberNodeNum = fromNum, decision = ReceivedUserDecision.CANONICAL_DUPLICATE_RECONCILED, ) } @@ -998,6 +1064,9 @@ class NodeManagerImpl( after = after, upsertNode = null, notifyNode = null, + // The identity lives at canonicalNum (that is what `canonicalNum in otherSameKeyNums` asserts), so + // that — not the yielding fromNum slot — is the row this traffic proves the radio just relayed. + sessionMemberNodeNum = canonicalNum, decision = ReceivedUserDecision.STALE_PRESENTATION_REMOVED, ) } @@ -1026,6 +1095,7 @@ class NodeManagerImpl( after = before.put(fromNum, transformed, preferredNum = fromNum), upsertNode = null, notifyNode = null, + sessionMemberNodeNum = fromNum, decision = ReceivedUserDecision.AMBIGUOUS_DUPLICATE_UPDATED, ) } @@ -1068,6 +1138,7 @@ class NodeManagerImpl( upsertNode = transformed.takeIf { persist }, notifyNode = notify, unretireNodeNum = unretireNodeNum, + sessionMemberNodeNum = fromNum, decision = decision, ) } 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 8bfc06a9e8c..cbc39135210 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 @@ -627,6 +627,45 @@ class MeshConfigFlowManagerImplTest { verifySuspend { connectionManager.onNodeDbReady() } } + @Test + fun `Stage 2 complete publishes exact session membership before readiness`() = testScope.runTest { + val firstNum = 100 + val secondNum = 200 + val firstNode = org.meshtastic.core.testing.TestDataFactory.createTestNode(num = firstNum) + val secondNode = org.meshtastic.core.testing.TestDataFactory.createTestNode(num = secondNum) + every { nodeManager.nodeDBbyNodeNum } returns mapOf(firstNum to firstNode, secondNum to secondNode) + val callOrder = mutableListOf() + every { nodeManager.publishCurrentSessionNodeNums(any(), any()) } calls + { + callOrder.add("publishSessionNodeNums") + } + every { nodeManager.setNodeDbReady(true) } calls { callOrder.add("nodeDbReady") } + + handleMyInfo(protoMyNodeInfo) + advanceUntilIdle() + manager.handleLocalMetadata(metadata) + advanceUntilIdle() + manager.handleConfigComplete(HandshakeConstants.CONFIG_NONCE) + advanceTimeBy(STAGE_TRANSITION_ADVANCE_MS) + runCurrent() + manager.handleNodeInfo(NodeInfo(num = firstNum)) + manager.handleNodeInfo(NodeInfo(num = secondNum)) + manager.handleConfigComplete(HandshakeConstants.NODE_INFO_NONCE) + advanceUntilIdle() + + // Exactly the downloaded set plus the local node — not the entire (possibly larger, locally-retained) + // nodeDBbyNodeNum, which would defeat the badge's purpose of flagging rows the radio did NOT just report. + verify { + nodeManager.publishCurrentSessionNodeNums( + activeSession.generation, + setOf(myNodeNum, firstNum, secondNum), + ) + } + // Published strictly before setNodeDbReady(true), so no reader can observe "ready" against a stale/absent + // session snapshot. + assertEquals(listOf("publishSessionNodeNums", "nodeDbReady"), callOrder) + } + @Test fun `Stage 2 applies trusted migrations before readiness and replay`() = testScope.runTest { val retiredNum = 456 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 364cd1ce60c..d21cab136e7 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 @@ -62,6 +62,7 @@ import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertNotNull import kotlin.test.assertNull +import kotlin.test.assertSame import kotlin.test.assertTrue import org.meshtastic.proto.NodeInfo as ProtoNodeInfo import org.meshtastic.proto.Position as ProtoPosition @@ -2726,4 +2727,199 @@ class NodeManagerImplTest { assertEquals(reuseKey, reused.publicKey) assertEquals(1, reuseDispatches.count { it.id == num && it.message == "Replacement" }) } + + // ---------- currentSessionNodeNums (#6263) ---------- + + @Test + fun `currentSessionNodeNums is null until the current session publishes a snapshot`() { + assertNull(nodeManager.currentSessionNodeNums.value) + } + + @Test + fun `publishCurrentSessionNodeNums exposes exactly the published set`() { + nodeManager.publishCurrentSessionNodeNums(sessionGeneration = 0L, nodeNums = setOf(1, 2, 3)) + + assertEquals(setOf(1, 2, 3), nodeManager.currentSessionNodeNums.value) + } + + @Test + fun `clearConnectionIdentity clears the session node-number snapshot unconditionally`() { + nodeManager.publishCurrentSessionNodeNums(sessionGeneration = 0L, nodeNums = setOf(1, 2, 3)) + + nodeManager.clearConnectionIdentity() + + assertNull(nodeManager.currentSessionNodeNums.value) + } + + @Test + fun `clearStaleConnectionIdentity clears a snapshot from a superseded generation`() { + nodeManager.publishCurrentSessionNodeNums(sessionGeneration = 0L, nodeNums = setOf(1, 2, 3)) + + nodeManager.clearStaleConnectionIdentity(activeSessionGeneration = 1L) + + assertNull(nodeManager.currentSessionNodeNums.value) + } + + @Test + fun `clearStaleConnectionIdentity preserves a snapshot already published for the active generation`() { + // Regression: RadioControllerImpl's sessionGeneration collector can be delayed and fire + // clearStaleConnectionIdentity(activeGeneration) AFTER installAndPublishNodeDatabase already published the + // new session's snapshot for that same generation. A generation-blind clear would silently blank the badge + // for the rest of the session — mirrors connectionIdentity's own race-survival contract. + nodeManager.publishCurrentSessionNodeNums(sessionGeneration = 5L, nodeNums = setOf(1, 2, 3)) + + nodeManager.clearStaleConnectionIdentity(activeSessionGeneration = 5L) + + assertEquals(setOf(1, 2, 3), nodeManager.currentSessionNodeNums.value) + } + + @Test + fun `clear resets the session node-number snapshot`() { + nodeManager.publishCurrentSessionNodeNums(sessionGeneration = 0L, nodeNums = setOf(1, 2, 3)) + + nodeManager.clear() + + assertNull(nodeManager.currentSessionNodeNums.value) + } + + // ---------- Mid-session membership growth (#6263) ---------- + + private val liveSession = RadioSessionContext(generation = SESSION_GEN, address = "ble:live") + + /** Publishes a Stage 2 snapshot that deliberately does NOT contain [MID_SESSION_NUM]. */ + private fun publishStageTwoSnapshot() { + nodeManager.publishCurrentSessionNodeNums(SESSION_GEN, setOf(LOCAL_NUM, STAGE_TWO_NUM)) + } + + /** + * The exact predicate every consumer applies (`NodeListScreen`, `CommonGetNodeDetailsUseCase`): a row is badged + * "Saved on phone" — and has its online/fresh affordance suppressed — when a snapshot exists and omits it. + */ + private fun isSavedOnPhone(nodeNum: Int): Boolean = + nodeManager.currentSessionNodeNums.value?.let { nodeNum !in it } ?: false + + private fun midSessionUser(num: Int = MID_SESSION_NUM) = User( + id = NodeAddress.numToDefaultId(num), + long_name = "Mid Session Arrival", + short_name = "MSA", + hw_model = HardwareModel.TLORA_V2, + ) + + @Test + fun `a node first heard mid-session joins the session set instead of being badged saved-on-phone`() { + // The defect this locks down: the Stage 2 snapshot is a point-in-time photograph, so a node that announces + // itself later on a long-lived connection was flagged as locally-retained history and had its genuinely fresh + // online status suppressed — the same "presence looks wrong" defect #6263 set out to fix, inverted. + publishStageTwoSnapshot() + assertTrue(isSavedOnPhone(MID_SESSION_NUM), "precondition: absent from the Stage 2 snapshot") + + nodeManager.handleReceivedUser(MID_SESSION_NUM, midSessionUser(), session = liveSession) + + assertTrue(MID_SESSION_NUM in nodeManager.currentSessionNodeNums.value.orEmpty()) + assertFalse(isSavedOnPhone(MID_SESSION_NUM), "a node just heard over the mesh must not be badged") + // Purely additive: Stage 2's own membership is untouched. + assertEquals(setOf(LOCAL_NUM, STAGE_TWO_NUM, MID_SESSION_NUM), nodeManager.currentSessionNodeNums.value) + } + + @Test + fun `a session-scoped position joins the session set`() { + publishStageTwoSnapshot() + + nodeManager.handleReceivedPosition( + MID_SESSION_NUM, + myNodeNum = LOCAL_NUM, + p = ProtoPosition(latitude_i = 123, longitude_i = 456), + defaultTime = 1000L, + session = liveSession, + ) + + assertFalse(isSavedOnPhone(MID_SESSION_NUM)) + } + + @Test + fun `a session-scoped node update joins the session set`() { + // updateNodeForSession is the mesh telemetry (TelemetryPacketHandlerImpl) and admin-reply + // (AdminPacketHandlerImpl) arrival path — neither reaches handleReceivedUser. + publishStageTwoSnapshot() + + nodeManager.updateNodeForSession(MID_SESSION_NUM, liveSession) { it.copy(lastHeard = 42) } + + assertFalse(isSavedOnPhone(MID_SESSION_NUM)) + } + + @Test + fun `a session-scoped node status joins the session set`() { + publishStageTwoSnapshot() + + nodeManager.handleReceivedNodeStatus(MID_SESSION_NUM, StatusMessage(status = "live"), liveSession) + + assertFalse(isSavedOnPhone(MID_SESSION_NUM)) + } + + @Test + fun `a sessionless user update cannot claim session membership`() { + // MessagingControllerImpl imports a shared contact, AdminControllerImpl projects an optimistic config write, + // and CommandSenderImpl applies a local fixed position — all with no session. Those nodes are precisely the + // "saved on phone" case, so a session-blind add would badge nothing at all and make the feature inert. + publishStageTwoSnapshot() + + nodeManager.handleReceivedUser(MID_SESSION_NUM, midSessionUser(), manuallyVerified = true) + nodeManager.updateNode(MID_SESSION_NUM) { it.copy(lastHeard = 42) } + + assertEquals(setOf(LOCAL_NUM, STAGE_TWO_NUM), nodeManager.currentSessionNodeNums.value) + assertTrue(isSavedOnPhone(MID_SESSION_NUM)) + } + + @Test + fun `traffic from a superseded generation cannot contaminate the active session set`() { + publishStageTwoSnapshot() + val supersededSession = RadioSessionContext(generation = SESSION_GEN - 1, address = "ble:old") + + nodeManager.handleReceivedUser(MID_SESSION_NUM, midSessionUser(), session = supersededSession) + + assertEquals(setOf(LOCAL_NUM, STAGE_TWO_NUM), nodeManager.currentSessionNodeNums.value) + } + + @Test + fun `mid-session traffic before any snapshot leaves the set null rather than fabricating one`() { + // Nothing may be badged until the radio has actually reported its own NodeDB, so a partial set built only from + // live traffic would badge the entire real NodeDB as saved-on-phone. + nodeManager.handleReceivedUser(MID_SESSION_NUM, midSessionUser(), session = liveSession) + + assertNull(nodeManager.currentSessionNodeNums.value) + assertFalse(isSavedOnPhone(MID_SESSION_NUM)) + } + + @Test + fun `a suppressed replay for a retired number claims no session membership`() { + val retiredNum = validPk.noncanonicalNum(4600) + nodeManager.updateNode(retiredNum) { makeKnownNode(retiredNum, validPk, "Pre-migration") } + nodeManager.applyTrustedIdentityMigrations(listOf(retiredNum)) + publishStageTwoSnapshot() + + // No valid key against a retired slot: the reducer suppresses the packet outright, so it is no evidence that + // the radio still knows this number. + nodeManager.handleReceivedUser(retiredNum, midSessionUser(retiredNum), session = liveSession) + + assertEquals(setOf(LOCAL_NUM, STAGE_TWO_NUM), nodeManager.currentSessionNodeNums.value) + } + + @Test + fun `repeat traffic from an existing member allocates no new set`() { + // Guards the hot inbound path: every position and telemetry packet reaches this, and a blind `set + num` would + // copy up to MAX_IN_MEMORY_NODES entries per packet for a change StateFlow conflates away regardless. + publishStageTwoSnapshot() + val before = nodeManager.currentSessionNodeNums.value + + nodeManager.handleReceivedUser(STAGE_TWO_NUM, midSessionUser(STAGE_TWO_NUM), session = liveSession) + + assertSame(before, nodeManager.currentSessionNodeNums.value) + } + + private companion object { + const val SESSION_GEN = 5L + const val LOCAL_NUM = 1111 + const val STAGE_TWO_NUM = 2222 + const val MID_SESSION_NUM = 3333 + } } diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/Packet.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/Packet.kt index 25dae5c58c2..3529495a5c9 100644 --- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/Packet.kt +++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/Packet.kt @@ -159,7 +159,10 @@ data class Packet( if (candidateRelayNodes.size == 1) { candidateRelayNodes.first() } else { - candidateRelayNodes.minByOrNull { it.hopsAway } + // hopsAwayOrNull sorts last via Int.MAX_VALUE: an unresolved hop count (-1) must never look + // "closer" than a real, known hop count under a plain minByOrNull { it.hopsAway }. Mirrors + // Node.Companion.getRelayNode's fix for the same defect (#6263). + candidateRelayNodes.minByOrNull { it.hopsAwayOrNull ?: Int.MAX_VALUE } } return closestRelayNode diff --git a/core/database/src/commonTest/kotlin/org/meshtastic/core/database/entity/PacketGetRelayNodeTest.kt b/core/database/src/commonTest/kotlin/org/meshtastic/core/database/entity/PacketGetRelayNodeTest.kt new file mode 100644 index 00000000000..50f3d5b7f31 --- /dev/null +++ b/core/database/src/commonTest/kotlin/org/meshtastic/core/database/entity/PacketGetRelayNodeTest.kt @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.database.entity + +import org.meshtastic.core.model.Node +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * Regression coverage for [Packet.Companion.getRelayNode] — the copy of this resolver actually wired into production + * (`DebugViewModel`), as opposed to the unused [Node.Companion.getRelayNode] of the same name. Both must sort an + * unresolved hop count last rather than let it look like the closest relay (#6263). + */ +class PacketGetRelayNodeTest { + + @Test + fun getRelayNode_neverPrefersAnUnresolvedHopCountOverAKnownOne() { + val unresolved = Node(num = 0x000001AA, lastHeard = 100, hopsAway = Node.HOPS_AWAY_UNSET) + val knownFar = Node(num = 0x000002AA, lastHeard = 100, hopsAway = 4) + val otherSuffix = Node(num = 0x000003BB, lastHeard = 100, hopsAway = 1) + + val relayNode = + Packet.getRelayNode( + relayNodeId = 0x0000FFAA.toInt(), + nodes = listOf(unresolved, knownFar, otherSuffix), + ourNodeNum = null, + ) + + assertEquals(knownFar, relayNode) + } + + @Test + fun getRelayNode_picksTheClosestOfSeveralResolvedCandidates() { + val far = Node(num = 0x000001AA, lastHeard = 100, hopsAway = 4) + val near = Node(num = 0x000002AA, lastHeard = 100, hopsAway = 1) + + val relayNode = + Packet.getRelayNode(relayNodeId = 0x0000FFAA.toInt(), nodes = listOf(far, near), ourNodeNum = null) + + assertEquals(near, relayNode) + } +} 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 9f794e1be15..94d81696cb0 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 @@ -106,6 +106,14 @@ data class Node( val rssiOrNull: Int? get() = rssi.takeIf { it != RSSI_UNSET } + /** + * Hop count to this node, or null when it has never been resolved ([hopsAway] still holds [HOPS_AWAY_UNSET]). 0 + * hops is a real reading (a direct neighbor) — every read of [hopsAway] should go through this rather than + * comparing against the raw sentinel. + */ + val hopsAwayOrNull: Int? + get() = hopsAway.takeIf { it != HOPS_AWAY_UNSET } + val hasEnvironmentMetrics: Boolean get() = environmentMetrics != EnvironmentMetrics() @@ -193,13 +201,16 @@ data class Node( const val PUBLIC_KEY_SIZE: Int = 32 /** - * Sentinels stored when a node has no radio-metric reading. They exist because [snr]/[rssi] are not nullable - * (the Room columns behind them are NOT NULL); resolve them with [snrOrNull]/[rssiOrNull] rather than comparing - * against them at call sites. + * Sentinels stored when a node has no radio-metric reading. They exist because [snr]/[rssi]/[hopsAway] are not + * nullable (the Room columns behind them are NOT NULL); resolve them with [snrOrNull]/[rssiOrNull]/ + * [hopsAwayOrNull] rather than comparing against them at call sites. */ const val SNR_UNSET: Float = Float.MAX_VALUE const val RSSI_UNSET: Int = Int.MAX_VALUE + /** Mirrors the `-1` default already carried by [hopsAway] (and `NodeEntity.hopsAway`). */ + const val HOPS_AWAY_UNSET: Int = -1 + val ERROR_BYTE_STRING: ByteString = ByteArray(PUBLIC_KEY_SIZE) { 0 }.toByteString() fun getRelayNode(relayNodeId: Int, nodes: List, ourNodeNum: Int?): Node? { @@ -216,7 +227,9 @@ data class Node( if (candidateRelayNodes.size == 1) { candidateRelayNodes.first() } else { - candidateRelayNodes.minByOrNull { it.hopsAway } + // hopsAwayOrNull sorts last via Int.MAX_VALUE: an unresolved hop count (-1) must never look + // "closer" than a real, known hop count under a plain minByOrNull { it.hopsAway }. + candidateRelayNodes.minByOrNull { it.hopsAwayOrNull ?: Int.MAX_VALUE } } return closestRelayNode diff --git a/core/model/src/commonTest/kotlin/org/meshtastic/core/model/NodeTest.kt b/core/model/src/commonTest/kotlin/org/meshtastic/core/model/NodeTest.kt index 2da8c6b1d97..326be8a83bb 100644 --- a/core/model/src/commonTest/kotlin/org/meshtastic/core/model/NodeTest.kt +++ b/core/model/src/commonTest/kotlin/org/meshtastic/core/model/NodeTest.kt @@ -101,6 +101,35 @@ class NodeTest { assertEquals(chosen, relayNode) } + @Test + fun getRelayNode_neverPrefersAnUnresolvedHopCountOverAKnownOne() { + // Regression for the presence-vs-sentinel-zero class: a plain `minByOrNull { it.hopsAway }` would rank + // -1 (unresolved) below every real hop count, wrongly picking `unresolved` as the "closest" relay. + val unresolved = Node(num = 0x000001AA, lastHeard = 100, hopsAway = Node.HOPS_AWAY_UNSET) + val knownFar = Node(num = 0x000002AA, lastHeard = 100, hopsAway = 4) + val otherSuffix = Node(num = 0x000003BB, lastHeard = 100, hopsAway = 1) + + val relayNode = + Node.getRelayNode( + relayNodeId = 0x0000FFAA.toInt(), + nodes = listOf(unresolved, knownFar, otherSuffix), + ourNodeNum = null, + ) + + assertEquals(knownFar, relayNode) + } + + @Test + fun hopsAwayOrNull_isNullOnlyForTheUnsetSentinel() { + val unset = Node(num = 1, hopsAway = Node.HOPS_AWAY_UNSET) + val direct = Node(num = 2, hopsAway = 0) + val relayed = Node(num = 3, hopsAway = 3) + + assertEquals(null, unset.hopsAwayOrNull) + assertEquals(0, direct.hopsAwayOrNull) + assertEquals(3, relayed.hopsAwayOrNull) + } + @Test fun isUnknownUser_falseWhenHardwareModelIsKnown() { val node = Node(num = 1, user = User(hw_model = HardwareModel.TLORA_V2)) 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 71d1265c929..18e2c5fb056 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 @@ -116,6 +116,44 @@ interface NodeManager : NodeIdLookup { */ fun publishConnectionIdentity(sessionGeneration: Long, address: String, nodeNum: Int, deviceId: String?) + /** + * Node numbers the connected radio is known to know about during the current connection session: the set its Stage + * 2 handshake downloaded, plus the local node, plus **every node the radio has forwarded live traffic for since** + * (see below). Null before that handshake has completed at least once this session (no snapshot to compare against + * yet, so nothing should be flagged). + * + * Distinct from [nodeDBbyNodeNum]: the phone's node database is deliberately cumulative and keeps rows the + * connected radio no longer reports (multi-radio use, meshes bigger than the radio's own bounded/evicting NodeDB). + * A row present in [nodeDBbyNodeNum] but absent here is real, locally-retained history — not a fabricated or stale + * entry — and UI should badge it as such (see #6263) rather than hide or delete it. + * + * **This set grows for the life of the session.** The Stage 2 snapshot alone would be a point-in-time photograph: + * on a long-lived connection, any node announcing itself mid-session (NodeInfo, Position, Telemetry, node status, + * PaxCounter, admin reply) is genuinely part of the live session, and treating it as locally-retained history would + * suppress its real, freshly observed presence. Every mid-session path that carries a [RadioSessionContext] — i.e. + * that reached the phone through the radio, as opposed to a local projection or an imported contact — adds its node + * number here. Local-only writes (optimistic config projections, shared-contact imports, fixed-position edits) + * carry no session and deliberately do not, since those nodes are exactly the "saved on phone" case. + * + * It never shrinks mid-session. A node evicted from the radio's *own* bounded NodeDB after Stage 2 keeps its + * membership until the next handshake republishes: the firmware sends no eviction notification, so the phone has no + * signal short of re-downloading the whole NodeDB. Erring toward "the radio knew about this node this session" is + * the safe direction — it withholds a badge rather than falsely claiming locally-retained history. + * + * Reconciled alongside [connectionIdentity] at every transport-session boundary — see [clearConnectionIdentity] and + * [clearStaleConnectionIdentity] — using the same generation carried by [publishCurrentSessionNodeNums], so a + * snapshot already published for the active generation survives a delayed boundary collector from that same + * generation, exactly like [connectionIdentity]. Mid-session additions are gated on that same generation, so + * late-arriving traffic from a superseded session can never contaminate the new session's set. + */ + val currentSessionNodeNums: StateFlow?> + + /** + * Publishes the exact set of node numbers [sessionGeneration]'s Stage 2 handshake downloaded (+ the local node). + * This replaces (rather than extends) any previous set, and is the only point at which membership can shrink. + */ + fun publishCurrentSessionNodeNums(sessionGeneration: Long, nodeNums: Set) + /** Loads the cached node database from the repository. */ fun loadCachedNodeDB() diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml index 15f772168b6..e02b6777fcd 100644 --- a/core/resources/src/commonMain/composeResources/values/strings.xml +++ b/core/resources/src/commonMain/composeResources/values/strings.xml @@ -1272,6 +1272,8 @@ Node Number Couldn't send request. Try again. Restarting… + Saved on phone + Not in the radio's current node list — showing data saved on this phone A-Z Node sorting options Channel diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/BuildNodeDescription.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/BuildNodeDescription.kt index eacf3919621..95d60ac1fb4 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/BuildNodeDescription.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/BuildNodeDescription.kt @@ -31,6 +31,7 @@ import org.meshtastic.core.resources.a11y_node_online import org.meshtastic.core.resources.a11y_node_role import org.meshtastic.core.resources.a11y_node_signal import org.meshtastic.core.resources.node_incomplete +import org.meshtastic.core.resources.node_saved_on_phone_description import org.meshtastic.core.resources.now import org.meshtastic.core.resources.unknown import org.meshtastic.core.ui.util.formatAgo @@ -54,6 +55,7 @@ internal data class NodeDescriptionStrings( val unknown: String, val now: String, val incomplete: String, + val savedOnPhone: String, ) /** Resolves [NodeDescriptionStrings] from Compose string resources. */ @@ -71,6 +73,7 @@ internal fun rememberNodeDescriptionStrings(): NodeDescriptionStrings = NodeDesc unknown = stringResource(Res.string.unknown), now = stringResource(Res.string.now), incomplete = stringResource(Res.string.node_incomplete), + savedOnPhone = stringResource(Res.string.node_saved_on_phone_description), ) /** Builds a TalkBack-friendly description aggregating node state. Shared between [NodeItem] and [NodeItemCompact]. */ @@ -90,6 +93,7 @@ internal fun buildNodeDescription( lastHeardIsRelative: Boolean = true, modemPreset: ModemPreset? = null, isUnknownUser: Boolean = false, + isSavedOnPhone: Boolean = false, ): String = buildString { append(name) if (isUnknownUser) { @@ -97,11 +101,17 @@ internal fun buildNodeDescription( append(strings.incomplete) } append(", ") - append(if (isOnline) strings.online else strings.offline) + // A saved-on-phone row wasn't observed this session — announcing "online" from a cached reading would repeat + // the same false-freshness claim the badge exists to correct (#6263). + append(if (isOnline && !isSavedOnPhone) strings.online else strings.offline) if (isFavorite) { append(", ") append(strings.favorite) } + if (isSavedOnPhone) { + append(", ") + append(strings.savedOnPhone) + } if (lastHeard > 0) { val timeText = if (lastHeardIsRelative) { diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/NodeItem.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/NodeItem.kt index 755e8780c12..1b1ce654b07 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/NodeItem.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/NodeItem.kt @@ -94,6 +94,7 @@ fun NodeItem( isActive: Boolean = false, showTelemetry: Boolean = true, deviceImageUrl: String? = null, + isSavedOnPhone: Boolean = false, ) { val originalLongName = thatNode.user.long_name.ifEmpty { stringResource(Res.string.unknown_username) } val isMuted = remember(thatNode) { thatNode.isMuted } @@ -135,7 +136,7 @@ fun NodeItem( val a11yStrings = rememberNodeDescriptionStrings() val modemPreset = LocalModemPreset.current val nodeDescription = - remember(thatNode, distance, a11yStrings, modemPreset) { + remember(thatNode, distance, a11yStrings, modemPreset, isSavedOnPhone) { buildNodeDescription( name = originalLongName, isOnline = thatNode.isOnline, @@ -150,6 +151,7 @@ fun NodeItem( strings = a11yStrings, modemPreset = modemPreset, isUnknownUser = thatNode.isUnknownUser, + isSavedOnPhone = isSavedOnPhone, ) } @@ -188,6 +190,7 @@ fun NodeItem( connectionState = connectionState, deviceType = deviceType, contentColor = contentColor, + isSavedOnPhone = isSavedOnPhone, ) thatNode.nodeStatus?.let { status -> @@ -309,9 +312,10 @@ private fun NodeSignalRow(thatNode: Node, isThisNode: Boolean, contentColor: Col ) } } else { - if (thatNode.hopsAway > 0) { - add { HopsInfo(hops = thatNode.hopsAway, contentColor = contentColor) } - } else if (thatNode.hopsAway == 0 && !thatNode.viaMqtt) { + val hopsAway = thatNode.hopsAwayOrNull + if (hopsAway != null && hopsAway > 0) { + add { HopsInfo(hops = hopsAway, contentColor = contentColor) } + } else if (hopsAway == 0 && !thatNode.viaMqtt) { val snr = thatNode.snrOrNull val rssi = thatNode.rssiOrNull if (snr != null || rssi != null) { @@ -501,6 +505,7 @@ private fun NodeItemHeader( connectionState: ConnectionState, deviceType: DeviceType?, contentColor: Color, + isSavedOnPhone: Boolean, ) { Row( modifier = Modifier.fillMaxWidth(), @@ -533,7 +538,10 @@ private fun NodeItemHeader( Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) { StatusAwareLastHeard( lastHeard = thatNode.lastHeard, - online = !isThisNode && thatNode.isOnline, + // A row not observed this session must not carry the "online" affordance forward from a cached + // reading — that's the same false-freshness claim the "Saved on phone" badge exists to correct + // (#6263). + online = !isThisNode && thatNode.isOnline && !isSavedOnPhone, contentColor = contentColor, ) } @@ -548,6 +556,7 @@ private fun NodeItemHeader( deviceType = deviceType, contentColor = contentColor, isUnknownUser = thatNode.isUnknownUser, + isSavedOnPhone = isSavedOnPhone, ) } } diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/NodeItemCompact.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/NodeItemCompact.kt index 5d002ef4299..4d4c0e83f9e 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/NodeItemCompact.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/NodeItemCompact.kt @@ -63,6 +63,8 @@ import org.meshtastic.core.resources.ic_memory import org.meshtastic.core.resources.node_incomplete import org.meshtastic.core.resources.node_list_click_label import org.meshtastic.core.resources.node_list_long_click_label +import org.meshtastic.core.resources.node_saved_on_phone +import org.meshtastic.core.resources.node_saved_on_phone_description import org.meshtastic.core.resources.unknown_username import org.meshtastic.core.ui.icon.Channel import org.meshtastic.core.ui.icon.Counter0 @@ -83,6 +85,7 @@ import org.meshtastic.core.ui.icon.MapCompass import org.meshtastic.core.ui.icon.MeshtasticIcons import org.meshtastic.core.ui.icon.MqttConnected import org.meshtastic.core.ui.icon.PersonQuestion +import org.meshtastic.core.ui.icon.PhoneAndroid import org.meshtastic.core.ui.icon.Pressure import org.meshtastic.core.ui.icon.Temperature import org.meshtastic.core.ui.icon.Unmessageable @@ -114,6 +117,7 @@ fun NodeItemCompact( showTelemetry: Boolean = true, tempInFahrenheit: Boolean = false, deviceImageUrl: String? = null, + isSavedOnPhone: Boolean = false, ) { val longName = thatNode.user.long_name.ifEmpty { stringResource(Res.string.unknown_username) } val isFavorite = thatNode.isFavorite @@ -147,7 +151,7 @@ fun NodeItemCompact( val a11yStrings = rememberNodeDescriptionStrings() val modemPreset = LocalModemPreset.current val nodeDescription = - remember(thatNode, distance, lastHeardIsRelative, a11yStrings, modemPreset) { + remember(thatNode, distance, lastHeardIsRelative, a11yStrings, modemPreset, isSavedOnPhone) { buildNodeDescription( name = longName, isOnline = thatNode.isOnline, @@ -163,6 +167,7 @@ fun NodeItemCompact( lastHeardIsRelative = lastHeardIsRelative, modemPreset = modemPreset, isUnknownUser = thatNode.isUnknownUser, + isSavedOnPhone = isSavedOnPhone, ) } @@ -216,6 +221,7 @@ fun NodeItemCompact( isIgnored = isIgnored, isFavorite = isFavorite, unmessageable = unmessageable, + isSavedOnPhone = isSavedOnPhone, ) // Row 2: Glanceable health — online + last heard + distance + signal @@ -229,6 +235,7 @@ fun NodeItemCompact( showLocation = showLocation, showSignal = showSignal, contentColor = contentColor, + isSavedOnPhone = isSavedOnPhone, ) // Row 3: Environment metrics — temp · humidity · pressure (icon + value only) @@ -262,6 +269,7 @@ private fun CompactNameRow( isIgnored: Boolean, isFavorite: Boolean, unmessageable: Boolean, + isSavedOnPhone: Boolean, ) { Row( modifier = Modifier.fillMaxWidth(), @@ -277,6 +285,14 @@ private fun CompactNameRow( overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f), ) + if (isSavedOnPhone) { + StatusBadge( + imageVector = MeshtasticIcons.PhoneAndroid, + contentDescription = Res.string.node_saved_on_phone, + tooltipText = Res.string.node_saved_on_phone_description, + tint = MaterialTheme.colorScheme.outline, + ) + } if (thatNode.isUnknownUser) { Icon( imageVector = MeshtasticIcons.PersonQuestion, @@ -316,6 +332,7 @@ private fun CompactHealthRow( showLocation: Boolean, showSignal: Boolean, contentColor: Color, + isSavedOnPhone: Boolean, ) { val segments = buildList { // Last heard (tinted by online status) @@ -324,7 +341,10 @@ private fun CompactHealthRow( @Composable { StatusAwareLastHeard( lastHeard = thatNode.lastHeard, - online = thatNode.isOnline, + // A row not observed this session must not carry the "online" affordance forward from a + // cached reading — that's the same false-freshness claim the "Saved on phone" badge exists + // to correct (#6263). + online = thatNode.isOnline && !isSavedOnPhone, contentColor = contentColor, relative = lastHeardIsRelative, ) @@ -418,13 +438,14 @@ private fun CompactFooterRow( ) } } - if (showHops && thatNode.hopsAway > 0 && !isThisNode) { + val hopsAway = thatNode.hopsAwayOrNull + if (showHops && hopsAway != null && hopsAway > 0 && !isThisNode) { add { IconInfo( icon = MeshtasticIcons.HopCount, - contentDescription = "${thatNode.hopsAway} hops", + contentDescription = "$hopsAway hops", contentColor = tertiaryColor, - text = thatNode.hopsAway.toString(), + text = hopsAway.toString(), ) } } diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/NodeStatusIcons.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/NodeStatusIcons.kt index 64508e54835..6d6fffcece7 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/NodeStatusIcons.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/NodeStatusIcons.kt @@ -47,11 +47,14 @@ import org.meshtastic.core.resources.favorite import org.meshtastic.core.resources.mute_always import org.meshtastic.core.resources.node_incomplete import org.meshtastic.core.resources.node_incomplete_description +import org.meshtastic.core.resources.node_saved_on_phone +import org.meshtastic.core.resources.node_saved_on_phone_description import org.meshtastic.core.resources.unmessageable import org.meshtastic.core.resources.unmonitored_or_infrastructure import org.meshtastic.core.ui.icon.Favorite import org.meshtastic.core.ui.icon.MeshtasticIcons import org.meshtastic.core.ui.icon.PersonQuestion +import org.meshtastic.core.ui.icon.PhoneAndroid import org.meshtastic.core.ui.icon.Unmessageable import org.meshtastic.core.ui.icon.VolumeOff import org.meshtastic.core.ui.theme.StatusColors.StatusYellow @@ -69,8 +72,17 @@ fun NodeStatusIcons( deviceType: DeviceType? = null, contentColor: Color = LocalContentColor.current, isUnknownUser: Boolean = false, + isSavedOnPhone: Boolean = false, ) { Row(modifier = modifier.padding(4.dp)) { + if (isSavedOnPhone) { + StatusBadge( + imageVector = MeshtasticIcons.PhoneAndroid, + contentDescription = Res.string.node_saved_on_phone, + tooltipText = Res.string.node_saved_on_phone_description, + tint = contentColor, + ) + } if (isUnknownUser) { StatusBadge( imageVector = MeshtasticIcons.PersonQuestion, @@ -150,9 +162,10 @@ private fun ThisNodeStatusBadge(connectionState: ConnectionState, deviceType: De } } +/** Icon + tooltip status indicator. Internal (not private) so [NodeItemCompact] can reuse it for its own badges. */ @OptIn(ExperimentalMaterial3Api::class) @Composable -private fun StatusBadge( +internal fun StatusBadge( imageVector: ImageVector, contentDescription: StringResource, tooltipText: StringResource, diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/SavedOnPhoneBadge.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/SavedOnPhoneBadge.kt new file mode 100644 index 00000000000..47c88c21d9b --- /dev/null +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/SavedOnPhoneBadge.kt @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.ui.component + +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.tooling.preview.PreviewLightDark +import org.jetbrains.compose.resources.stringResource +import org.meshtastic.core.resources.Res +import org.meshtastic.core.resources.node_saved_on_phone +import org.meshtastic.core.resources.node_saved_on_phone_description +import org.meshtastic.core.ui.icon.MeshtasticIcons +import org.meshtastic.core.ui.icon.PhoneAndroid +import org.meshtastic.core.ui.theme.AppTheme + +/** + * Labeled indicator for a node that is locally cached but wasn't part of the connected radio's current session snapshot + * — the phone deliberately keeps rows the radio no longer reports (multi-radio use, meshes bigger than the radio's own + * bounded NodeDB), so this explains why the row's live metrics may be absent or dated rather than letting it look + * broken (#6263). See [NodeStatusIcons] for the icon-only variant used in dense node rows. + */ +@Composable +fun SavedOnPhoneBadge(modifier: Modifier = Modifier, contentColor: Color = MaterialTheme.colorScheme.outline) { + IconInfo( + modifier = modifier, + icon = MeshtasticIcons.PhoneAndroid, + contentDescription = stringResource(Res.string.node_saved_on_phone_description), + label = stringResource(Res.string.node_saved_on_phone), + contentColor = contentColor, + ) +} + +@PreviewLightDark +@Composable +private fun SavedOnPhoneBadgePreview() { + AppTheme { SavedOnPhoneBadge() } +} diff --git a/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/component/BuildNodeDescriptionTest.kt b/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/component/BuildNodeDescriptionTest.kt index 9ba609691b0..a79a498749c 100644 --- a/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/component/BuildNodeDescriptionTest.kt +++ b/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/component/BuildNodeDescriptionTest.kt @@ -38,6 +38,7 @@ class BuildNodeDescriptionTest { unknown = "unknown", now = "now", incomplete = "incomplete", + savedOnPhone = "saved on phone", ) private fun describe( @@ -53,6 +54,7 @@ class BuildNodeDescriptionTest { viaMqtt: Boolean = false, lastHeardIsRelative: Boolean = true, isUnknownUser: Boolean = false, + isSavedOnPhone: Boolean = false, ): String = buildNodeDescription( name = name, isOnline = isOnline, @@ -67,6 +69,7 @@ class BuildNodeDescriptionTest { strings = testStrings, lastHeardIsRelative = lastHeardIsRelative, isUnknownUser = isUnknownUser, + isSavedOnPhone = isSavedOnPhone, ) // ---- Basic output ---- @@ -199,4 +202,32 @@ class BuildNodeDescriptionTest { val result = describe(snr = -5f, hopsAway = 0, viaMqtt = false) assertContains(result, "signal") } + + // ---- Saved on phone (#6263) ---- + + @Test + fun includes_saved_on_phone_when_flagged() { + val result = describe(isSavedOnPhone = true) + assertContains(result, "saved on phone") + } + + @Test + fun omits_saved_on_phone_by_default() { + val result = describe() + assertFalse(result.contains("saved on phone")) + } + + @Test + fun reports_offline_when_saved_on_phone_even_if_isOnline_is_true() { + // A row not observed this session must not announce "online" from a cached reading (#6263) — that's the + // same false-freshness claim the badge exists to correct. + val result = describe(isOnline = true, isSavedOnPhone = true) + assertContains(result, "offline") + } + + @Test + fun reports_online_normally_when_not_saved_on_phone() { + val result = describe(isOnline = true, isSavedOnPhone = false) + assertTrue(result.contains("online")) + } } diff --git a/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/component/NodeItemZeroMetricsTest.kt b/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/component/NodeItemZeroMetricsTest.kt index 68d6e91a469..4b0560955d3 100644 --- a/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/component/NodeItemZeroMetricsTest.kt +++ b/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/component/NodeItemZeroMetricsTest.kt @@ -20,10 +20,12 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.ui.test.ComposeUiTest import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.v2.runComposeUiTest import org.meshtastic.core.model.ConnectionState import org.meshtastic.core.model.Node +import org.meshtastic.core.model.util.onlineTimeThreshold import org.meshtastic.proto.Config.DisplayConfig.DisplayUnits import org.meshtastic.proto.EnvironmentMetrics import org.meshtastic.proto.User @@ -103,24 +105,120 @@ class NodeItemZeroMetricsTest { onNodeWithText("0.0°C").assertDoesNotExist() } - private fun ComposeUiTest.setNodeItem(metrics: EnvironmentMetrics) = setContent { + // hopsAway: -1 (Node.HOPS_AWAY_UNSET) is "never resolved", not a real reading — 0 is a real direct-neighbor + // reading. Pinned as a pair (per the code-review "presence vs. sentinel zero" checklist) so the two states can't + // collapse back into one: a real 0 must not read as "unresolved", and "unresolved" must not paint a fake "0 hops". + @Test + fun nodeItem_showsHopsChipForARealNonZeroReading() = runComposeUiTest { + setNodeItem(EnvironmentMetrics(), hopsAway = 3) + onNodeWithText("Hops Away").assertIsDisplayed() + onNodeWithText("3").assertIsDisplayed() + } + + @Test + fun nodeItem_hidesHopsChipWhenNeverResolved() = runComposeUiTest { + setNodeItem(EnvironmentMetrics(), hopsAway = Node.HOPS_AWAY_UNSET) + onNodeWithText("Hops Away").assertDoesNotExist() + } + + @Test + fun nodeItemCompact_showsHopsChipForARealNonZeroReading() = runComposeUiTest { + setNodeItemCompact(EnvironmentMetrics(), hopsAway = 4) + onNodeWithText("4").assertIsDisplayed() + } + + @Test + fun nodeItemCompact_hidesHopsChipWhenNeverResolved() = runComposeUiTest { + setNodeItemCompact(EnvironmentMetrics(), hopsAway = Node.HOPS_AWAY_UNSET) + onNodeWithText("4").assertDoesNotExist() + } + + // "Saved on phone" badge (#6263): flags a node that is locally cached but wasn't part of the connected radio's + // current session snapshot, instead of letting it render as if it were freshly heard. + @Test + fun nodeItem_showsSavedOnPhoneBadgeWhenFlagged() = runComposeUiTest { + setNodeItem(EnvironmentMetrics(), isSavedOnPhone = true) + onNodeWithContentDescription("Saved on phone").assertIsDisplayed() + } + + @Test + fun nodeItem_hidesSavedOnPhoneBadgeByDefault() = runComposeUiTest { + setNodeItem(EnvironmentMetrics()) + onNodeWithContentDescription("Saved on phone").assertDoesNotExist() + } + + @Test + fun nodeItemCompact_showsSavedOnPhoneBadgeWhenFlagged() = runComposeUiTest { + setNodeItemCompact(EnvironmentMetrics(), isSavedOnPhone = true) + onNodeWithContentDescription("Saved on phone").assertIsDisplayed() + } + + @Test + fun nodeItemCompact_hidesSavedOnPhoneBadgeByDefault() = runComposeUiTest { + setNodeItemCompact(EnvironmentMetrics()) + onNodeWithContentDescription("Saved on phone").assertDoesNotExist() + } + + // A saved-on-phone row's lastHeard is real historical data (not a sentinel), but announcing it as "online" would + // carry forward the exact false-freshness claim the badge exists to correct (#6263) — see + // BuildNodeDescriptionTest for the string-level coverage of this same branch. + @Test + fun nodeItem_doesNotClaimOnlineWhenSavedOnPhoneEvenWithRecentLastHeard() = runComposeUiTest { + setNodeItem(EnvironmentMetrics(), isSavedOnPhone = true, lastHeard = onlineTimeThreshold() + 1) + onNodeWithContentDescription("online", substring = true).assertDoesNotExist() + } + + @Test + fun nodeItem_claimsOnlineForARecentlyHeardNodeNotSavedOnPhone() = runComposeUiTest { + setNodeItem(EnvironmentMetrics(), isSavedOnPhone = false, lastHeard = onlineTimeThreshold() + 1) + onNodeWithContentDescription("online", substring = true).assertIsDisplayed() + } + + @Test + fun nodeItemCompact_doesNotClaimOnlineWhenSavedOnPhoneEvenWithRecentLastHeard() = runComposeUiTest { + setNodeItemCompact(EnvironmentMetrics(), isSavedOnPhone = true, lastHeard = onlineTimeThreshold() + 1) + onNodeWithContentDescription("online", substring = true).assertDoesNotExist() + } + + private fun ComposeUiTest.setNodeItem( + metrics: EnvironmentMetrics, + hopsAway: Int = Node.HOPS_AWAY_UNSET, + isSavedOnPhone: Boolean = false, + lastHeard: Int = 0, + ) = setContent { MaterialTheme { NodeItem( thisNode = null, - thatNode = node(metrics), + thatNode = node(metrics, hopsAway, lastHeard), distanceUnits = DisplayUnits.METRIC.value, tempInFahrenheit = false, connectionState = ConnectionState.Connected, + isSavedOnPhone = isSavedOnPhone, ) } } - private fun ComposeUiTest.setNodeItemCompact(metrics: EnvironmentMetrics) = setContent { + private fun ComposeUiTest.setNodeItemCompact( + metrics: EnvironmentMetrics, + hopsAway: Int = Node.HOPS_AWAY_UNSET, + isSavedOnPhone: Boolean = false, + lastHeard: Int = 0, + ) = setContent { MaterialTheme { - NodeItemCompact(thisNode = null, thatNode = node(metrics), distanceUnits = DisplayUnits.METRIC.value) + NodeItemCompact( + thisNode = null, + thatNode = node(metrics, hopsAway, lastHeard), + distanceUnits = DisplayUnits.METRIC.value, + isSavedOnPhone = isSavedOnPhone, + ) } } - private fun node(metrics: EnvironmentMetrics) = - Node(num = 2, user = User(id = "!2", long_name = "Sensor"), environmentMetrics = metrics) + private fun node(metrics: EnvironmentMetrics, hopsAway: Int = Node.HOPS_AWAY_UNSET, lastHeard: Int = 0) = Node( + num = 2, + user = User(id = "!2", long_name = "Sensor"), + environmentMetrics = metrics, + hopsAway = hopsAway, + lastHeard = lastHeard, + ) } diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeDetailComponentPreviews.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeDetailComponentPreviews.kt index c85a9d1edc5..260740f158d 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeDetailComponentPreviews.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeDetailComponentPreviews.kt @@ -301,6 +301,15 @@ fun NodeDetailsSectionSignedPreview() { AppTheme { Surface { NodeDetailsSection(node = node) } } } +@Suppress("PreviewPublic") +@PreviewLightDark +@Composable +fun NodeDetailsSectionSavedOnPhonePreview() { + // Locally-retained node absent from the connected radio's current session snapshot (#6263). + val node = previewData.mickeyMouse + AppTheme { Surface { NodeDetailsSection(node = node, isSavedOnPhone = true) } } +} + @PreviewLightDark @Composable private fun NodeDetailsSectionWithDeviceHeroPreview() { diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeDetailsSection.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeDetailsSection.kt index c2d1f7160d8..6f1f7a462f2 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeDetailsSection.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeDetailsSection.kt @@ -81,6 +81,7 @@ import org.meshtastic.core.resources.supported import org.meshtastic.core.resources.transport import org.meshtastic.core.resources.uptime import org.meshtastic.core.resources.user_id +import org.meshtastic.core.ui.component.SavedOnPhoneBadge import org.meshtastic.core.ui.component.SignedNodeDialog import org.meshtastic.core.ui.component.transportInfo import org.meshtastic.core.ui.icon.ArrowCircleUp @@ -110,9 +111,13 @@ fun NodeDetailsSection( modifier: Modifier = Modifier, deviceHardware: DeviceHardware? = null, reportedTarget: String? = null, + isSavedOnPhone: Boolean = false, ) { SectionCard(title = Res.string.details, modifier = modifier) { Column(modifier = Modifier.animateContentSize()) { + if (isSavedOnPhone) { + SavedOnPhoneBadge(modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp)) + } if (node.mismatchKey) { MismatchKeyWarning(Modifier.padding(horizontal = 16.dp)) Spacer(Modifier.height(16.dp)) @@ -176,7 +181,7 @@ private fun MainNodeDetails(node: Node) { HearsAndHopsRow(node) SectionDivider() UserAndUptimeRow(node) - if (node.hopsAway == 0 && !node.viaMqtt) { + if (node.hopsAwayOrNull == 0 && !node.viaMqtt) { SectionDivider() SignalRow(node) } @@ -251,10 +256,11 @@ private fun HearsAndHopsRow(node: Node) { icon = MeshtasticIcons.History, modifier = Modifier.weight(1f), ) - if (node.hopsAway >= 0) { + val hopsAway = node.hopsAwayOrNull + if (hopsAway != null) { InfoItem( label = stringResource(Res.string.hops_away), - value = node.hopsAway.toString(), + value = hopsAway.toString(), icon = MeshtasticIcons.HopCount, modifier = Modifier.weight(1f), ) diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeListItemPreviews.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeListItemPreviews.kt index 3b683e9dd4f..abbd65d3eca 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeListItemPreviews.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeListItemPreviews.kt @@ -100,6 +100,30 @@ fun NodeItemSignedPreview() { } } +@PreviewLightDark +@Composable +fun NodeItemSavedOnPhonePreview() { + // Locally-retained node absent from the connected radio's current session snapshot (#6263). Backdated lastHeard + // shows the "Saved on phone" badge suppresses the "online" affordance even for a reading that would otherwise + // read as live. + val savedOnPhoneNode = + previewNodes.minnieMouse.copy(lastHeard = (org.meshtastic.core.common.util.nowSeconds - 300).toInt()) + AppTheme { + Surface { + Column(modifier = Modifier.fillMaxWidth().padding(8.dp)) { + NodeItem( + thisNode = previewNodes.mickeyMouse, + thatNode = savedOnPhoneNode, + distanceUnits = 0, + tempInFahrenheit = false, + connectionState = ConnectionState.Connected, + isSavedOnPhone = true, + ) + } + } + } +} + @PreviewLightDark @Composable fun NodeItemUnknownPreview() { @@ -206,6 +230,26 @@ fun NodeItemCompactOnlineRemotePreview() { } } +@PreviewLightDark +@Composable +fun NodeItemCompactSavedOnPhonePreview() { + // Compact-density counterpart of [NodeItemSavedOnPhonePreview] (#6263). + val savedOnPhoneNode = + previewNodes.minnieMouse.copy(lastHeard = (org.meshtastic.core.common.util.nowSeconds - 300).toInt()) + AppTheme { + Surface { + Column(modifier = Modifier.fillMaxWidth().padding(8.dp)) { + NodeItemCompact( + thisNode = previewNodes.mickeyMouse, + thatNode = savedOnPhoneNode, + distanceUnits = 0, + isSavedOnPhone = true, + ) + } + } + } +} + @PreviewLightDark @Composable fun NodeItemCompleteOnlineRemotePreview() { diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailContent.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailContent.kt index f3c94df515f..b9dc9bfc564 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailContent.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailContent.kt @@ -108,6 +108,7 @@ fun NodeDetailList( node = node, deviceHardware = uiState.metricsState.deviceHardware, reportedTarget = uiState.metricsState.reportedTarget, + isSavedOnPhone = uiState.isSavedOnPhone, ) } if (uiState.metricsState.deviceLinks.isNotEmpty()) { diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailViewModel.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailViewModel.kt index 354f91f348b..1a5cb40e07e 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailViewModel.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailViewModel.kt @@ -71,6 +71,8 @@ data class NodeDetailUiState( val lastRequestNeighborsTime: Long? = null, val sessionStatus: SessionStatus = SessionStatus.NoSession, val isEnsuringSession: Boolean = false, + /** True when [node] is locally cached but wasn't part of the connected radio's current session snapshot. */ + val isSavedOnPhone: Boolean = false, ) /** diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/domain/usecase/CommonGetNodeDetailsUseCase.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/domain/usecase/CommonGetNodeDetailsUseCase.kt index 25e87fc1090..145a7b8fd89 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/domain/usecase/CommonGetNodeDetailsUseCase.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/domain/usecase/CommonGetNodeDetailsUseCase.kt @@ -38,6 +38,7 @@ import org.meshtastic.core.repository.DeviceHardwareRepository import org.meshtastic.core.repository.DeviceLinkRepository import org.meshtastic.core.repository.FirmwareReleaseRepository import org.meshtastic.core.repository.MeshLogRepository +import org.meshtastic.core.repository.NodeManager import org.meshtastic.core.repository.NodeRepository import org.meshtastic.core.repository.RadioConfigRepository import org.meshtastic.core.resources.Res @@ -66,13 +67,19 @@ constructor( private val deviceLinkRepository: DeviceLinkRepository, private val firmwareReleaseRepository: FirmwareReleaseRepository, private val nodeRequestActions: NodeRequestActions, + private val nodeManager: NodeManager, ) : GetNodeDetailsUseCase { @OptIn(ExperimentalCoroutinesApi::class) @Suppress("LongMethod", "CyclomaticComplexMethod") - override operator fun invoke(nodeId: Int): Flow = - nodeRepository.effectiveLogNodeId(nodeId).flatMapLatest { effectiveNodeId -> - buildFlow(nodeId, effectiveNodeId) + override operator fun invoke(nodeId: Int): Flow = nodeRepository + .effectiveLogNodeId(nodeId) + .flatMapLatest { effectiveNodeId -> buildFlow(nodeId, effectiveNodeId) } + .combine(nodeManager.currentSessionNodeNums) { state, sessionNodeNums -> + // Null session snapshot means the current session's Stage 2 handshake hasn't completed yet — there + // is nothing trustworthy to compare against, so nothing should be flagged (#6263). + val isSavedOnPhone = sessionNodeNums != null && state.node?.num?.let { it !in sessionNodeNums } == true + state.copy(isSavedOnPhone = isSavedOnPhone) } @Suppress("LongMethod", "CyclomaticComplexMethod") diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeListScreen.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeListScreen.kt index f550b26584f..a84c00bdd70 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeListScreen.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeListScreen.kt @@ -117,6 +117,7 @@ fun NodeListScreen( val totalNodeCount by viewModel.totalNodeCount.collectAsStateWithLifecycle(0) val unfilteredNodes by viewModel.unfilteredNodeList.collectAsStateWithLifecycle() val deviceImageUrls by viewModel.deviceImageUrls.collectAsStateWithLifecycle() + val currentSessionNodeNums by viewModel.currentSessionNodeNums.collectAsStateWithLifecycle() val ignoredNodeCount = unfilteredNodes.count { it.isIgnored } val listState = rememberLazyListState() @@ -265,6 +266,13 @@ fun NodeListScreen( } val isActive = remember(activeNodeId, node.num) { activeNodeId == node.num } + // Null session snapshot (not yet connected, or handshake not yet complete this session) + // means there is nothing trustworthy to compare against — don't badge anything until the + // radio has actually reported its own NodeDB for this connection (#6263). + val isSavedOnPhone = + remember(currentSessionNodeNums, node.num) { + currentSessionNodeNums?.let { node.num !in it } ?: false + } when (density) { NodeListDensity.COMPLETE -> @@ -281,6 +289,7 @@ fun NodeListScreen( isActive = isActive, showTelemetry = showTelemetry, deviceImageUrl = deviceImageUrls[node.user.hw_model.value], + isSavedOnPhone = isSavedOnPhone, ) NodeListDensity.COMPACT -> @@ -303,6 +312,7 @@ fun NodeListScreen( showTelemetry = showTelemetry, tempInFahrenheit = state.tempInFahrenheit, deviceImageUrl = deviceImageUrls[node.user.hw_model.value], + isSavedOnPhone = isSavedOnPhone, ) } val isThisNode = remember(node) { ourNode?.num == node.num } diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeListViewModel.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeListViewModel.kt index 47aa3ba22e8..6df651217ec 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeListViewModel.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeListViewModel.kt @@ -39,6 +39,7 @@ import org.meshtastic.core.model.util.DistanceUnit import org.meshtastic.core.repository.AdminController import org.meshtastic.core.repository.ConnectionStateProvider import org.meshtastic.core.repository.DeviceHardwareRepository +import org.meshtastic.core.repository.NodeManager import org.meshtastic.core.repository.NodeRepository import org.meshtastic.core.repository.RadioConfigRepository import org.meshtastic.core.repository.RadioInterfaceService @@ -60,6 +61,7 @@ class NodeListViewModel( private val adminController: AdminController, private val radioInterfaceService: RadioInterfaceService, private val deviceHardwareRepository: DeviceHardwareRepository, + private val nodeManager: NodeManager, val nodeManagementActions: NodeManagementActions, private val nodeRequestActions: NodeRequestActions, private val getFilteredNodesUseCase: GetFilteredNodesUseCase, @@ -68,6 +70,14 @@ class NodeListViewModel( val ourNodeInfo: StateFlow = nodeRepository.ourNodeInfo + /** + * Node numbers the connected radio's own NodeDB reported for the current session, or null before that handshake has + * completed at least once. Used to badge locally-retained rows the radio did NOT just report (#6263) — see + * [NodeManager.currentSessionNodeNums]. + */ + val currentSessionNodeNums: StateFlow?> = + nodeManager.currentSessionNodeNums.stateInWhileSubscribed(initialValue = null) + val onlineNodeCount = nodeRepository.onlineNodeCount.stateInWhileSubscribed(initialValue = 0) val totalNodeCount = nodeRepository.totalNodeCount.stateInWhileSubscribed(initialValue = 0) diff --git a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/list/NodeListViewModelTest.kt b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/list/NodeListViewModelTest.kt index e4bcc4a6157..1200f143fdf 100644 --- a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/list/NodeListViewModelTest.kt +++ b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/list/NodeListViewModelTest.kt @@ -29,6 +29,7 @@ import org.meshtastic.core.model.ConnectionState import org.meshtastic.core.model.Node import org.meshtastic.core.model.NodeSortOption import org.meshtastic.core.repository.ConnectionStateProvider +import org.meshtastic.core.repository.NodeManager import org.meshtastic.core.repository.RadioConfigRepository import org.meshtastic.core.testing.FakeDeviceHardwareRepository import org.meshtastic.core.testing.FakeNodeRepository @@ -55,6 +56,7 @@ class NodeListViewModelTest { private val nodeManagementActions: NodeManagementActions = mock(MockMode.autofill) private val nodeRequestActions: NodeRequestActions = mock(MockMode.autofill) private val getFilteredNodesUseCase: GetFilteredNodesUseCase = mock(MockMode.autofill) + private val nodeManager: NodeManager = mock(MockMode.autofill) @BeforeTest fun setUp() { @@ -75,6 +77,7 @@ class NodeListViewModelTest { every { nodeFilterPreferences.excludeMqtt } returns MutableStateFlow(false) every { getFilteredNodesUseCase(any(), any()) } returns MutableStateFlow(emptyList()) + every { nodeManager.currentSessionNodeNums } returns MutableStateFlow(null) viewModel = createViewModel() } @@ -87,6 +90,7 @@ class NodeListViewModelTest { adminController = radioController, radioInterfaceService = radioInterfaceService, deviceHardwareRepository = FakeDeviceHardwareRepository(), + nodeManager = nodeManager, nodeManagementActions = nodeManagementActions, nodeRequestActions = nodeRequestActions, getFilteredNodesUseCase = getFilteredNodesUseCase, @@ -141,4 +145,18 @@ class NodeListViewModelTest { cancelAndIgnoreRemainingEvents() } } + + @Test + fun `currentSessionNodeNums mirrors nodeManager until a session snapshot is published`() = runTest { + val stateFlow = MutableStateFlow?>(null) + every { nodeManager.currentSessionNodeNums } returns stateFlow + + val vm = createViewModel() + vm.currentSessionNodeNums.test { + assertEquals(null, awaitItem()) + stateFlow.value = setOf(100, 200) + assertEquals(setOf(100, 200), awaitItem()) + cancelAndIgnoreRemainingEvents() + } + } } diff --git a/screenshot-tests/src/screenshotTest/kotlin/org/meshtastic/screenshots/feature/NodeScreenshotTests.kt b/screenshot-tests/src/screenshotTest/kotlin/org/meshtastic/screenshots/feature/NodeScreenshotTests.kt index 64304a71aad..ba05bcf8c5b 100644 --- a/screenshot-tests/src/screenshotTest/kotlin/org/meshtastic/screenshots/feature/NodeScreenshotTests.kt +++ b/screenshot-tests/src/screenshotTest/kotlin/org/meshtastic/screenshots/feature/NodeScreenshotTests.kt @@ -26,15 +26,18 @@ import org.meshtastic.feature.node.component.EnvironmentMetricsPreview import org.meshtastic.feature.node.component.HopHistogramContentPreview import org.meshtastic.feature.node.component.HopHistogramEmptyPreview import org.meshtastic.feature.node.component.NodeDetailsSectionPreview +import org.meshtastic.feature.node.component.NodeDetailsSectionSavedOnPhonePreview import org.meshtastic.feature.node.component.NodeDetailsSectionSignedPreview import org.meshtastic.feature.node.component.NodeItemCompactActivePreview import org.meshtastic.feature.node.component.NodeItemCompactAllFieldsPreview import org.meshtastic.feature.node.component.NodeItemCompactMinimalPreview import org.meshtastic.feature.node.component.NodeItemCompactOnlineRemotePreview +import org.meshtastic.feature.node.component.NodeItemCompactSavedOnPhonePreview import org.meshtastic.feature.node.component.NodeItemCompactUnknownPreview import org.meshtastic.feature.node.component.NodeItemCompleteActivePreview import org.meshtastic.feature.node.component.NodeItemCompleteOnlineRemotePreview import org.meshtastic.feature.node.component.NodeItemCompletePreview +import org.meshtastic.feature.node.component.NodeItemSavedOnPhonePreview import org.meshtastic.feature.node.component.NodeItemSignedPreview import org.meshtastic.feature.node.component.NodeItemUnknownPreview import org.meshtastic.feature.node.component.PositionInlineContentPreview @@ -144,6 +147,13 @@ fun ScreenshotNodeDetailsSectionSigned() { NodeDetailsSectionSignedPreview() } +@PreviewTest +@PreviewLightDark +@Composable +fun ScreenshotNodeDetailsSectionSavedOnPhone() { + NodeDetailsSectionSavedOnPhonePreview() +} + @PreviewTest @PreviewLightDark @Composable @@ -267,6 +277,20 @@ fun ScreenshotNodeItemCompleteOnlineRemote() { NodeItemCompleteOnlineRemotePreview() } +@PreviewTest +@PreviewLightDark +@Composable +fun ScreenshotNodeItemSavedOnPhone() { + NodeItemSavedOnPhonePreview() +} + +@PreviewTest +@PreviewLightDark +@Composable +fun ScreenshotNodeItemCompactSavedOnPhone() { + NodeItemCompactSavedOnPhonePreview() +} + @PreviewTest @PreviewLightDark @Composable diff --git a/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/NodeScreenshotTestsKt/ScreenshotNodeDetailsSectionSavedOnPhone_Dark_d19fbf1f_0.png b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/NodeScreenshotTestsKt/ScreenshotNodeDetailsSectionSavedOnPhone_Dark_d19fbf1f_0.png new file mode 100644 index 00000000000..bc19197fcfb Binary files /dev/null and b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/NodeScreenshotTestsKt/ScreenshotNodeDetailsSectionSavedOnPhone_Dark_d19fbf1f_0.png differ diff --git a/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/NodeScreenshotTestsKt/ScreenshotNodeDetailsSectionSavedOnPhone_Light_b29dc7a7_0.png b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/NodeScreenshotTestsKt/ScreenshotNodeDetailsSectionSavedOnPhone_Light_b29dc7a7_0.png new file mode 100644 index 00000000000..6332a1f6502 Binary files /dev/null and b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/NodeScreenshotTestsKt/ScreenshotNodeDetailsSectionSavedOnPhone_Light_b29dc7a7_0.png differ diff --git a/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/NodeScreenshotTestsKt/ScreenshotNodeItemCompactSavedOnPhone_Dark_d19fbf1f_0.png b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/NodeScreenshotTestsKt/ScreenshotNodeItemCompactSavedOnPhone_Dark_d19fbf1f_0.png new file mode 100644 index 00000000000..8b7aff275ba Binary files /dev/null and b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/NodeScreenshotTestsKt/ScreenshotNodeItemCompactSavedOnPhone_Dark_d19fbf1f_0.png differ diff --git a/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/NodeScreenshotTestsKt/ScreenshotNodeItemCompactSavedOnPhone_Light_b29dc7a7_0.png b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/NodeScreenshotTestsKt/ScreenshotNodeItemCompactSavedOnPhone_Light_b29dc7a7_0.png new file mode 100644 index 00000000000..3725d3c3c98 Binary files /dev/null and b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/NodeScreenshotTestsKt/ScreenshotNodeItemCompactSavedOnPhone_Light_b29dc7a7_0.png differ diff --git a/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/NodeScreenshotTestsKt/ScreenshotNodeItemSavedOnPhone_Dark_d19fbf1f_0.png b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/NodeScreenshotTestsKt/ScreenshotNodeItemSavedOnPhone_Dark_d19fbf1f_0.png new file mode 100644 index 00000000000..3649dffc64d Binary files /dev/null and b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/NodeScreenshotTestsKt/ScreenshotNodeItemSavedOnPhone_Dark_d19fbf1f_0.png differ diff --git a/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/NodeScreenshotTestsKt/ScreenshotNodeItemSavedOnPhone_Light_b29dc7a7_0.png b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/NodeScreenshotTestsKt/ScreenshotNodeItemSavedOnPhone_Light_b29dc7a7_0.png new file mode 100644 index 00000000000..c379db5563d Binary files /dev/null and b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/NodeScreenshotTestsKt/ScreenshotNodeItemSavedOnPhone_Light_b29dc7a7_0.png differ