From 6d972ea40673ca277d2aa99a5a7204e9409ab6cf Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Sat, 12 Sep 2026 04:51:39 -0700 Subject: [PATCH] fix(node): stop presenting MQTT-only nodes as unheard on the current LoRa (#7138) --- .../core/database/entity/NodeEntity.kt | 8 +- .../org/meshtastic/core/model/Capabilities.kt | 6 +- .../kotlin/org/meshtastic/core/model/Node.kt | 24 ++++- .../org/meshtastic/core/model/NodeTest.kt | 15 +++ .../composeResources/values/strings.xml | 6 +- .../meshtastic/core/ui/component/NodeItem.kt | 9 +- .../core/ui/component/NodeItemCompact.kt | 2 +- .../ui/component/NodeItemUnheardMarkerTest.kt | 98 +++++++++++++++++++ .../node/component/NodeListItemPreviews.kt | 4 +- .../domain/usecase/GetFilteredNodesUseCase.kt | 2 +- .../feature/node/list/NodeListScreen.kt | 10 +- .../node/list/RemovableUnheardNodes.kt | 36 +++++++ .../usecase/GetFilteredNodesUseCaseTest.kt | 25 +++++ .../node/list/RemovableUnheardNodesTest.kt | 77 +++++++++++++++ 14 files changed, 295 insertions(+), 27 deletions(-) create mode 100644 core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/component/NodeItemUnheardMarkerTest.kt create mode 100644 feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/RemovableUnheardNodes.kt create mode 100644 feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/list/RemovableUnheardNodesTest.kt diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/NodeEntity.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/NodeEntity.kt index 8faf12790d..1c40e4f663 100644 --- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/NodeEntity.kt +++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/NodeEntity.kt @@ -163,9 +163,11 @@ data class NodeEntity( /** True when this node signs its broadcasts via XEdDSA (NodeInfo.has_xeddsa_signed). */ @ColumnInfo(name = "has_xeddsa_signed", defaultValue = "0") var signsPackets: Boolean = false, /** - * True when the radio has heard this node over RF since its current LoRa config took effect - * (NodeInfo.heard_on_current_lora). Defaults true so nodes stored before this column existed, and nodes from - * firmware that does not report it, are never shown as unheard. + * True when the radio has heard this node over RF on the LoRa configuration it is using now + * (NodeInfo.heard_on_current_lora). The radio derives this from the slot each node was heard on rather than + * clearing it on a config change, so returning to a configuration restores the previous answers. Defaults true so + * nodes stored before this column existed, and nodes from firmware that does not report it, are never shown as + * unheard. */ @ColumnInfo(name = "heard_on_current_lora", defaultValue = "1") var heardOnCurrentLora: Boolean = true, /** diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Capabilities.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Capabilities.kt index c3e03fb507..5e129b863d 100644 --- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Capabilities.kt +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Capabilities.kt @@ -98,9 +98,9 @@ data class Capabilities(val firmwareVersion: String?, internal val forceEnableAl val supportsMeshBeacon = atLeast(V2_8_0) /** - * Whether the node reports [NodeInfo.heard_on_current_lora] - whether it has heard each node since its current LoRa - * config took effect. Gated to [UNRELEASED] until the firmware side ships. Older firmware never sends the field, - * and a proto3 bool defaults to false, so an ungated read marks every node as unheard. + * Whether the node reports [NodeInfo.heard_on_current_lora] - whether it has heard each node over RF on the LoRa + * configuration it is using now. Gated to [UNRELEASED] until the firmware side ships. Older firmware never sends + * the field, and a proto3 bool defaults to false, so an ungated read marks every node as unheard. * * Deliberately outside [forceEnableAll]: every other capability being wrong in a debug build shows a UI the * firmware ignores, but this one being wrong persists false into the node DB and offers real nodes for removal. 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 b35513bf81..23e6a7f177 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 @@ -69,7 +69,11 @@ data class Node( val manuallyVerified: Boolean = false, /** True when this node signs its broadcasts via XEdDSA (NodeInfo.has_xeddsa_signed). Automatic trust. */ val signsPackets: Boolean = false, - /** False when the radio has not heard this node since its current LoRa config took effect. */ + /** + * False when the radio has not heard this node over RF on the LoRa configuration it is using now. The radio derives + * this rather than storing it, so returning to a configuration restores the previous answers. Ask + * [isUnheardOnCurrentLora] rather than this flag: false alone does not mean the node became unreachable. + */ val heardOnCurrentLora: Boolean = true, val nodeStatus: String? = null, /** The transport mechanism this node was last heard over (see [MeshPacket.TransportMechanism]). */ @@ -84,6 +88,24 @@ data class Node( ) { val capabilities: Capabilities by lazy { Capabilities(metadata?.firmware_version) } + /** + * True when the radio does not report having heard this node over RF on the LoRa configuration in force now, and it + * is not an [viaMqtt] node. + * + * This is not a claim that the node was ever heard over RF. `heard_on_current_lora` is a single bool, and the radio + * does not expose its "heard over RF at least once" bit separately, so a node heard under different settings and a + * node never heard at all (one added as a shared contact, say) are indistinguishable here. Both read true. Callers + * that must not act on the second case need another signal - the removal offer relies on favourites, which is what + * firmware marks a contact. + * + * [viaMqtt] nodes are excluded because they arrive over the internet rather than over our own radio, so the radio + * never marks them heard over RF and [heardOnCurrentLora] is false for them permanently - not because a setting + * changed. Presenting those as unreachable would badge every node on an MQTT-uplinked mesh and offer it for + * removal, and removing one is pointless because it returns on the next uplinked packet. + */ + val isUnheardOnCurrentLora: Boolean + get() = !heardOnCurrentLora && !viaMqtt + val isOnline: Boolean get() = isOnline(onlineTimeThreshold()) 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 9a65b79b9e..d67c1c9ebc 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 @@ -164,4 +164,19 @@ class NodeTest { private fun nodeWithPosition(num: Int, latitudeI: Int, longitudeI: Int): Node = Node(num = num, position = Position(latitude_i = latitudeI, longitude_i = longitudeI)) + + @Test + fun `isUnheardOnCurrentLora is true for a node not heard on the config in force now`() { + assertTrue(Node(num = 1, heardOnCurrentLora = false).isUnheardOnCurrentLora) + assertFalse(Node(num = 1, heardOnCurrentLora = true).isUnheardOnCurrentLora) + } + + @Test + fun `isUnheardOnCurrentLora excludes MQTT nodes the radio never heard over RF`() { + // The radio sets the mark only on an RF hear, so a node that only ever arrives over MQTT reports false for + // the lifetime of the entry. Presenting that as "unreachable since you changed settings" would badge every + // node on an MQTT-uplinked mesh and offer it for removal, and removing one is pointless. + assertFalse(Node(num = 1, heardOnCurrentLora = false, viaMqtt = true).isUnheardOnCurrentLora) + assertFalse(Node(num = 1, heardOnCurrentLora = true, viaMqtt = true).isUnheardOnCurrentLora) + } } diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml index 150444e296..55d44c48a6 100644 --- a/core/resources/src/commonMain/composeResources/values/strings.xml +++ b/core/resources/src/commonMain/composeResources/values/strings.xml @@ -1354,7 +1354,7 @@ Node Details Node List Help Node options - Not heard since you changed settings + Not heard on your current LoRa settings Node Number Couldn't send request. Try again. Restarting… @@ -1377,8 +1377,8 @@ Nearby nodes will appear here as they're discovered. Searching for nodes %1$d nodes queued for deletion: - %1$d nodes not heard since you changed settings - 1 node not heard since you changed settings + %1$d nodes not heard on your current LoRa settings + 1 node not heard on your current LoRa settings Keep Remove Noise Floor 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 421b363ae9..4ceefd78fb 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 @@ -461,9 +461,10 @@ private fun MetricsGrid(items: List<@Composable () -> Unit>) { * [LastHeardInfo] tinted StatusGreen when [online] — the "online" affordance — rendered plain otherwise. Shared by the * complete and compact node rows. * - * When [heardOnCurrentLora] is false the radio has not heard this node since its LoRa settings changed, so it cannot be - * reached from here. That is a different claim from "offline" and takes precedence: an online node can still be - * unreachable, and the online tint would say the opposite. + * When [heardOnCurrentLora] is false the radio has not heard this node on the LoRa configuration it is using now, so it + * cannot be reached from here. That is a different claim from "offline" and takes precedence: an online node can still + * be unreachable, and the online tint would say the opposite. Callers pass Node.isUnheardOnCurrentLora inverted, not + * the raw flag, so MQTT-only nodes are not presented as unreachable. */ @Composable internal fun StatusAwareLastHeard( @@ -560,7 +561,7 @@ private fun NodeItemHeader( lastHeard = thatNode.lastHeard, online = !isThisNode && thatNode.isOnline, contentColor = contentColor, - heardOnCurrentLora = isThisNode || thatNode.heardOnCurrentLora, + heardOnCurrentLora = isThisNode || !thatNode.isUnheardOnCurrentLora, ) } } 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 a25efc358f..dae2125624 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 @@ -329,7 +329,7 @@ private fun CompactHealthRow( online = thatNode.isOnline, contentColor = contentColor, relative = lastHeardIsRelative, - heardOnCurrentLora = isThisNode || thatNode.heardOnCurrentLora, + heardOnCurrentLora = isThisNode || !thatNode.isUnheardOnCurrentLora, ) }, ) diff --git a/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/component/NodeItemUnheardMarkerTest.kt b/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/component/NodeItemUnheardMarkerTest.kt new file mode 100644 index 0000000000..0e51bd4993 --- /dev/null +++ b/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/component/NodeItemUnheardMarkerTest.kt @@ -0,0 +1,98 @@ +/* + * 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.ui.test.ComposeUiTest +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.onAllNodesWithContentDescription +import androidx.compose.ui.test.v2.runComposeUiTest +import org.meshtastic.core.common.util.MeasurementSystem +import org.meshtastic.core.common.util.nowSeconds +import org.meshtastic.core.model.ConnectionState +import org.meshtastic.core.model.Node +import org.meshtastic.core.resources.Res +import org.meshtastic.core.resources.getString +import org.meshtastic.core.resources.node_not_heard_on_current_lora +import org.meshtastic.proto.User +import kotlin.test.Test + +/** + * The unreachable marker on both node rows. Each feeds [StatusAwareLastHeard] from `Node.isUnheardOnCurrentLora`, not + * the raw `heardOnCurrentLora` flag, so an MQTT-only node must not wear it: the radio sets the mark only on an RF hear, + * so such a node reports false for the life of the entry and would otherwise be badged forever and offered for removal. + * + * Driven through the real rows rather than [StatusAwareLastHeard] directly - the wiring is what can regress, so the + * via_mqtt cases fail if either row goes back to reading the raw flag. + */ +@OptIn(ExperimentalTestApi::class) +class NodeItemUnheardMarkerTest { + + private fun node(viaMqtt: Boolean) = Node( + num = 1928, + user = User(long_name = "Minnie Mouse", short_name = "MiMo", id = "!minnie"), + lastHeard = (nowSeconds - 300).toInt(), + heardOnCurrentLora = false, + viaMqtt = viaMqtt, + ) + + private fun ComposeUiTest.markerCount() = + onAllNodesWithContentDescription(getString(Res.string.node_not_heard_on_current_lora)) + + private fun ComposeUiTest.setNodeItem(viaMqtt: Boolean) = setContent { + MaterialTheme { + NodeItem( + thisNode = null, + thatNode = node(viaMqtt), + distanceUnits = MeasurementSystem.METRIC, + tempInFahrenheit = false, + connectionState = ConnectionState.Connected, + ) + } + } + + private fun ComposeUiTest.setNodeItemCompact(viaMqtt: Boolean) = setContent { + MaterialTheme { + NodeItemCompact(thisNode = null, thatNode = node(viaMqtt), distanceUnits = MeasurementSystem.METRIC) + } + } + + @Test + fun nodeItem_marksANodeNotHeardOnTheConfigInForceNow() = runComposeUiTest { + setNodeItem(viaMqtt = false) + markerCount().assertCountEquals(1) + } + + @Test + fun nodeItem_withholdsTheMarkerFromAnMqttNode() = runComposeUiTest { + setNodeItem(viaMqtt = true) + markerCount().assertCountEquals(0) + } + + @Test + fun nodeItemCompact_marksANodeNotHeardOnTheConfigInForceNow() = runComposeUiTest { + setNodeItemCompact(viaMqtt = false) + markerCount().assertCountEquals(1) + } + + @Test + fun nodeItemCompact_withholdsTheMarkerFromAnMqttNode() = runComposeUiTest { + setNodeItemCompact(viaMqtt = true) + markerCount().assertCountEquals(0) + } +} 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 1932f34fc7..ca6f9fe88f 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 @@ -84,8 +84,8 @@ fun NodeItemCompleteActivePreview() { @PreviewLightDark @Composable fun NodeItemUnheardPreview() { - // Heard recently enough to count as online, but not since the radio's LoRa config changed. The unheard marker - // has to win over the online tint here: green would claim it is reachable. + // Heard recently enough to count as online, but not on the LoRa config the radio is using now. The unheard + // marker has to win over the online tint here: green would claim it is reachable. val unheardNode = previewNodes.minnieMouse.copy( lastHeard = (org.meshtastic.core.common.util.nowSeconds - 300).toInt(), diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/domain/usecase/GetFilteredNodesUseCase.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/domain/usecase/GetFilteredNodesUseCase.kt index b8107d6b9e..fcc2f4bde3 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/domain/usecase/GetFilteredNodesUseCase.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/domain/usecase/GetFilteredNodesUseCase.kt @@ -66,7 +66,7 @@ open class GetFilteredNodesUseCase constructor(private val nodeRepository: NodeR .filter { node -> if (filter.excludeMqtt) !node.viaMqtt else true } // The connected node is never unreachable from itself, and both row renderers already exempt it. .filter { node -> - if (filter.excludeUnheard) node.heardOnCurrentLora || node.num == ourNum else true + if (filter.excludeUnheard) !node.isUnheardOnCurrentLora || node.num == ourNum else true } } } 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 2981ba555a..e5bc7f058c 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 @@ -144,15 +144,7 @@ fun NodeListScreen( // radio is not something the node list may delete. ourNode and unfilteredNodes come from independent flows, so // the list can already contain the local node while ourNode is still null. Offer nothing until it is known, // rather than risk removing the user's own node from the radio. - val unheardNodes = - remember(unfilteredNodes, ourNode) { - val ourNum = ourNode?.num - if (ourNum == null) { - emptyList() - } else { - unfilteredNodes.filter { !it.heardOnCurrentLora && !it.isFavorite && it.num != ourNum } - } - } + val unheardNodes = remember(unfilteredNodes, ourNode) { selectRemovableUnheardNodes(unfilteredNodes, ourNode?.num) } val deviceImageUrls by viewModel.deviceImageUrls.collectAsStateWithLifecycle() val ignoredNodeCount = unfilteredNodes.count { it.isIgnored } diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/RemovableUnheardNodes.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/RemovableUnheardNodes.kt new file mode 100644 index 0000000000..94376ab9ee --- /dev/null +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/RemovableUnheardNodes.kt @@ -0,0 +1,36 @@ +/* + * 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.feature.node.list + +import org.meshtastic.core.model.Node + +/** + * The nodes the unheard banner counts and its one-tap clear would remove. + * + * Deliberately narrower than [Node.isUnheardOnCurrentLora] alone, because this one deletes: + * - **Favourites are exempt.** An explicit keep, and it is also what firmware marks a node added as a shared contact, + * which has never been heard over RF and would otherwise always qualify. + * - **The connected radio is exempt**, and nothing is offered at all until [ourNum] is known. It and the node list + * arrive on independent flows, so the list can hold the local node while [ourNum] is still null; offering then would + * risk removing the user's own node. + * - **MQTT nodes are exempt** via [Node.isUnheardOnCurrentLora]: they read unheard permanently, and removing one + * achieves nothing because the next uplinked packet brings it back. + */ +internal fun selectRemovableUnheardNodes(nodes: List, ourNum: Int?): List { + if (ourNum == null) return emptyList() + return nodes.filter { it.isUnheardOnCurrentLora && !it.isFavorite && it.num != ourNum } +} diff --git a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/domain/usecase/GetFilteredNodesUseCaseTest.kt b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/domain/usecase/GetFilteredNodesUseCaseTest.kt index 44c8f8f724..938f1b2edf 100644 --- a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/domain/usecase/GetFilteredNodesUseCaseTest.kt +++ b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/domain/usecase/GetFilteredNodesUseCaseTest.kt @@ -58,6 +58,7 @@ class GetFilteredNodesUseCaseTest { viaMqtt: Boolean = false, signsPackets: Boolean = false, publicKey: ByteString? = null, + heardOnCurrentLora: Boolean = true, ): Node { val user = User(id = "!$num", long_name = name, short_name = "N$num", role = role) return Node( @@ -67,6 +68,7 @@ class GetFilteredNodesUseCaseTest { viaMqtt = viaMqtt, signsPackets = signsPackets, publicKey = publicKey, + heardOnCurrentLora = heardOnCurrentLora, ) } @@ -193,4 +195,27 @@ class GetFilteredNodesUseCaseTest { assertEquals(listOf(2), result.map { it.num }) } + + @Test + fun `the unheard filter drops a node the radio can no longer reach`() = runTest { + val nodes = listOf(createNode(1), createNode(2, heardOnCurrentLora = false)) + every { nodeRepository.getNodes() } returns flowOf(nodes) + + val result = useCase(NodeFilterState(excludeUnheard = true), NodeSortOption.LAST_HEARD).first() + + assertEquals(listOf(1), result.map { it.num }) + } + + @Test + fun `the unheard filter keeps an MQTT node which reads unheard permanently`() = runTest { + // The radio only marks a node heard on the current LoRa config when it arrives over RF, so an MQTT-only node + // reads false permanently. That is not the same as "went unreachable when the settings changed", and hiding it + // would empty the list on an MQTT-uplinked mesh. + val nodes = listOf(createNode(1, viaMqtt = true, heardOnCurrentLora = false), createNode(2)) + every { nodeRepository.getNodes() } returns flowOf(nodes) + + val result = useCase(NodeFilterState(excludeUnheard = true), NodeSortOption.LAST_HEARD).first() + + assertEquals(listOf(1, 2), result.map { it.num }.sorted()) + } } diff --git a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/list/RemovableUnheardNodesTest.kt b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/list/RemovableUnheardNodesTest.kt new file mode 100644 index 0000000000..122259e08d --- /dev/null +++ b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/list/RemovableUnheardNodesTest.kt @@ -0,0 +1,77 @@ +/* + * 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.feature.node.list + +import org.meshtastic.core.model.Node +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * What the unheard banner counts and its one-tap clear would delete. This is the highest-consequence consumer of + * [Node.isUnheardOnCurrentLora] - it removes nodes - so each exemption is pinned separately. + */ +class RemovableUnheardNodesTest { + + private val ourNum = 1 + + private fun node( + num: Int, + heardOnCurrentLora: Boolean = false, + viaMqtt: Boolean = false, + isFavorite: Boolean = false, + ) = Node(num = num, heardOnCurrentLora = heardOnCurrentLora, viaMqtt = viaMqtt, isFavorite = isFavorite) + + @Test + fun offersANodeNotHeardOnTheConfigInForceNow() { + val nodes = listOf(node(2), node(3, heardOnCurrentLora = true)) + + assertEquals(listOf(2), selectRemovableUnheardNodes(nodes, ourNum).map { it.num }) + } + + @Test + fun neverOffersAnMqttNode() { + // Reads unheard for the life of the entry, and deleting it achieves nothing - the next uplinked packet + // brings it straight back. + val nodes = listOf(node(2, viaMqtt = true), node(3)) + + assertEquals(listOf(3), selectRemovableUnheardNodes(nodes, ourNum).map { it.num }) + } + + @Test + fun neverOffersAFavourite() { + // Also covers shared contacts: firmware marks a contact favourite, and one has never been heard over RF. + val nodes = listOf(node(2, isFavorite = true), node(3)) + + assertEquals(listOf(3), selectRemovableUnheardNodes(nodes, ourNum).map { it.num }) + } + + @Test + fun neverOffersTheConnectedRadio() { + val nodes = listOf(node(ourNum), node(3)) + + assertEquals(listOf(3), selectRemovableUnheardNodes(nodes, ourNum).map { it.num }) + } + + @Test + fun offersNothingUntilTheLocalNodeNumberIsKnown() { + // ourNode and the node list arrive on independent flows; offering here could delete the user's own node. + val nodes = listOf(node(2), node(3)) + + assertTrue(selectRemovableUnheardNodes(nodes, ourNum = null).isEmpty()) + } +}