feat: rearchitect around SDK — decompose RadioController, simplify DataPacket, integrate SDK utilities

Android rearchitecture consuming meshtastic-sdk improvements:

A1 — ConnectionState Enrichment:
- Rich sealed interface with Connecting(attempt), Configuring(phase, progress), Reconnecting(attempt)
- SdkStateBridge maps SDK states preserving metadata

A2 — MessageHandle Integration:
- MessageDeliveryTracker: tracks delivery via SDK MessageHandle
- SdkRadioController captures handles on send

A3 — RadioController Decomposition:
- Split into 5 focused interfaces: MessageSender, DeviceAdmin, RemoteAdmin, DeviceControl, DataRequester
- RadioController extends all; SdkRadioController binds all via Koin

A4 — DataPacket Simplification:
- to/from fields changed from String? to Int (node numbers directly)
- Removed string ID parsing layer; added BROADCAST/LOCAL constants
- Updated ~40 consumer files across feature modules

A5 — SDK Utility Consumption:
- DeviceVersion, Capabilities, SfppHasher, LocationUtils delegate to SDK
- Removed duplicated protocol logic

A6 — Presence Events:
- SdkStateBridge handles NodeChange.WentOffline/CameOnline
- Updates node online status via repository

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
James Rich
2026-05-05 13:08:07 -05:00
parent 43ecd2eb73
commit e9cb439849
55 changed files with 573 additions and 558 deletions

View File

@@ -78,12 +78,11 @@ class MessagePersistenceHandler(
override fun rememberDataPacket(dataPacket: DataPacket, myNodeNum: Int, updateNotification: Boolean) {
if (dataPacket.dataType !in rememberDataType) return
val fromLocal =
dataPacket.from == DataPacket.ID_LOCAL || dataPacket.from == DataPacket.nodeNumToDefaultId(myNodeNum)
val toBroadcast = dataPacket.to == DataPacket.ID_BROADCAST
val fromLocal = dataPacket.from == DataPacket.LOCAL || dataPacket.from == myNodeNum
val toBroadcast = dataPacket.to == DataPacket.BROADCAST
val contactId = if (fromLocal || toBroadcast) dataPacket.to else dataPacket.from
val contactKey = "${dataPacket.channel}$contactId"
val contactKey = "${dataPacket.channel}${DataPacket.nodeNumToId(contactId)}"
scope.handledLaunch {
packetRepository.value.apply {
@@ -116,7 +115,7 @@ class MessagePersistenceHandler(
@Suppress("ReturnCount")
private suspend fun PacketRepository.shouldFilterMessage(dataPacket: DataPacket, contactKey: String): Boolean {
val isIgnored = nodeRepository.nodeDBbyID[dataPacket.from]?.isIgnored == true
val isIgnored = nodeRepository.nodeDBbyNum.value[dataPacket.from]?.isIgnored == true
if (isIgnored) return true
if (dataPacket.dataType != PortNum.TEXT_MESSAGE_APP.value) return false
@@ -130,7 +129,7 @@ class MessagePersistenceHandler(
updateNotification: Boolean,
) {
val conversationMuted = packetRepository.value.getContactSettings(contactKey).isMuted
val nodeMuted = nodeRepository.nodeDBbyID[dataPacket.from]?.isMuted == true
val nodeMuted = nodeRepository.nodeDBbyNum.value[dataPacket.from]?.isMuted == true
val isSilent = conversationMuted || nodeMuted
if (dataPacket.dataType == PortNum.ALERT_APP.value && !isSilent) {
scope.launch {
@@ -149,11 +148,10 @@ class MessagePersistenceHandler(
}
private suspend fun getSenderName(packet: DataPacket): String {
if (packet.from == DataPacket.ID_LOCAL) {
val myId = nodeRepository.getMyId()
return nodeRepository.nodeDBbyID[myId]?.user?.long_name ?: getStringSuspend(Res.string.unknown_username)
if (packet.from == DataPacket.LOCAL) {
return nodeRepository.ourNodeInfo.value?.user?.long_name ?: getStringSuspend(Res.string.unknown_username)
}
return nodeRepository.nodeDBbyID[packet.from]?.user?.long_name ?: getStringSuspend(Res.string.unknown_username)
return nodeRepository.nodeDBbyNum.value[packet.from]?.user?.long_name ?: getStringSuspend(Res.string.unknown_username)
}
private suspend fun updateNotification(contactKey: String, dataPacket: DataPacket, isSilent: Boolean) {
@@ -161,7 +159,7 @@ class MessagePersistenceHandler(
PortNum.TEXT_MESSAGE_APP.value -> {
val message = dataPacket.text!!
val channelName =
if (dataPacket.to == DataPacket.ID_BROADCAST) {
if (dataPacket.to == DataPacket.BROADCAST) {
radioConfigRepository.channelSetFlow.first().settings.getOrNull(dataPacket.channel)?.name
} else {
null
@@ -170,7 +168,7 @@ class MessagePersistenceHandler(
contactKey,
getSenderName(dataPacket),
message,
dataPacket.to == DataPacket.ID_BROADCAST,
dataPacket.to == DataPacket.BROADCAST,
channelName,
isSilent,
)

View File

@@ -97,7 +97,7 @@ class StoreForwardPacketHandlerImpl(
encryptedPayload = sfpp.message.toByteArray(),
to =
if (sfpp.encapsulated_to == 0) {
DataPacket.NODENUM_BROADCAST
DataPacket.BROADCAST
} else {
sfpp.encapsulated_to
},
@@ -174,7 +174,7 @@ class StoreForwardPacketHandlerImpl(
s.text != null -> {
if (s.rr == StoreAndForward.RequestResponse.ROUTER_TEXT_BROADCAST) {
dataPacket.to = DataPacket.ID_BROADCAST
dataPacket.to = DataPacket.BROADCAST
}
val u = dataPacket.copy(bytes = s.text, dataType = PortNum.TEXT_MESSAGE_APP.value)
dataHandler.value.rememberDataPacket(u, myNodeNum)

View File

@@ -0,0 +1,88 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.data.radio
import co.touchlab.kermit.Logger
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import org.koin.core.annotation.Single
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.model.MessageStatus
import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.sdk.MessageHandle
import org.meshtastic.sdk.SendState
/**
* Tracks in-flight message delivery via SDK [MessageHandle]s.
* Maps SDK [SendState] transitions to app [MessageStatus] and persists updates.
*/
@Single
class MessageDeliveryTracker(
private val packetRepository: Lazy<PacketRepository>,
dispatchers: CoroutineDispatchers,
) {
private val scope = CoroutineScope(SupervisorJob() + dispatchers.default)
private val activeHandles = mutableMapOf<Int, MessageHandle>()
private val activeHandlesMutex = Mutex()
/**
* Begin tracking a [MessageHandle] for the given packet ID.
* Observes state transitions and updates message status in the repository.
*/
fun track(packetId: Int, handle: MessageHandle) {
scope.launch {
activeHandlesMutex.withLock {
activeHandles[packetId] = handle
}
val repository = packetRepository.value
handle.state
.onEach { state ->
val status = mapSendState(state)
Logger.d { "[DeliveryTracker] Packet $packetId$status" }
repository.updateMessageStatus(packetId, status)
}
.first { state ->
val terminal = state.isTerminal()
if (terminal) {
activeHandlesMutex.withLock {
if (activeHandles[packetId] === handle) {
activeHandles.remove(packetId)
}
}
}
terminal
}
}
}
private fun mapSendState(state: SendState): MessageStatus = when (state) {
SendState.Queued -> MessageStatus.QUEUED
SendState.Sent -> MessageStatus.ENROUTE
SendState.Acked -> MessageStatus.DELIVERED
SendState.Delivered -> MessageStatus.DELIVERED
is SendState.Failed -> MessageStatus.ERROR
}
private fun SendState.isTerminal(): Boolean =
this is SendState.Acked || this is SendState.Delivered || this is SendState.Failed
}

View File

@@ -23,9 +23,14 @@ import okio.ByteString.Companion.toByteString
import org.koin.core.annotation.Single
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.DataRequester
import org.meshtastic.core.model.DeviceAdmin
import org.meshtastic.core.model.DeviceControl
import org.meshtastic.core.model.MeshActivity
import org.meshtastic.core.model.MessageSender
import org.meshtastic.core.model.Position
import org.meshtastic.core.model.RadioController
import org.meshtastic.core.model.RemoteAdmin
import org.meshtastic.core.repository.MeshLocationManager
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.ServiceRepository
@@ -55,13 +60,23 @@ import org.meshtastic.sdk.RadioClient
* **State distribution:** Handled by [SdkStateBridge], which feeds SDK flows into
* [ServiceRepository] and [org.meshtastic.core.repository.NodeRepository].
*/
@Single(binds = [RadioController::class])
@Single(
binds = [
RadioController::class,
MessageSender::class,
DeviceAdmin::class,
RemoteAdmin::class,
DeviceControl::class,
DataRequester::class,
],
)
@Suppress("TooManyFunctions", "LongParameterList")
class SdkRadioController(
private val accessor: RadioClientAccessor,
private val serviceRepository: ServiceRepository,
private val nodeRepository: NodeRepository,
private val locationManager: MeshLocationManager,
private val deliveryTracker: MessageDeliveryTracker,
) : RadioController {
private val packetIdCounter = atomic(1)
@@ -95,13 +110,14 @@ class SdkRadioController(
Logger.w { "sendMessage: no client, dropping packet" }
return
}
val destNum = when (packet.to) {
null, DataPacket.ID_BROADCAST -> DataPacket.NODENUM_BROADCAST
else -> DataPacket.idToDefaultNodeNum(packet.to?.removePrefix("!")) ?: DataPacket.NODENUM_BROADCAST
}
val destNum = packet.to
val packetId = packet.id.takeIf { it != 0 } ?: getPacketId()
val meshPacket = MeshPacket(
id = packetId,
to = destNum,
channel = packet.channel,
want_ack = packet.wantAck,
hop_limit = packet.hopLimit,
decoded = Data(
portnum = PortNum.fromValue(packet.dataType) ?: PortNum.UNKNOWN_APP,
payload = packet.bytes ?: okio.ByteString.EMPTY,
@@ -109,7 +125,8 @@ class SdkRadioController(
),
)
try {
c.send(meshPacket)
val handle = c.send(meshPacket)
deliveryTracker.track(packetId, handle)
serviceRepository.emitMeshActivity(MeshActivity.Send)
} catch (e: Exception) {
Logger.e(e) { "sendMessage failed" }

View File

@@ -27,10 +27,12 @@ import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import okio.ByteString.Companion.toByteString
import org.koin.core.annotation.Single
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.RadioController
import org.meshtastic.core.model.util.onlineTimeThreshold
import org.meshtastic.core.model.service.ServiceAction
import org.meshtastic.core.repository.MeshLocationManager
import org.meshtastic.core.repository.NodeRepository
@@ -102,6 +104,26 @@ class SdkStateBridge(
is NodeChange.Added -> nodeRepository.installNodeInfo(change.node, withBroadcast = true)
is NodeChange.Updated -> nodeRepository.installNodeInfo(change.node, withBroadcast = true)
is NodeChange.Removed -> nodeRepository.removeByNodenum(change.nodeId.raw)
is NodeChange.WentOffline -> {
val nodeNum = change.nodeId.raw
Logger.d {
"[SdkBridge] Node ${DataPacket.nodeNumToDefaultId(nodeNum)} went offline (last heard: ${change.lastHeard})"
}
if (nodeRepository.nodeDBbyNodeNum.containsKey(nodeNum)) {
nodeRepository.updateNode(nodeNum) { node ->
node.copy(lastHeard = minOf(node.lastHeard, change.lastHeard, onlineTimeThreshold()))
}
}
}
is NodeChange.CameOnline -> {
val nodeNum = change.nodeId.raw
Logger.d { "[SdkBridge] Node ${DataPacket.nodeNumToDefaultId(nodeNum)} came online" }
if (nodeRepository.nodeDBbyNodeNum.containsKey(nodeNum)) {
nodeRepository.updateNode(nodeNum) { node ->
node.copy(lastHeard = maxOf(node.lastHeard, nowSeconds.toInt()))
}
}
}
}
}
.launchIn(scope)
@@ -230,8 +252,7 @@ class SdkStateBridge(
is ServiceAction.Reaction -> {
val channel = action.contactKey[0].digitToInt()
val destId = action.contactKey.substring(1)
val destNum = DataPacket.idToDefaultNodeNum(destId.removePrefix("!"))
?: DataPacket.NODENUM_BROADCAST
val destNum = runCatching { DataPacket.parseNodeNum(destId) }.getOrDefault(DataPacket.BROADCAST)
client.send(
MeshPacket(
to = destNum,
@@ -288,12 +309,15 @@ class SdkStateBridge(
companion object {
private const val EMOJI_INDICATOR = 1
fun mapConnectionState(sdkState: SdkConnectionState): AppConnectionState = when (sdkState) {
private fun mapConnectionState(sdkState: SdkConnectionState): AppConnectionState = when (sdkState) {
is SdkConnectionState.Disconnected -> AppConnectionState.Disconnected
is SdkConnectionState.Connecting -> AppConnectionState.Connecting
is SdkConnectionState.Configuring -> AppConnectionState.Connecting
is SdkConnectionState.Connecting -> AppConnectionState.Connecting(attempt = sdkState.attempt)
is SdkConnectionState.Configuring -> AppConnectionState.Configuring(
phase = sdkState.phase.name,
progress = sdkState.progress,
)
is SdkConnectionState.Connected -> AppConnectionState.Connected
is SdkConnectionState.Reconnecting -> AppConnectionState.DeviceSleep
is SdkConnectionState.Reconnecting -> AppConnectionState.Reconnecting(attempt = sdkState.attempt)
}
}
}

View File

@@ -352,27 +352,22 @@ class PacketRepositoryImpl(
val dao = dbManager.currentDb.value.packetDao()
val packets = findPacketsWithIdInternal(packetId)
val reactions = findReactionsWithIdInternal(packetId)
val fromId = DataPacket.nodeNumToDefaultId(from)
val fromId = from
val fromIdString = DataPacket.nodeNumToId(from)
val isFromLocalNode = myNodeNum != null && from == myNodeNum
val toId =
if (to == 0 || to == DataPacket.NODENUM_BROADCAST) {
DataPacket.ID_BROADCAST
} else {
DataPacket.nodeNumToDefaultId(to)
}
val toNodeNum = if (to == 0 || to == DataPacket.BROADCAST) DataPacket.BROADCAST else to
val toId = DataPacket.nodeNumToId(toNodeNum)
val hashByteString = hash.toByteString()
packets.forEach { packet ->
// For sent messages, from is stored as ID_LOCAL, but SFPP packet has node number
val fromMatches =
packet.data.from == fromId || (isFromLocalNode && packet.data.from == DataPacket.ID_LOCAL)
val fromMatches = packet.data.from == fromId || (isFromLocalNode && packet.data.from == DataPacket.LOCAL)
co.touchlab.kermit.Logger.d {
"SFPP match check: packetFrom=${packet.data.from} fromId=$fromId " +
"isFromLocal=$isFromLocalNode fromMatches=$fromMatches " +
"packetTo=${packet.data.to} toId=$toId toMatches=${packet.data.to == toId}"
"packetTo=${packet.data.to} toId=$toNodeNum toMatches=${packet.data.to == toNodeNum}"
}
if (fromMatches && packet.data.to == toId) {
if (fromMatches && packet.data.to == toNodeNum) {
// If it's already confirmed, don't downgrade it to routing
if (packet.data.status == MessageStatus.SFPP_CONFIRMED && status == MessageStatus.SFPP_ROUTING) {
return@forEach
@@ -385,8 +380,7 @@ class PacketRepositoryImpl(
reactions.forEach { reaction ->
val reactionFrom = reaction.userId
// For sent reactions, from is stored as ID_LOCAL, but SFPP packet has node number
val fromMatches = reactionFrom == fromId || (isFromLocalNode && reactionFrom == DataPacket.ID_LOCAL)
val fromMatches = reactionFrom == fromIdString || (isFromLocalNode && reactionFrom == DataPacket.nodeNumToId(DataPacket.LOCAL))
val toMatches = reaction.to == toId

View File

@@ -125,9 +125,9 @@ class SdkNodeRepositoryImpl(
override fun getNode(userId: String): Node =
_nodeDBbyNum.value.values.find { it.user.id == userId }
?: Node(num = DataPacket.idToDefaultNodeNum(userId) ?: 0, user = getUser(userId))
?: Node(num = runCatching { DataPacket.parseNodeNum(userId) }.getOrDefault(0), user = getUser(userId))
override fun getUser(nodeNum: Int): User = getUser(DataPacket.nodeNumToDefaultId(nodeNum))
override fun getUser(nodeNum: Int): User = getUser(DataPacket.nodeNumToId(nodeNum))
private val last4 = 4
@@ -138,13 +138,13 @@ class SdkNodeRepositoryImpl(
}
val fallbackId = userId.takeLast(last4)
val defaultLong =
if (userId == DataPacket.ID_LOCAL) {
if (userId == DataPacket.nodeNumToId(DataPacket.LOCAL)) {
ourNodeInfo.value?.user?.long_name?.takeIf { it.isNotBlank() } ?: "Local"
} else {
"Meshtastic $fallbackId"
}
val defaultShort =
if (userId == DataPacket.ID_LOCAL) {
if (userId == DataPacket.nodeNumToId(DataPacket.LOCAL)) {
ourNodeInfo.value?.user?.short_name?.takeIf { it.isNotBlank() } ?: "Local"
} else {
fallbackId
@@ -408,8 +408,8 @@ class SdkNodeRepositoryImpl(
// ── NodeIdLookup ────────────────────────────────────────────────────────
override fun toNodeID(nodeNum: Int): String = if (nodeNum == DataPacket.NODENUM_BROADCAST) {
DataPacket.ID_BROADCAST
override fun toNodeID(nodeNum: Int): String = if (nodeNum == DataPacket.BROADCAST) {
DataPacket.nodeNumToId(DataPacket.BROADCAST)
} else {
_nodeDBbyNum.value[nodeNum]?.user?.id ?: DataPacket.nodeNumToDefaultId(nodeNum)
}

View File

@@ -198,8 +198,8 @@ class SdkNodeRepositoryImplTest {
@Test
fun `toNodeID returns broadcast ID for broadcast nodeNum`() {
val result = nodeRepository.toNodeID(DataPacket.NODENUM_BROADCAST)
assertEquals(DataPacket.ID_BROADCAST, result)
val result = nodeRepository.toNodeID(DataPacket.BROADCAST)
assertEquals(DataPacket.nodeNumToId(DataPacket.BROADCAST), result)
}
@Test

View File

@@ -86,8 +86,8 @@ class StoreForwardPacketHandlerImplTest {
private fun makeDataPacket(from: Int): DataPacket = DataPacket(
id = 1,
time = 1700000000000L,
to = DataPacket.ID_BROADCAST,
from = DataPacket.nodeNumToDefaultId(from),
to = DataPacket.BROADCAST,
from = from,
bytes = null,
dataType = PortNum.STORE_FORWARD_APP.value,
)

View File

@@ -75,8 +75,8 @@ class TelemetryPacketHandlerImplTest {
private fun makeDataPacket(from: Int): DataPacket = DataPacket(
id = 1,
time = 1700000000000L,
to = DataPacket.ID_BROADCAST,
from = DataPacket.nodeNumToDefaultId(from),
to = DataPacket.BROADCAST,
from = from,
bytes = null,
dataType = PortNum.TELEMETRY_APP.value,
)

View File

@@ -53,7 +53,7 @@ abstract class CommonPacketRepositoryTest {
// Set the current node number so PacketRepositoryImpl can pass it to queries
nodeRepository.setMyNodeInfo(org.meshtastic.core.testing.TestDataFactory.createMyNodeInfo(myNodeNum = myNodeNum))
val packet = DataPacket(to = "0!ffffffff", bytes = okio.ByteString.EMPTY, dataType = 1, id = 123)
val packet = DataPacket(to = DataPacket.BROADCAST, bytes = okio.ByteString.EMPTY, dataType = 1, id = 123)
repository.savePacket(myNodeNum, contact, packet, 1000L)