mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-05-12 00:28:20 -04:00
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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) } }
|
||||
|
||||
Reference in New Issue
Block a user