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

@@ -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) } }