feat: SFPP delegation to SDK, NeighborInfo SDK model, congestion + S&F badges

- SdkStateBridge: handle SfppLinkProvided/SfppCanonAnnounced events from SDK
- StoreForwardPacketHandlerImpl: SFPP parsing removed (SDK-owned)
- NeighborInfoHandlerImpl: delegate formatting to SDK NeighborInfo.fromProto()
- NodeStatusIcons: CongestionBadge (yellow/orange/red for MEDIUM/HIGH/CRITICAL)
- NodeStatusIcons: StoreForwardBadge (blue cloud icon for S&F servers)
- NodeListViewModel: expose congestionLevel + storeForwardServers flows
- Tests updated for SFPP bridge coverage

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
James Rich
2026-05-05 16:06:29 -05:00
parent 7683db0c57
commit 64464196b0
9 changed files with 216 additions and 244 deletions

View File

@@ -28,6 +28,7 @@ import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.NeighborInfo
import org.meshtastic.sdk.NeighborInfo as SdkNeighborInfo
@Single
class NeighborInfoHandlerImpl(
@@ -47,37 +48,33 @@ class NeighborInfoHandlerImpl(
val payload = packet.decoded?.payload ?: return
val ni = NeighborInfo.ADAPTER.decode(payload)
// Store the last neighbor info from our connected radio
val from = packet.from
if (from == nodeRepository.myNodeNum.value) {
lastNeighborInfo = ni
Logger.d { "Stored last neighbor info from connected radio" }
}
// Update Node DB
nodeRepository.nodeDBbyNodeNum[from]?.let { /* SDK client.nodes is canonical source */ }
// Format for UI response
val requestId = packet.decoded?.request_id ?: 0
val start = startTimes.value[requestId]
startTimes.update { it.remove(requestId) }
val neighbors =
ni.neighbors.joinToString("\n") { n ->
val user = nodeRepository.getUser(n.node_id)
val name = "${user.long_name} (${user.short_name})"
"$name (SNR: ${n.snr})"
}
val fromUser = nodeRepository.getUser(from)
val formatted = "Neighbors of ${fromUser.long_name}:\n$neighbors"
val formatted =
SdkNeighborInfo
.fromProto(
reportingNode = from,
neighborNodeIds = ni.neighbors.map { it.node_id },
snrValues = ni.neighbors.map { it.snr },
).format { nodeId ->
val user = nodeRepository.getUser(nodeId.raw)
"${user.long_name} (${user.short_name})"
}
val responseText =
if (start != null) {
val elapsedMs = nowMillis - start
val seconds = elapsedMs / MILLIS_PER_SECOND
Logger.i { "Neighbor info $requestId complete in $seconds s" }
"$formatted\n\nDuration: ${NumberFormatter.format(seconds, 1)} s"
"$formatted\nDuration: ${NumberFormatter.format(seconds, 1)} s"
} else {
formatted
}

View File

@@ -19,13 +19,9 @@ package org.meshtastic.core.data.manager
import co.touchlab.kermit.Logger
import kotlinx.coroutines.CoroutineScope
import okio.ByteString.Companion.toByteString
import okio.IOException
import org.koin.core.annotation.Named
import org.koin.core.annotation.Single
import org.meshtastic.core.common.util.handledLaunch
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.MessageStatus
import org.meshtastic.core.model.util.SfppHasher
import org.meshtastic.core.repository.HistoryManager
import org.meshtastic.core.repository.MeshDataHandler
import org.meshtastic.core.repository.NodeRepository
@@ -34,14 +30,12 @@ import org.meshtastic.core.repository.StoreForwardPacketHandler
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.PortNum
import org.meshtastic.proto.StoreAndForward
import org.meshtastic.proto.StoreForwardPlusPlus
import kotlin.time.Duration.Companion.milliseconds
/**
* Implementation of [StoreForwardPacketHandler] that handles both legacy S&F and SF++ packets.
* Implementation of [StoreForwardPacketHandler] that keeps legacy S&F parsing for backward compatibility.
*
* Legacy Store-and-Forward discovery/history is now exposed through `RadioClient.storeForward`, but we still keep
* this parser for backward compatibility and for SF++ handling that the SDK does not fully replace yet.
* SF++ parsing/status updates are now delegated to the SDK and consumed via [org.meshtastic.core.data.radio.SdkStateBridge].
*/
@Single
class StoreForwardPacketHandlerImpl(
@@ -58,89 +52,8 @@ class StoreForwardPacketHandlerImpl(
handleReceivedStoreAndForward(dataPacket, u, myNodeNum)
}
@Suppress("LongMethod", "ReturnCount")
override fun handleStoreForwardPlusPlus(packet: MeshPacket) {
val payload = packet.decoded?.payload ?: return
val sfpp =
try {
StoreForwardPlusPlus.ADAPTER.decode(payload)
} catch (e: IOException) {
Logger.e(e) { "Failed to parse StoreForwardPlusPlus packet" }
return
}
Logger.d { "Received StoreForwardPlusPlus packet: $sfpp" }
when (sfpp.sfpp_message_type) {
StoreForwardPlusPlus.SFPP_message_type.LINK_PROVIDE,
StoreForwardPlusPlus.SFPP_message_type.LINK_PROVIDE_FIRSTHALF,
StoreForwardPlusPlus.SFPP_message_type.LINK_PROVIDE_SECONDHALF,
-> handleLinkProvide(sfpp)
StoreForwardPlusPlus.SFPP_message_type.CANON_ANNOUNCE -> handleCanonAnnounce(sfpp)
StoreForwardPlusPlus.SFPP_message_type.CHAIN_QUERY -> {
Logger.i { "SF++: Node ${packet.from} is querying chain status" }
}
StoreForwardPlusPlus.SFPP_message_type.LINK_REQUEST -> {
Logger.i { "SF++: Node ${packet.from} is requesting links" }
}
}
}
private fun handleLinkProvide(sfpp: StoreForwardPlusPlus) {
val isFragment = sfpp.sfpp_message_type != StoreForwardPlusPlus.SFPP_message_type.LINK_PROVIDE
val status = if (sfpp.commit_hash.size == 0) MessageStatus.SFPP_ROUTING else MessageStatus.SFPP_CONFIRMED
val hash =
when {
sfpp.message_hash.size != 0 -> sfpp.message_hash.toByteArray()
!isFragment && sfpp.message.size != 0 -> {
SfppHasher.computeMessageHash(
encryptedPayload = sfpp.message.toByteArray(),
to =
if (sfpp.encapsulated_to == 0) {
DataPacket.BROADCAST
} else {
sfpp.encapsulated_to
},
from = sfpp.encapsulated_from,
id = sfpp.encapsulated_id,
)
}
else -> null
} ?: return
Logger.d {
"SFPP updateStatus: packetId=${sfpp.encapsulated_id} from=${sfpp.encapsulated_from} " +
"to=${sfpp.encapsulated_to} myNodeNum=${nodeRepository.myNodeNum.value} status=$status"
}
scope.handledLaunch {
packetRepository.value.updateSFPPStatus(
packetId = sfpp.encapsulated_id,
from = sfpp.encapsulated_from,
to = sfpp.encapsulated_to,
hash = hash,
status = status,
rxTime = sfpp.encapsulated_rxtime.toLong() and 0xFFFFFFFFL,
myNodeNum = nodeRepository.myNodeNum.value ?: 0,
)
}
}
private fun handleCanonAnnounce(sfpp: StoreForwardPlusPlus) {
scope.handledLaunch {
sfpp.message_hash.let {
packetRepository.value.updateSFPPStatusByHash(
hash = it.toByteArray(),
status = MessageStatus.SFPP_CONFIRMED,
rxTime = sfpp.encapsulated_rxtime.toLong() and 0xFFFFFFFFL,
)
}
}
Logger.d { "SFPP packet received from=${packet.from} (handled by SDK)" }
}
private fun handleReceivedStoreAndForward(dataPacket: DataPacket, s: StoreAndForward, myNodeNum: Int) {

View File

@@ -33,6 +33,7 @@ import org.meshtastic.core.common.util.nowSeconds
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.model.ConnectionState as AppConnectionState
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.MessageStatus
import org.meshtastic.core.model.RadioController
import org.meshtastic.core.model.util.onlineTimeThreshold
import org.meshtastic.core.model.service.ServiceAction
@@ -208,6 +209,28 @@ class SdkStateBridge(
"[SdkBridge] S&F heartbeat from ${DataPacket.nodeNumToDefaultId(event.server.raw)}"
}
}
is StoreForwardEvent.SfppLinkProvided -> {
event.messageHash?.let { hash ->
val status = if (event.confirmed) MessageStatus.SFPP_CONFIRMED else MessageStatus.SFPP_ROUTING
packetRepository.value.updateSFPPStatus(
packetId = event.packetId,
from = event.from,
to = event.to,
hash = hash,
status = status,
rxTime = 0L,
myNodeNum = nodeRepository.myNodeNum.value ?: 0,
)
}
}
is StoreForwardEvent.SfppCanonAnnounced -> {
packetRepository.value.updateSFPPStatusByHash(
hash = event.messageHash,
status = MessageStatus.SFPP_CONFIRMED,
rxTime = event.rxTime,
)
}
else -> Logger.d { "[SdkBridge] S&F event: $event" }
}
}
.launchIn(scope)

View File

@@ -17,19 +17,16 @@
package org.meshtastic.core.data.manager
import dev.mokkery.MockMode
import dev.mokkery.answering.returns
import dev.mokkery.every
import dev.mokkery.matcher.any
import dev.mokkery.mock
import dev.mokkery.verify.VerifyMode
import dev.mokkery.verify
import dev.mokkery.verifySuspend
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import okio.ByteString
import okio.ByteString.Companion.toByteString
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.repository.HistoryManager
@@ -40,7 +37,6 @@ import org.meshtastic.proto.Data
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.PortNum
import org.meshtastic.proto.StoreAndForward
import org.meshtastic.proto.StoreForwardPlusPlus
import kotlin.test.BeforeTest
import kotlin.test.Test
@@ -61,8 +57,6 @@ class StoreForwardPacketHandlerImplTest {
@BeforeTest
fun setUp() {
every { nodeRepository.myNodeNum } returns MutableStateFlow<Int?>(myNodeNum)
handler =
StoreForwardPacketHandlerImpl(
nodeRepository = nodeRepository,
@@ -78,11 +72,6 @@ class StoreForwardPacketHandlerImplTest {
return MeshPacket(from = from, decoded = Data(portnum = PortNum.STORE_FORWARD_APP, payload = payload))
}
private fun makeSfppPacket(from: Int, sfpp: StoreForwardPlusPlus): MeshPacket {
val payload = StoreForwardPlusPlus.ADAPTER.encode(sfpp).toByteString()
return MeshPacket(from = from, decoded = Data(portnum = PortNum.STORE_FORWARD_APP, payload = payload))
}
private fun makeDataPacket(from: Int): DataPacket = DataPacket(
id = 1,
time = 1700000000000L,
@@ -200,138 +189,27 @@ class StoreForwardPacketHandlerImplTest {
// No crash — falls through to else branch
}
// ---------- SF++: LINK_PROVIDE ----------
// ---------- SF++: delegated to SDK ----------
@Test
fun `handleStoreForwardPlusPlus LINK_PROVIDE with message_hash updates status`() = testScope.runTest {
val sfpp =
StoreForwardPlusPlus(
sfpp_message_type = StoreForwardPlusPlus.SFPP_message_type.LINK_PROVIDE,
encapsulated_id = 42,
encapsulated_from = 1000,
encapsulated_to = 2000,
message_hash = ByteString.of(0x01, 0x02, 0x03, 0x04),
commit_hash = ByteString.EMPTY,
fun `handleStoreForwardPlusPlus logs only and leaves repository untouched`() = testScope.runTest {
val packet =
MeshPacket(
from = 999,
decoded = Data(
portnum = PortNum.STORE_FORWARD_APP,
payload = "ignored".encodeToByteArray().toByteString(),
),
)
val packet = makeSfppPacket(999, sfpp)
handler.handleStoreForwardPlusPlus(packet)
advanceUntilIdle()
verifySuspend { packetRepository.updateSFPPStatus(any(), any(), any(), any(), any(), any(), any()) }
}
// ---------- SF++: CANON_ANNOUNCE ----------
@Test
fun `handleStoreForwardPlusPlus CANON_ANNOUNCE updates status by hash`() = testScope.runTest {
val sfpp =
StoreForwardPlusPlus(
sfpp_message_type = StoreForwardPlusPlus.SFPP_message_type.CANON_ANNOUNCE,
message_hash = ByteString.of(0xAA.toByte(), 0xBB.toByte()),
encapsulated_rxtime = 1700000000,
)
val packet = makeSfppPacket(999, sfpp)
handler.handleStoreForwardPlusPlus(packet)
advanceUntilIdle()
verifySuspend { packetRepository.updateSFPPStatusByHash(any(), any(), any()) }
}
// ---------- SF++: CHAIN_QUERY ----------
@Test
fun `handleStoreForwardPlusPlus CHAIN_QUERY logs info without crash`() = testScope.runTest {
val sfpp = StoreForwardPlusPlus(sfpp_message_type = StoreForwardPlusPlus.SFPP_message_type.CHAIN_QUERY)
val packet = makeSfppPacket(999, sfpp)
handler.handleStoreForwardPlusPlus(packet)
advanceUntilIdle()
// No crash, just logs
}
// ---------- SF++: LINK_REQUEST ----------
@Test
fun `handleStoreForwardPlusPlus LINK_REQUEST logs info without crash`() = testScope.runTest {
val sfpp = StoreForwardPlusPlus(sfpp_message_type = StoreForwardPlusPlus.SFPP_message_type.LINK_REQUEST)
val packet = makeSfppPacket(999, sfpp)
handler.handleStoreForwardPlusPlus(packet)
advanceUntilIdle()
// No crash, just logs
}
// ---------- SF++: invalid payload ----------
@Test
fun `handleStoreForwardPlusPlus with null payload returns early`() = testScope.runTest {
val packet = MeshPacket(from = 999, decoded = null)
handler.handleStoreForwardPlusPlus(packet)
advanceUntilIdle()
// No crash
}
// ---------- SF++: fragment types ----------
@Test
fun `handleStoreForwardPlusPlus LINK_PROVIDE_FIRSTHALF handled as link provide`() = testScope.runTest {
val sfpp =
StoreForwardPlusPlus(
sfpp_message_type = StoreForwardPlusPlus.SFPP_message_type.LINK_PROVIDE_FIRSTHALF,
encapsulated_id = 55,
encapsulated_from = 1000,
encapsulated_to = 2000,
message_hash = ByteString.of(0x01, 0x02),
commit_hash = ByteString.EMPTY,
)
val packet = makeSfppPacket(999, sfpp)
handler.handleStoreForwardPlusPlus(packet)
advanceUntilIdle()
verifySuspend { packetRepository.updateSFPPStatus(any(), any(), any(), any(), any(), any(), any()) }
}
@Test
fun `handleStoreForwardPlusPlus LINK_PROVIDE_SECONDHALF handled as link provide`() = testScope.runTest {
val sfpp =
StoreForwardPlusPlus(
sfpp_message_type = StoreForwardPlusPlus.SFPP_message_type.LINK_PROVIDE_SECONDHALF,
encapsulated_id = 56,
encapsulated_from = 1000,
encapsulated_to = 2000,
message_hash = ByteString.of(0x03, 0x04),
commit_hash = ByteString.EMPTY,
)
val packet = makeSfppPacket(999, sfpp)
handler.handleStoreForwardPlusPlus(packet)
advanceUntilIdle()
verifySuspend { packetRepository.updateSFPPStatus(any(), any(), any(), any(), any(), any(), any()) }
}
// ---------- SF++: commit_hash present changes status ----------
@Test
fun `handleStoreForwardPlusPlus LINK_PROVIDE with commit_hash sets SFPP_CONFIRMED`() = testScope.runTest {
val sfpp =
StoreForwardPlusPlus(
sfpp_message_type = StoreForwardPlusPlus.SFPP_message_type.LINK_PROVIDE,
encapsulated_id = 77,
encapsulated_from = 1000,
encapsulated_to = 2000,
message_hash = ByteString.of(0x01, 0x02),
commit_hash = ByteString.of(0xAA.toByte()), // non-empty
)
val packet = makeSfppPacket(999, sfpp)
handler.handleStoreForwardPlusPlus(packet)
advanceUntilIdle()
verifySuspend { packetRepository.updateSFPPStatus(any(), any(), any(), any(), any(), any(), any()) }
verifySuspend(mode = VerifyMode.exactly(0)) {
packetRepository.updateSFPPStatus(any(), any(), any(), any(), any(), any(), any())
}
verifySuspend(mode = VerifyMode.exactly(0)) {
packetRepository.updateSFPPStatusByHash(any(), any(), any())
}
}
}

View File

@@ -17,7 +17,9 @@
package org.meshtastic.core.data.radio
import dev.mokkery.MockMode
import dev.mokkery.matcher.any
import dev.mokkery.mock
import dev.mokkery.verifySuspend
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
@@ -25,7 +27,9 @@ import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import okio.ByteString.Companion.toByteString
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.model.MessageStatus
import org.meshtastic.core.model.Node
import org.meshtastic.core.repository.MeshLocationManager
import org.meshtastic.core.repository.PacketRepository
@@ -37,6 +41,7 @@ import org.meshtastic.proto.Data
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.PortNum
import org.meshtastic.proto.Position
import org.meshtastic.proto.StoreForwardPlusPlus
import org.meshtastic.proto.User
import org.meshtastic.sdk.DeviceStorage
import org.meshtastic.sdk.NodeId
@@ -126,6 +131,71 @@ class SdkStateBridgeTest {
client.disconnect()
}
@Test
fun `sfpp link provided updates packet repository`() = runTest {
val packetRepository = mock<PacketRepository>(MockMode.autofill)
val (transport, client) = connectedClient(SeededHeartbeatStorageProvider(emptyMap()))
buildBridge(client, FakeNodeRepository(), packetRepository)
client.connect()
runCurrent()
transport.injectSfpp(
StoreForwardPlusPlus(
sfpp_message_type = StoreForwardPlusPlus.SFPP_message_type.LINK_PROVIDE,
message_hash = byteArrayOf(1, 2, 3, 4).toByteString(),
commit_hash = byteArrayOf(9, 8, 7).toByteString(),
encapsulated_id = 0x1234,
encapsulated_to = 0x01020304,
encapsulated_from = 0x55667788,
),
)
runCurrent()
verifySuspend {
packetRepository.updateSFPPStatus(
0x1234,
0x55667788,
0x01020304,
any(),
MessageStatus.SFPP_CONFIRMED,
0L,
0,
)
}
client.disconnect()
}
@Test
fun `sfpp canon announce updates packet repository by hash`() = runTest {
val packetRepository = mock<PacketRepository>(MockMode.autofill)
val (transport, client) = connectedClient(SeededHeartbeatStorageProvider(emptyMap()))
buildBridge(client, FakeNodeRepository(), packetRepository)
client.connect()
runCurrent()
transport.injectSfpp(
StoreForwardPlusPlus(
sfpp_message_type = StoreForwardPlusPlus.SFPP_message_type.CANON_ANNOUNCE,
message_hash = byteArrayOf(7, 6, 5, 4).toByteString(),
encapsulated_rxtime = 0xFEDCBA98.toInt(),
),
)
runCurrent()
verifySuspend {
packetRepository.updateSFPPStatusByHash(
any(),
MessageStatus.SFPP_CONFIRMED,
0xFEDCBA98L,
)
}
client.disconnect()
}
private fun TestScope.connectedClient(
storage: StorageProvider,
myNodeNum: Int = 0x11111111,
@@ -146,12 +216,13 @@ class SdkStateBridgeTest {
private fun TestScope.buildBridge(
client: RadioClient,
nodeRepository: FakeNodeRepository,
packetRepository: PacketRepository = mock(MockMode.autofill),
): SdkStateBridge =
SdkStateBridge(
accessor = TestRadioClientAccessor(client),
serviceRepository = FakeServiceRepository(),
nodeRepository = nodeRepository,
packetRepository = lazyOf(mock<PacketRepository>(MockMode.autofill)),
packetRepository = lazyOf(packetRepository),
locationManager = NoOpLocationManager,
uiPrefs = FakeUiPrefs(),
radioController = FakeRadioController(),
@@ -162,6 +233,23 @@ class SdkStateBridgeTest {
),
)
private fun FakeRadioTransport.injectSfpp(
message: StoreForwardPlusPlus,
fromNode: Int = 0x10203040,
) {
injectPacket(
MeshPacket(
id = 1,
from = fromNode,
to = 0,
decoded = Data(
portnum = PortNum.STORE_FORWARD_APP,
payload = StoreForwardPlusPlus.ADAPTER.encode(message).toByteString(),
),
),
)
}
private class TestRadioClientAccessor(client: RadioClient) : RadioClientAccessor {
override val client = MutableStateFlow<RadioClient?>(client)

View File

@@ -90,6 +90,7 @@ import org.meshtastic.core.ui.icon.ChannelUtilization
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.Notes
import org.meshtastic.proto.Config
import org.meshtastic.sdk.CongestionLevel
private const val ACTIVE_ALPHA = 0.5f
private const val INACTIVE_ALPHA = 0.2f
@@ -102,12 +103,14 @@ fun NodeItem(
thatNode: Node,
distanceUnits: Int,
tempInFahrenheit: Boolean,
congestionLevel: CongestionLevel? = null,
modifier: Modifier = Modifier,
onClick: () -> Unit = {},
onLongClick: (() -> Unit)? = null,
connectionState: ConnectionState,
deviceType: DeviceType? = null,
isActive: Boolean = false,
isStoreForwardServer: Boolean = false,
) {
val originalLongName = thatNode.user.long_name.ifEmpty { stringResource(Res.string.unknown_username) }
val isMuted = remember(thatNode) { thatNode.isMuted }
@@ -167,7 +170,9 @@ fun NodeItem(
isMuted = isMuted,
isUnmessageable = unmessageable,
connectionState = connectionState,
congestionLevel = congestionLevel,
deviceType = deviceType,
isStoreForwardServer = isStoreForwardServer,
contentColor = contentColor,
)
@@ -395,7 +400,9 @@ private fun NodeItemHeader(
isMuted: Boolean,
isUnmessageable: Boolean,
connectionState: ConnectionState,
congestionLevel: CongestionLevel?,
deviceType: DeviceType?,
isStoreForwardServer: Boolean,
contentColor: Color,
) {
Row(
@@ -441,7 +448,9 @@ private fun NodeItemHeader(
isMuted = isMuted,
isUnmessageable = isUnmessageable,
connectionState = connectionState,
congestionLevel = congestionLevel,
deviceType = deviceType,
isStoreForwardServer = isStoreForwardServer,
contentColor = contentColor,
)
}

View File

@@ -48,11 +48,17 @@ import org.meshtastic.core.resources.mute_always
import org.meshtastic.core.resources.unmessageable
import org.meshtastic.core.resources.unmonitored_or_infrastructure
import org.meshtastic.core.ui.component.ConnectionsNavIcon
import org.meshtastic.core.ui.icon.CloudDownload
import org.meshtastic.core.ui.icon.Favorite
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.Unmessageable
import org.meshtastic.core.ui.icon.VolumeOff
import org.meshtastic.core.ui.icon.Warning
import org.meshtastic.core.ui.theme.StatusColors.StatusBlue
import org.meshtastic.core.ui.theme.StatusColors.StatusOrange
import org.meshtastic.core.ui.theme.StatusColors.StatusRed
import org.meshtastic.core.ui.theme.StatusColors.StatusYellow
import org.meshtastic.sdk.CongestionLevel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -62,14 +68,19 @@ fun NodeStatusIcons(
isFavorite: Boolean,
isMuted: Boolean,
connectionState: ConnectionState,
congestionLevel: CongestionLevel? = null,
modifier: Modifier = Modifier,
deviceType: DeviceType? = null,
isStoreForwardServer: Boolean = false,
contentColor: Color = LocalContentColor.current,
) {
Row(modifier = modifier.padding(4.dp)) {
if (isThisNode) {
ThisNodeStatusBadge(connectionState = connectionState, deviceType = deviceType)
}
if (isThisNode && congestionLevel != null && congestionLevel != CongestionLevel.LOW) {
CongestionBadge(congestionLevel)
}
if (isUnmessageable) {
StatusBadge(
@@ -79,6 +90,9 @@ fun NodeStatusIcons(
tint = contentColor,
)
}
if (isStoreForwardServer) {
StoreForwardBadge()
}
if (isMuted && !isThisNode) {
StatusBadge(
imageVector = MeshtasticIcons.VolumeOff,
@@ -125,6 +139,47 @@ private fun ThisNodeStatusBadge(connectionState: ConnectionState, deviceType: De
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun StoreForwardBadge() {
TooltipBox(
positionProvider = TooltipDefaults.rememberTooltipPositionProvider(TooltipAnchorPosition.Above),
tooltip = { PlainTooltip { Text("Store & Forward server") } },
state = rememberTooltipState(),
) {
Icon(
imageVector = MeshtasticIcons.CloudDownload,
contentDescription = "Store & Forward server",
modifier = Modifier.size(24.dp),
tint = MaterialTheme.colorScheme.StatusBlue,
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun CongestionBadge(level: CongestionLevel) {
val color =
when (level) {
CongestionLevel.MEDIUM -> MaterialTheme.colorScheme.StatusYellow
CongestionLevel.HIGH -> MaterialTheme.colorScheme.StatusOrange
CongestionLevel.CRITICAL -> MaterialTheme.colorScheme.StatusRed
else -> return
}
TooltipBox(
positionProvider = TooltipDefaults.rememberTooltipPositionProvider(TooltipAnchorPosition.Above),
tooltip = { PlainTooltip { Text("Channel: ${level.name}") } },
state = rememberTooltipState(),
) {
Icon(
imageVector = MeshtasticIcons.Warning,
contentDescription = "Congestion: ${level.name}",
modifier = Modifier.size(24.dp),
tint = color,
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun StatusBadge(

View File

@@ -98,6 +98,8 @@ fun NodeListScreen(
val nodes by viewModel.nodeList.collectAsStateWithLifecycle()
val ourNode by viewModel.ourNodeInfo.collectAsStateWithLifecycle()
val congestionLevel by viewModel.congestionLevel.collectAsStateWithLifecycle()
val storeForwardServers by viewModel.storeForwardServers.collectAsStateWithLifecycle()
val onlineNodeCount by viewModel.onlineNodeCount.collectAsStateWithLifecycle(0)
val totalNodeCount by viewModel.totalNodeCount.collectAsStateWithLifecycle(0)
val unfilteredNodes by viewModel.unfilteredNodeList.collectAsStateWithLifecycle()
@@ -203,11 +205,13 @@ fun NodeListScreen(
thatNode = node,
distanceUnits = state.distanceUnits,
tempInFahrenheit = state.tempInFahrenheit,
congestionLevel = congestionLevel,
onClick = { navigateToNodeDetails(node.num) },
onLongClick = longClick,
connectionState = connectionState,
deviceType = deviceType,
isActive = isActive,
isStoreForwardServer = node.num in storeForwardServers,
)
val isThisNode = remember(node) { ourNode?.num == node.num }
if (!isThisNode) {

View File

@@ -39,6 +39,7 @@ import org.meshtastic.feature.node.detail.NodeManagementActions
import org.meshtastic.feature.node.domain.usecase.GetFilteredNodesUseCase
import org.meshtastic.proto.ChannelSet
import org.meshtastic.proto.Config
import org.meshtastic.sdk.CongestionLevel
@Suppress("LongParameterList")
@KoinViewModel
@@ -62,6 +63,10 @@ class NodeListViewModel(
val connectionState = serviceRepository.connectionState
val congestionLevel: StateFlow<CongestionLevel?> = serviceRepository.congestionLevel
val storeForwardServers: StateFlow<List<Int>> = serviceRepository.storeForwardServers
val deviceType: StateFlow<DeviceType?> =
radioPrefs.devAddr
.map { address -> address?.let { DeviceType.fromAddress(it) } }