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())
+ }
+}