diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/NeighborInfoHandlerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/NeighborInfoHandlerImpl.kt index 91c68b1aa..a74dbd849 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/NeighborInfoHandlerImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/NeighborInfoHandlerImpl.kt @@ -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 } diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/StoreForwardPacketHandlerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/StoreForwardPacketHandlerImpl.kt index 162efb048..39fbc3b7b 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/StoreForwardPacketHandlerImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/StoreForwardPacketHandlerImpl.kt @@ -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) { diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/SdkStateBridge.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/SdkStateBridge.kt index 5a114e842..d696871f9 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/SdkStateBridge.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/SdkStateBridge.kt @@ -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) diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/StoreForwardPacketHandlerImplTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/StoreForwardPacketHandlerImplTest.kt index f84254504..75e37318f 100644 --- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/StoreForwardPacketHandlerImplTest.kt +++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/StoreForwardPacketHandlerImplTest.kt @@ -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(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()) + } } } diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/radio/SdkStateBridgeTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/radio/SdkStateBridgeTest.kt index bbd361708..edea8eb0d 100644 --- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/radio/SdkStateBridgeTest.kt +++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/radio/SdkStateBridgeTest.kt @@ -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(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(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(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(client) diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeItem.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeItem.kt index 84dd70d1f..f79e266d0 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeItem.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeItem.kt @@ -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, ) } diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeStatusIcons.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeStatusIcons.kt index de6e547e5..40ca70da8 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeStatusIcons.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeStatusIcons.kt @@ -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( 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 fbb81101a..736802382 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 @@ -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) { 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 2f0ab1c80..bf173df28 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.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 = serviceRepository.congestionLevel + + val storeForwardServers: StateFlow> = serviceRepository.storeForwardServers + val deviceType: StateFlow = radioPrefs.devAddr .map { address -> address?.let { DeviceType.fromAddress(it) } }