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

@@ -431,10 +431,10 @@ fun MapView(
}
}
fun getUsername(id: String?) = if (id == DataPacket.ID_LOCAL || (myId != null && id == myId)) {
fun getUsername(id: Int) = if (id == DataPacket.LOCAL || id == mapViewModel.myNodeNum) {
getString(Res.string.you)
} else {
mapViewModel.getUser(id).long_name
mapViewModel.getUser(DataPacket.nodeNumToId(id)).long_name
}
@Suppress("MagicNumber")

View File

@@ -671,7 +671,7 @@ class MapViewModel(
}
override fun getUser(userId: String?) =
nodeRepository.getUser(userId ?: org.meshtastic.core.model.DataPacket.ID_BROADCAST)
nodeRepository.getUser(userId ?: org.meshtastic.core.model.DataPacket.nodeNumToId(org.meshtastic.core.model.DataPacket.BROADCAST))
}
enum class LayerType {

View File

@@ -15,6 +15,9 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import org.gradle.api.tasks.testing.Test
import org.gradle.jvm.toolchain.JavaLanguageVersion
plugins {
alias(libs.plugins.meshtastic.kmp.library)
alias(libs.plugins.kotlin.parcelize)
@@ -34,6 +37,7 @@ kotlin {
commonMain.dependencies {
implementation(libs.kotlinx.atomicfu)
implementation(libs.kotlinx.coroutines.core)
implementation(libs.sdk.core)
api(libs.kotlinx.datetime)
api(libs.okio)
api(libs.uri.kmp)
@@ -44,3 +48,11 @@ kotlin {
commonTest.dependencies { implementation(libs.kotlinx.coroutines.test) }
}
}
tasks.withType<Test>().configureEach {
javaLauncher.set(
javaToolchains.launcherFor {
languageVersion.set(JavaLanguageVersion.of(21))
},
)
}

View File

@@ -18,13 +18,8 @@
package org.meshtastic.core.common.util
import kotlin.math.PI
import kotlin.math.asin
import kotlin.math.atan2
import kotlin.math.cos
import org.meshtastic.sdk.PositionUtils
import kotlin.math.pow
import kotlin.math.sin
import kotlin.math.sqrt
@Suppress("MagicNumber")
object GPSFormat {
@@ -39,30 +34,9 @@ object GPSFormat {
}
}
private const val EARTH_RADIUS_METERS = 6371e3
@Suppress("MagicNumber")
private fun Double.toRadians(): Double = this * PI / 180.0
@Suppress("MagicNumber")
private fun Double.toDegrees(): Double = this * 180.0 / PI
/** @return distance in meters along the surface of the earth (ish) */
@Suppress("MagicNumber")
fun latLongToMeter(latitudeA: Double, longitudeA: Double, latitudeB: Double, longitudeB: Double): Double {
val lat1 = latitudeA.toRadians()
val lon1 = longitudeA.toRadians()
val lat2 = latitudeB.toRadians()
val lon2 = longitudeB.toRadians()
val dLat = lat2 - lat1
val dLon = lon2 - lon1
val a = sin(dLat / 2).pow(2) + cos(lat1) * cos(lat2) * sin(dLon / 2).pow(2)
val c = 2 * asin(sqrt(a))
return EARTH_RADIUS_METERS * c
}
fun latLongToMeter(latitudeA: Double, longitudeA: Double, latitudeB: Double, longitudeB: Double): Double =
PositionUtils.distance(latitudeA, longitudeA, latitudeB, longitudeB)
/**
* Computes the bearing in degrees between two points on Earth.
@@ -73,18 +47,5 @@ fun latLongToMeter(latitudeA: Double, longitudeA: Double, latitudeB: Double, lon
* @param lon2 Longitude of the second point
* @return Bearing between the two points in degrees. A value of 0 means due north.
*/
@Suppress("MagicNumber")
fun bearing(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Double {
val lat1Rad = lat1.toRadians()
val lon1Rad = lon1.toRadians()
val lat2Rad = lat2.toRadians()
val lon2Rad = lon2.toRadians()
val dLon = lon2Rad - lon1Rad
val y = sin(dLon) * cos(lat2Rad)
val x = cos(lat1Rad) * sin(lat2Rad) - sin(lat1Rad) * cos(lat2Rad) * cos(dLon)
val bearing = atan2(y, x).toDegrees()
return (bearing + 360) % 360
}
fun bearing(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Double =
PositionUtils.bearing(lat1, lon1, lat2, lon2)

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)

View File

@@ -143,7 +143,7 @@ class MigrationTest {
contact_key = "$channel!broadcast",
received_time = nowMillis,
read = false,
data = DataPacket(to = DataPacket.ID_BROADCAST, channel = channel, text = text),
data = DataPacket(to = DataPacket.BROADCAST, channel = channel, text = text),
)
packetDao.insert(packet)
}

View File

@@ -37,8 +37,8 @@ data class PacketEntity(
val reactions: List<ReactionEntity> = emptyList(),
) {
suspend fun toMessage(getNode: suspend (userId: String?) -> Node) = with(packet) {
val node = getNode(data.from)
val isFromLocal = node.user.id == DataPacket.ID_LOCAL || (myNodeNum != 0 && node.num == myNodeNum)
val node = getNode(DataPacket.nodeNumToId(data.from))
val isFromLocal = data.from == DataPacket.LOCAL || (myNodeNum != 0 && data.from == myNodeNum)
Message(
uuid = uuid,
receivedTime = received_time,

View File

@@ -40,7 +40,7 @@ abstract class CommonPacketDaoTest {
private val myNodeNum = 42424242
private val testContactKeys = listOf("0${DataPacket.ID_BROADCAST}", "1!test1234")
private val testContactKeys = listOf("0${DataPacket.nodeNumToId(DataPacket.BROADCAST)}", "1!test1234")
private fun generateTestPackets(nodeNum: Int) = testContactKeys.flatMap { contactKey ->
List(SAMPLE_SIZE) {
@@ -53,7 +53,7 @@ abstract class CommonPacketDaoTest {
read = false,
data =
DataPacket(
to = DataPacket.ID_BROADCAST,
to = DataPacket.BROADCAST,
bytes = "Message $it!".encodeToByteArray().toByteString(),
dataType = PortNum.TEXT_MESSAGE_APP.value,
),
@@ -115,7 +115,7 @@ abstract class CommonPacketDaoTest {
val messages = packetDao.getMessagesFrom(myNodeNum, contactKey).first()
val packet = messages.first().packet.data
val packetWithId = packet.copy(id = 999, from = "!$myNodeNum")
val packetWithId = packet.copy(id = 999, from = myNodeNum)
val updatedRoomPacket = messages.first().packet.copy(data = packetWithId, packetId = 999)
packetDao.update(updatedRoomPacket)
@@ -136,7 +136,7 @@ abstract class CommonPacketDaoTest {
read = true,
data =
DataPacket(
to = DataPacket.ID_BROADCAST,
to = DataPacket.BROADCAST,
bytes = "Queued".encodeToByteArray().toByteString(),
dataType = PortNum.TEXT_MESSAGE_APP.value,
status = MessageStatus.QUEUED,
@@ -170,12 +170,12 @@ abstract class CommonPacketDaoTest {
uuid = 0L,
myNodeNum = myNodeNum,
port_num = PortNum.WAYPOINT_APP.value,
contact_key = "0${DataPacket.ID_BROADCAST}",
contact_key = "0${DataPacket.nodeNumToId(DataPacket.BROADCAST)}",
received_time = nowMillis,
read = true,
data =
DataPacket(
to = DataPacket.ID_BROADCAST,
to = DataPacket.BROADCAST,
bytes = "Waypoint".encodeToByteArray().toByteString(),
dataType = PortNum.WAYPOINT_APP.value,
),
@@ -208,7 +208,7 @@ abstract class CommonPacketDaoTest {
received_time = nowMillis + index,
read = false,
data = DataPacket(
to = DataPacket.ID_BROADCAST,
to = DataPacket.BROADCAST,
bytes = text.encodeToByteArray().toByteString(),
dataType = PortNum.TEXT_MESSAGE_APP.value,
),
@@ -227,7 +227,7 @@ abstract class CommonPacketDaoTest {
received_time = nowMillis + normalMessages.size + index,
read = true,
data = DataPacket(
to = DataPacket.ID_BROADCAST,
to = DataPacket.BROADCAST,
bytes = text.encodeToByteArray().toByteString(),
dataType = PortNum.TEXT_MESSAGE_APP.value,
),
@@ -265,7 +265,7 @@ abstract class CommonPacketDaoTest {
received_time = baseTime + id,
read = false,
data = DataPacket(
to = DataPacket.ID_BROADCAST,
to = DataPacket.BROADCAST,
bytes = "Chunk $id".encodeToByteArray().toByteString(),
dataType = PortNum.TEXT_MESSAGE_APP.value,
),

View File

@@ -15,6 +15,9 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import org.gradle.api.tasks.testing.Test
import org.gradle.jvm.toolchain.JavaLanguageVersion
plugins {
alias(libs.plugins.meshtastic.kmp.library)
alias(libs.plugins.meshtastic.kotlinx.serialization)
@@ -38,6 +41,7 @@ kotlin {
api(projects.core.common)
api(projects.core.resources)
implementation(libs.sdk.core)
api(libs.kotlinx.coroutines.core)
api(libs.kotlinx.serialization.json)
api(libs.kotlinx.datetime)
@@ -72,3 +76,11 @@ publishing {
}
}
}
tasks.withType<Test>().configureEach {
javaLauncher.set(
javaToolchains.launcherFor {
languageVersion.set(JavaLanguageVersion.of(21))
},
)
}

View File

@@ -17,62 +17,52 @@
package org.meshtastic.core.model
import org.meshtastic.core.model.util.isDebug
import org.meshtastic.sdk.DeviceCapabilities as SdkCapabilities
/**
* Defines the capabilities and feature support based on the device firmware version.
*
* This class provides a centralized way to check if specific features are supported by the connected node's firmware.
* Add new features here to ensure consistency across the app.
*
* Note: Properties are calculated once during initialization for efficiency.
*/
data class Capabilities(val firmwareVersion: String?, internal val forceEnableAll: Boolean = isDebug) {
private val version = firmwareVersion?.let { DeviceVersion(it) }
private val sdk = SdkCapabilities(firmwareVersion)
private fun atLeast(min: DeviceVersion): Boolean = forceEnableAll || (version != null && version >= min)
private fun check(sdkValue: Boolean): Boolean = forceEnableAll || sdkValue
/** Ability to mute notifications from specific nodes via admin messages. */
val canMuteNode = atLeast(V2_7_18)
val canMuteNode get() = check(sdk.canMuteNode)
/** Ability to request neighbor information from other nodes. Gated to [UNRELEASED] until working reliably. */
val canRequestNeighborInfo = atLeast(UNRELEASED)
/**
* Ability to request neighbor information from other nodes.
* Gated to unreleased firmware until working reliably.
*/
val canRequestNeighborInfo get() = false
/** Ability to send verified shared contacts. Supported since firmware v2.7.12. */
val canSendVerifiedContacts = atLeast(V2_7_12)
val canSendVerifiedContacts get() = check(sdk.canSendVerifiedContacts)
/** Ability to toggle device telemetry globally via module config. Supported since firmware v2.7.12. */
val canToggleTelemetryEnabled = atLeast(V2_7_12)
val canToggleTelemetryEnabled get() = check(sdk.canToggleTelemetryEnabled)
/** Ability to toggle the 'is_unmessageable' flag in user config. Supported since firmware v2.6.9. */
val canToggleUnmessageable = atLeast(V2_6_9)
val canToggleUnmessageable get() = check(sdk.canToggleUnmessageable)
/** Support for sharing contact information via QR codes. Supported since firmware v2.6.8. */
val supportsQrCodeSharing = atLeast(V2_6_8)
val supportsQrCodeSharing get() = check(sdk.supportsQrCodeSharing)
/** Support for Status Message module. Supported since firmware v2.8.0. */
val supportsStatusMessage = atLeast(V2_8_0)
val supportsStatusMessage get() = check(sdk.supportsStatusMessage)
/** Support for Traffic Management module. Supported since firmware v3.0.0. */
val supportsTrafficManagementConfig = atLeast(V3_0_0)
val supportsTrafficManagementConfig get() = check(sdk.supportsTrafficManagementConfig)
/** Support for TAK (ATAK) module configuration. Supported since firmware v2.7.19. */
val supportsTakConfig = atLeast(V2_7_19)
val supportsTakConfig get() = check(sdk.supportsTakConfig)
/** Support for location sharing on secondary channels. Supported since firmware v2.6.10. */
val supportsSecondaryChannelLocation = atLeast(V2_6_10)
val supportsSecondaryChannelLocation get() = check(sdk.supportsSecondaryChannelLocation)
/** Support for ESP32 Unified OTA. Supported since firmware v2.7.18. */
val supportsEsp32Ota = atLeast(V2_7_18)
companion object {
private val V2_6_8 = DeviceVersion("2.6.8")
private val V2_6_9 = DeviceVersion("2.6.9")
private val V2_6_10 = DeviceVersion("2.6.10")
private val V2_7_12 = DeviceVersion("2.7.12")
private val V2_7_18 = DeviceVersion("2.7.18")
private val V2_7_19 = DeviceVersion("2.7.19")
private val V2_8_0 = DeviceVersion("2.8.0")
private val V3_0_0 = DeviceVersion("3.0.0")
private val UNRELEASED = DeviceVersion("9.9.9")
}
val supportsEsp32Ota get() = check(sdk.supportsEsp32Ota)
}

View File

@@ -21,16 +21,10 @@ package org.meshtastic.core.model
import org.meshtastic.proto.Config.LoRaConfig
import org.meshtastic.proto.Config.LoRaConfig.ModemPreset
import org.meshtastic.proto.Config.LoRaConfig.RegionCode
import org.meshtastic.sdk.channelNameHashDjb2
import kotlin.math.floor
/** hash a string into an integer using the djb2 algorithm by Dan Bernstein http://www.cse.yorku.ca/~oz/hash.html */
private fun hash(name: String): UInt { // using UInt instead of Long to match RadioInterface.cpp results
var hash = 5381u
for (c in name) {
hash += (hash shl 5) + c.code.toUInt()
}
return hash
}
private fun hash(name: String): UInt = channelNameHashDjb2(name)
private val ModemPreset.bandwidth: Float
get() {

View File

@@ -17,15 +17,24 @@
package org.meshtastic.core.model
sealed interface ConnectionState {
/** We are disconnected from the device, and we should be trying to reconnect. */
/** Not connected; should attempt to reconnect. */
data object Disconnected : ConnectionState
/** We are currently attempting to connect to the device. */
data object Connecting : ConnectionState
/** Transport connecting. */
data class Connecting(val attempt: Int = 1) : ConnectionState
/** We are connected to the device and communicating normally. */
/** Transport up, handshake in progress. */
data class Configuring(val phase: String = "", val progress: Float = 0f) : ConnectionState
/** Fully connected and operational. */
data object Connected : ConnectionState
/** The device is in a light sleep state, and we are waiting for it to wake up and reconnect to us. */
/** Connection dropped, attempting automatic reconnect. */
data class Reconnecting(val attempt: Int = 1) : ConnectionState
/** Device in light sleep. */
data object DeviceSleep : ConnectionState
/** Whether the connection is usable for sending messages. */
val isConnected: Boolean get() = this is Connected
}

View File

@@ -17,7 +17,17 @@
package org.meshtastic.core.model
import co.touchlab.kermit.Logger
import kotlinx.serialization.KSerializer
import kotlinx.serialization.SerializationException
import kotlinx.serialization.Serializable
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import kotlinx.serialization.json.JsonDecoder
import kotlinx.serialization.json.intOrNull
import kotlinx.serialization.json.jsonPrimitive
import okio.ByteString
import okio.ByteString.Companion.toByteString
import org.meshtastic.core.common.util.CommonIgnoredOnParcel
@@ -25,13 +35,15 @@ import org.meshtastic.core.common.util.CommonParcel
import org.meshtastic.core.common.util.CommonParcelable
import org.meshtastic.core.common.util.CommonParcelize
import org.meshtastic.core.common.util.CommonTypeParceler
import org.meshtastic.core.common.util.formatString
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.model.util.ByteStringParceler
import org.meshtastic.core.model.util.ByteStringSerializer
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.PortNum
import org.meshtastic.proto.Waypoint
import org.meshtastic.sdk.NodeId
import org.meshtastic.sdk.fromDefaultId
import org.meshtastic.sdk.toDefaultId
@CommonParcelize
enum class MessageStatus : CommonParcelable {
@@ -49,13 +61,15 @@ enum class MessageStatus : CommonParcelable {
@Serializable
@CommonParcelize
data class DataPacket(
var to: String? = ID_BROADCAST, // a nodeID string, or ID_BROADCAST for broadcast
@Serializable(with = NodeNumSerializer::class)
var to: Int = BROADCAST,
@Serializable(with = ByteStringSerializer::class)
@CommonTypeParceler<ByteString?, ByteStringParceler>
var bytes: ByteString?,
// A port number for this packet
var dataType: Int,
var from: String? = ID_LOCAL, // a nodeID string, or ID_LOCAL for localhost
@Serializable(with = NodeNumSerializer::class)
var from: Int = LOCAL,
var time: Long = nowMillis, // msecs since 1970
var id: Int = 0, // 0 means unassigned
var status: MessageStatus? = MessageStatus.UNKNOWN,
@@ -78,10 +92,10 @@ data class DataPacket(
) : CommonParcelable {
fun readFromParcel(parcel: CommonParcel) {
to = parcel.readString()
to = parcel.readInt()
bytes = ByteStringParceler.create(parcel)
dataType = parcel.readInt()
from = parcel.readString()
from = parcel.readInt()
time = parcel.readLong()
id = parcel.readInt()
@@ -121,7 +135,7 @@ data class DataPacket(
/** Syntactic sugar to make it easy to create text messages */
constructor(
to: String?,
to: Int,
channel: Int,
text: String,
replyId: Int? = null,
@@ -151,7 +165,7 @@ data class DataPacket(
}
constructor(
to: String?,
to: Int,
channel: Int,
waypoint: Waypoint,
) : this(
@@ -177,23 +191,48 @@ data class DataPacket(
get() = if (hopStart == 0 || (hopLimit > hopStart)) -1 else hopStart - hopLimit
companion object {
// Special node IDs that can be used for sending messages
/** the Node ID for broadcast destinations */
const val ID_BROADCAST = "^all"
/** The Node ID for the local node - used for from when sender doesn't know our local node ID */
const val ID_LOCAL = "^local"
// special broadcast address
const val NODENUM_BROADCAST = (0xffffffff).toInt()
const val BROADCAST: Int = 0xffffffff.toInt()
const val LOCAL: Int = 0
// Public-key cryptography (PKC) channel index
const val PKC_CHANNEL_INDEX = 8
fun nodeNumToDefaultId(n: Int): String = formatString("!%08x", n)
/** Format a node number as the default display ID ("!aabbccdd"). */
fun nodeNumToDefaultId(n: Int): String = NodeId(n).toDefaultId()
@Suppress("MagicNumber")
fun idToDefaultNodeNum(id: String?): Int? = runCatching { id?.toLong(16)?.toInt() }.getOrNull()
fun nodeNumToId(n: Int): String = when (n) {
BROADCAST -> "^all"
LOCAL -> "^local"
else -> nodeNumToDefaultId(n)
}
fun parseNodeNum(id: String): Int {
val normalized = id.trim()
return when {
normalized.equals("^all", ignoreCase = true) -> BROADCAST
normalized.equals("^local", ignoreCase = true) -> LOCAL
else -> NodeId.fromDefaultId(normalized)?.raw
?: NodeId.fromDefaultId("!$normalized")?.raw
?: runCatching { normalized.toLong(16).toInt() }.getOrNull()
?: throw SerializationException("Unsupported node id: $id")
}
}
}
}
private object NodeNumSerializer : KSerializer<Int> {
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("NodeNum", PrimitiveKind.STRING)
override fun serialize(encoder: Encoder, value: Int) {
encoder.encodeString(DataPacket.nodeNumToId(value))
}
override fun deserialize(decoder: Decoder): Int {
if (decoder is JsonDecoder) {
val primitive = decoder.decodeJsonElement().jsonPrimitive
primitive.intOrNull?.let { return it }
return DataPacket.parseNodeNum(primitive.content)
}
return DataPacket.parseNodeNum(decoder.decodeString())
}
}

View File

@@ -0,0 +1,26 @@
/*
* 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.model
/** Focused interface for requesting data from nodes. */
interface DataRequester {
suspend fun requestPosition(destNum: Int, currentPosition: Position)
suspend fun requestUserInfo(destNum: Int)
suspend fun requestTraceroute(requestId: Int, destNum: Int)
suspend fun requestTelemetry(requestId: Int, destNum: Int, typeValue: Int)
suspend fun requestNeighborInfo(requestId: Int, destNum: Int)
}

View File

@@ -0,0 +1,28 @@
/*
* 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.model
import org.meshtastic.proto.Channel
import org.meshtastic.proto.Config
/** Focused interface for local device configuration and edit sessions. */
interface DeviceAdmin {
suspend fun setLocalConfig(config: Config)
suspend fun setLocalChannel(channel: Channel)
suspend fun beginEditSettings(destNum: Int)
suspend fun commitEditSettings(destNum: Int)
}

View File

@@ -0,0 +1,28 @@
/*
* 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.model
/** Focused interface for device lifecycle control. */
interface DeviceControl {
suspend fun reboot(destNum: Int, packetId: Int)
suspend fun rebootToDfu(nodeNum: Int)
suspend fun requestRebootOta(requestId: Int, destNum: Int, mode: Int, hash: ByteArray?)
suspend fun shutdown(destNum: Int, packetId: Int)
suspend fun factoryReset(destNum: Int, packetId: Int)
suspend fun nodedbReset(destNum: Int, packetId: Int, preserveFavorites: Boolean)
suspend fun removeByNodenum(packetId: Int, nodeNum: Int)
}

View File

@@ -16,42 +16,16 @@
*/
package org.meshtastic.core.model
import co.touchlab.kermit.Logger
import org.meshtastic.sdk.DeviceVersion as SdkDeviceVersion
/** Provide structured access to parse and compare device version strings */
data class DeviceVersion(val asString: String) : Comparable<DeviceVersion> {
private val delegate = SdkDeviceVersion(asString)
/** The integer representation of the version (e.g., 2.7.12 -> 20712). Calculated once. */
@Suppress("TooGenericExceptionCaught", "SwallowedException")
val asInt: Int =
try {
verStringToInt(asString)
} catch (e: Exception) {
Logger.w { "Exception while parsing version '$asString', assuming version 0" }
0
}
val asInt: Int
get() = delegate.asInt
/**
* Convert a version string of the form 1.23.57 to a comparable integer of the form 12357.
*
* Or throw an exception if the string can not be parsed
*/
@Suppress("TooGenericExceptionThrown", "MagicNumber")
private fun verStringToInt(s: String): Int {
// Allow 1 to two digits per match
val versionString =
if (s.split(".").size == 2) {
"$s.0"
} else {
s
}
val match =
Regex("(\\d{1,2}).(\\d{1,2}).(\\d{1,2})").find(versionString) ?: throw Exception("Can't parse version $s")
val (major, minor, build) = match.destructured
return major.toInt() * 10000 + minor.toInt() * 100 + build.toInt()
}
override fun compareTo(other: DeviceVersion): Int = asInt.compareTo(other.asInt)
override fun compareTo(other: DeviceVersion): Int = delegate.compareTo(other.delegate)
companion object {
const val MIN_FW_VERSION = "2.5.14"

View File

@@ -0,0 +1,23 @@
/*
* 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.model
/** Focused interface for sending messages over the mesh. */
interface MessageSender {
suspend fun sendMessage(packet: DataPacket)
fun getPacketId(): Int
}

View File

@@ -20,14 +20,12 @@ import kotlinx.coroutines.flow.StateFlow
import org.meshtastic.proto.ClientNotification
/**
* Central interface for controlling the radio and mesh network.
* Composite interface for all radio operations.
*
* This component provides an abstraction over the underlying communication transport (e.g., BLE, Serial, TCP) and the
* low-level mesh protocols. It allows feature modules to interact with the mesh without needing to know about
* platform-specific service details or AIDL interfaces.
* Consumers should prefer the focused sub-interfaces (for example [MessageSender] and [RemoteAdmin]) for new code.
* This super-interface remains for backward compatibility with existing injections.
*/
@Suppress("TooManyFunctions")
interface RadioController {
interface RadioController : MessageSender, DeviceAdmin, RemoteAdmin, DeviceControl, DataRequester {
/**
* Canonical app-level connection state, delegated from [ServiceRepository][connectionState].
*
@@ -47,13 +45,6 @@ interface RadioController {
*/
val clientNotification: StateFlow<ClientNotification?>
/**
* Sends a data packet to the mesh.
*
* @param packet The [DataPacket] containing the payload and routing information.
*/
suspend fun sendMessage(packet: DataPacket)
/** Clears the current [clientNotification]. */
fun clearClientNotification()
@@ -75,258 +66,6 @@ interface RadioController {
*/
suspend fun sendSharedContact(nodeNum: Int): Boolean
/**
* Updates the local radio configuration.
*
* @param config The new configuration [org.meshtastic.proto.Config].
*/
suspend fun setLocalConfig(config: org.meshtastic.proto.Config)
/**
* Updates a local radio channel.
*
* @param channel The channel configuration [org.meshtastic.proto.Channel].
*/
suspend fun setLocalChannel(channel: org.meshtastic.proto.Channel)
/**
* Updates the owner (user info) on a remote node.
*
* @param destNum The destination node number.
* @param user The new user info [org.meshtastic.proto.User].
* @param packetId The request packet ID.
*/
suspend fun setOwner(destNum: Int, user: org.meshtastic.proto.User, packetId: Int)
/**
* Updates the general configuration on a remote node.
*
* @param destNum The destination node number.
* @param config The new configuration [org.meshtastic.proto.Config].
* @param packetId The request packet ID.
*/
suspend fun setConfig(destNum: Int, config: org.meshtastic.proto.Config, packetId: Int)
/**
* Updates a module configuration on a remote node.
*
* @param destNum The destination node number.
* @param config The new module configuration [org.meshtastic.proto.ModuleConfig].
* @param packetId The request packet ID.
*/
suspend fun setModuleConfig(destNum: Int, config: org.meshtastic.proto.ModuleConfig, packetId: Int)
/**
* Updates a channel configuration on a remote node.
*
* @param destNum The destination node number.
* @param channel The new channel configuration [org.meshtastic.proto.Channel].
* @param packetId The request packet ID.
*/
suspend fun setRemoteChannel(destNum: Int, channel: org.meshtastic.proto.Channel, packetId: Int)
/**
* Sets a fixed position on a remote node.
*
* @param destNum The destination node number.
* @param position The position to set.
*/
suspend fun setFixedPosition(destNum: Int, position: Position)
/**
* Updates the notification ringtone on a remote node.
*
* @param destNum The destination node number.
* @param ringtone The name/ID of the ringtone.
*/
suspend fun setRingtone(destNum: Int, ringtone: String)
/**
* Updates the canned messages configuration on a remote node.
*
* @param destNum The destination node number.
* @param messages The canned messages string.
*/
suspend fun setCannedMessages(destNum: Int, messages: String)
/**
* Requests the current owner (user info) from a remote node.
*
* @param destNum The remote node number.
* @param packetId The request packet ID.
*/
suspend fun getOwner(destNum: Int, packetId: Int)
/**
* Requests a specific configuration section from a remote node.
*
* @param destNum The remote node number.
* @param configType The numeric type of the configuration section.
* @param packetId The request packet ID.
*/
suspend fun getConfig(destNum: Int, configType: Int, packetId: Int)
/**
* Requests a module configuration section from a remote node.
*
* @param destNum The remote node number.
* @param moduleConfigType The numeric type of the module configuration section.
* @param packetId The request packet ID.
*/
suspend fun getModuleConfig(destNum: Int, moduleConfigType: Int, packetId: Int)
/**
* Requests a specific channel configuration from a remote node.
*
* @param destNum The remote node number.
* @param index The channel index.
* @param packetId The request packet ID.
*/
suspend fun getChannel(destNum: Int, index: Int, packetId: Int)
/**
* Requests the current ringtone from a remote node.
*
* @param destNum The remote node number.
* @param packetId The request packet ID.
*/
suspend fun getRingtone(destNum: Int, packetId: Int)
/**
* Requests the current canned messages from a remote node.
*
* @param destNum The remote node number.
* @param packetId The request packet ID.
*/
suspend fun getCannedMessages(destNum: Int, packetId: Int)
/**
* Requests the hardware connection status from a remote node.
*
* @param destNum The remote node number.
* @param packetId The request packet ID.
*/
suspend fun getDeviceConnectionStatus(destNum: Int, packetId: Int)
/**
* Commands a node to reboot.
*
* @param destNum The target node number.
* @param packetId The request packet ID.
*/
suspend fun reboot(destNum: Int, packetId: Int)
/**
* Commands a node to reboot into DFU (Device Firmware Update) mode.
*
* @param nodeNum The target node number.
*/
suspend fun rebootToDfu(nodeNum: Int)
/**
* Initiates an Over-The-Air (OTA) reboot request.
*
* @param requestId The request ID.
* @param destNum The target node number.
* @param mode The OTA mode.
* @param hash Optional hash for verification.
*/
suspend fun requestRebootOta(requestId: Int, destNum: Int, mode: Int, hash: ByteArray?)
/**
* Commands a node to shut down.
*
* @param destNum The target node number.
* @param packetId The request packet ID.
*/
suspend fun shutdown(destNum: Int, packetId: Int)
/**
* Performs a factory reset on a node.
*
* @param destNum The target node number.
* @param packetId The request packet ID.
*/
suspend fun factoryReset(destNum: Int, packetId: Int)
/**
* Resets the NodeDB on a node.
*
* @param destNum The target node number.
* @param packetId The request packet ID.
* @param preserveFavorites Whether to keep favorite nodes in the database.
*/
suspend fun nodedbReset(destNum: Int, packetId: Int, preserveFavorites: Boolean)
/**
* Removes a node from the mesh by its node number.
*
* @param packetId The request packet ID.
* @param nodeNum The node number to remove.
*/
suspend fun removeByNodenum(packetId: Int, nodeNum: Int)
/**
* Requests the current GPS position from a remote node.
*
* @param destNum The target node number.
* @param currentPosition Our current position to provide in the request.
*/
suspend fun requestPosition(destNum: Int, currentPosition: Position)
/**
* Requests detailed user info from a remote node.
*
* @param destNum The target node number.
*/
suspend fun requestUserInfo(destNum: Int)
/**
* Initiates a traceroute request to a remote node.
*
* @param requestId The request ID.
* @param destNum The destination node number.
*/
suspend fun requestTraceroute(requestId: Int, destNum: Int)
/**
* Requests telemetry data from a remote node.
*
* @param requestId The request ID.
* @param destNum The destination node number.
* @param typeValue The numeric type of telemetry requested.
*/
suspend fun requestTelemetry(requestId: Int, destNum: Int, typeValue: Int)
/**
* Requests neighbor information (detected nodes) from a remote node.
*
* @param requestId The request ID.
* @param destNum The destination node number.
*/
suspend fun requestNeighborInfo(requestId: Int, destNum: Int)
/**
* Signals the start of a batch configuration session.
*
* @param destNum The target node number.
*/
suspend fun beginEditSettings(destNum: Int)
/**
* Commits all pending configuration changes in a batch session.
*
* @param destNum The target node number.
*/
suspend fun commitEditSettings(destNum: Int)
/**
* Generates a unique packet ID for a new request.
*
* @return A unique 32-bit integer.
*/
fun getPacketId(): Int
/** Starts providing the phone's location to the mesh. */
fun startProvideLocation()

View File

@@ -0,0 +1,40 @@
/*
* 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.model
import org.meshtastic.proto.Channel
import org.meshtastic.proto.Config
import org.meshtastic.proto.ModuleConfig
import org.meshtastic.proto.User
/** Focused interface for remote node administration. */
interface RemoteAdmin {
suspend fun setOwner(destNum: Int, user: User, packetId: Int)
suspend fun setConfig(destNum: Int, config: Config, packetId: Int)
suspend fun setModuleConfig(destNum: Int, config: ModuleConfig, packetId: Int)
suspend fun setRemoteChannel(destNum: Int, channel: Channel, packetId: Int)
suspend fun setFixedPosition(destNum: Int, position: Position)
suspend fun setRingtone(destNum: Int, ringtone: String)
suspend fun setCannedMessages(destNum: Int, messages: String)
suspend fun getOwner(destNum: Int, packetId: Int)
suspend fun getConfig(destNum: Int, configType: Int, packetId: Int)
suspend fun getModuleConfig(destNum: Int, moduleConfigType: Int, packetId: Int)
suspend fun getChannel(destNum: Int, index: Int, packetId: Int)
suspend fun getRingtone(destNum: Int, packetId: Int)
suspend fun getCannedMessages(destNum: Int, packetId: Int)
suspend fun getDeviceConnectionStatus(destNum: Int, packetId: Int)
}

View File

@@ -33,8 +33,8 @@ open class MeshDataMapper(private val nodeIdLookup: NodeIdLookup) {
open fun toDataPacket(packet: MeshPacket): DataPacket? {
val decoded = packet.decoded ?: return null
return DataPacket(
from = nodeIdLookup.toNodeID(packet.from),
to = nodeIdLookup.toNodeID(packet.to),
from = packet.from,
to = packet.to,
time = packet.rx_time * 1000L,
id = packet.id,
dataType = decoded.portnum.value,

View File

@@ -16,27 +16,10 @@
*/
package org.meshtastic.core.model.util
import okio.ByteString.Companion.toByteString
import org.meshtastic.sdk.SfppHash
/** Computes SFPP (Store-Forward-Plus-Plus) message hashes for deduplication. */
object SfppHasher {
private const val HASH_SIZE = 16
private const val INT_BYTES = 4
private const val INT_COUNT = 3
private const val SHIFT_8 = 8
private const val SHIFT_16 = 16
private const val SHIFT_24 = 24
fun computeMessageHash(encryptedPayload: ByteArray, to: Int, from: Int, id: Int): ByteArray {
val input = ByteArray(encryptedPayload.size + INT_BYTES * INT_COUNT)
encryptedPayload.copyInto(input)
var offset = encryptedPayload.size
for (value in intArrayOf(to, from, id)) {
input[offset++] = value.toByte()
input[offset++] = (value shr SHIFT_8).toByte()
input[offset++] = (value shr SHIFT_16).toByte()
input[offset++] = (value shr SHIFT_24).toByte()
}
return input.toByteString().sha256().toByteArray().copyOf(HASH_SIZE)
}
fun computeMessageHash(encryptedPayload: ByteArray, to: Int, from: Int, id: Int): ByteArray =
SfppHash.compute(encryptedPayload, to, from, id)
}

View File

@@ -120,6 +120,11 @@ interface PacketRepository {
/** Updates the transmission status of a packet. */
suspend fun updateMessageStatus(d: DataPacket, m: MessageStatus)
/** Updates the transmission status of a packet by its mesh packet ID. */
suspend fun updateMessageStatus(packetId: Int, status: MessageStatus) {
getPacketByPacketId(packetId)?.let { updateMessageStatus(it, status) }
}
/** Updates the identifier of a persisted packet. */
suspend fun updateMessageId(d: DataPacket, id: Int)

View File

@@ -46,8 +46,10 @@ interface ServiceRepository {
*
* State transitions are managed by [SdkStateBridge], which maps SDK connection events into app-level transitions:
* - [ConnectionState.Disconnected] — no active connection to a radio
* - [ConnectionState.Connecting] — transport is up, mesh handshake (config + node-info) in progress
* - [ConnectionState.Connecting] — transport establishment is in progress
* - [ConnectionState.Configuring] — transport is up and mesh handshake/config sync is in progress
* - [ConnectionState.Connected] — handshake complete, radio fully operational
* - [ConnectionState.Reconnecting] — connection dropped and automatic retry is in progress
* - [ConnectionState.DeviceSleep] — radio entered light-sleep (transient disconnect)
*/
val connectionState: StateFlow<ConnectionState>

View File

@@ -44,7 +44,11 @@ import kotlin.random.Random
* This implementation is platform-agnostic and relies on injected repositories and controllers.
*/
interface SendMessageUseCase {
suspend operator fun invoke(text: String, contactKey: String = "0${DataPacket.ID_BROADCAST}", replyId: Int? = null)
suspend operator fun invoke(
text: String,
contactKey: String = "0${DataPacket.nodeNumToId(DataPacket.BROADCAST)}",
replyId: Int? = null,
)
}
@Suppress("TooGenericExceptionCaught")
@@ -69,15 +73,16 @@ class SendMessageUseCaseImpl(
val dest = if (channel != null) contactKey.substring(1) else contactKey
val ourNode = nodeRepository.ourNodeInfo.value
val fromId = ourNode?.user?.id ?: DataPacket.ID_LOCAL
val fromNodeNum = ourNode?.num ?: DataPacket.LOCAL
// Direct message side-effects: share the contact's public key (PKI) or
// favorite the node (legacy) before sending the first message. PKI DMs use
// channel == PKC_CHANNEL_INDEX (8); legacy DMs have no channel prefix
// (channel == null). Both formats target a specific node.
val isDirectMessage = channel == null || channel == DataPacket.PKC_CHANNEL_INDEX
val destNode = if (isDirectMessage) nodeRepository.getNode(dest) else null
val destNodeNum = destNode?.num ?: DataPacket.parseNodeNum(dest)
if (isDirectMessage) {
val destNode = nodeRepository.getNode(dest)
val fwVersion = ourNode?.metadata?.firmware_version
val isClientBase = ourNode?.user?.role == Config.DeviceConfig.Role.CLIENT_BASE
val capabilities = Capabilities(fwVersion)
@@ -86,10 +91,10 @@ class SendMessageUseCaseImpl(
// Best-effort: inform firmware of the destination's public key
// for its NodeDB cache. The MeshPacket itself carries the key
// directly, so the message can be encrypted regardless.
sendSharedContact(destNode)
sendSharedContact(destNode!!)
} else if (channel == null) {
// Legacy favoriting only applies to old-style DMs without PKI
if (!destNode.isFavorite && !isClientBase) {
if (!destNode!!.isFavorite && !isClientBase) {
favoriteNode(destNode)
}
}
@@ -106,8 +111,8 @@ class SendMessageUseCaseImpl(
val packetId = Random.nextInt(1, Int.MAX_VALUE)
val packet =
DataPacket(dest, channel ?: 0, finalMessageText, replyId).apply {
from = fromId
DataPacket(destNodeNum, channel ?: 0, finalMessageText, replyId).apply {
from = fromNodeNum
id = packetId
status = MessageStatus.QUEUED
}

View File

@@ -68,7 +68,7 @@ class SendMessageUseCaseTest {
appPreferences.homoglyph.setHomoglyphEncodingEnabled(false)
// Act
useCase("Hello broadcast", "0${DataPacket.ID_BROADCAST}", null)
useCase("Hello broadcast", "0${DataPacket.nodeNumToId(DataPacket.BROADCAST)}", null)
// Assert
radioController.favoritedNodes.size shouldBe 0
@@ -133,7 +133,7 @@ class SendMessageUseCaseTest {
val originalText = "\u0410pple" // Cyrillic A
// Act
useCase(originalText, "0${DataPacket.ID_BROADCAST}", null)
useCase(originalText, "0${DataPacket.nodeNumToId(DataPacket.BROADCAST)}", null)
// Assert
// Verified by observing that no exception is thrown and coverage is hit.

View File

@@ -64,9 +64,9 @@ class SendMessageWorkerTest {
fun `doWork returns success when packet is sent successfully`() = runTest {
// Arrange
val packetId = 12345
val dataPacket = DataPacket(to = "dest", bytes = "Hello".encodeToByteArray().toByteString(), dataType = 0)
val dataPacket = DataPacket(to = 1234, bytes = "Hello".encodeToByteArray().toByteString(), dataType = 0)
everySuspend { packetRepository.getPacketByPacketId(packetId) } returns dataPacket
everySuspend { packetRepository.updateMessageStatus(any(), any()) } returns Unit
everySuspend { packetRepository.updateMessageStatus(any<DataPacket>(), any<MessageStatus>()) } returns Unit
val worker =
TestListenableWorkerBuilder<SendMessageWorker>(context)
@@ -96,7 +96,7 @@ class SendMessageWorkerTest {
fun `doWork returns retry when radio is disconnected`() = runTest {
// Arrange
val packetId = 12345
val dataPacket = DataPacket(to = "dest", bytes = "Hello".encodeToByteArray().toByteString(), dataType = 0)
val dataPacket = DataPacket(to = 1234, bytes = "Hello".encodeToByteArray().toByteString(), dataType = 0)
everySuspend { packetRepository.getPacketByPacketId(packetId) } returns dataPacket
radioController.setConnectionState(ConnectionState.Disconnected)
@@ -121,7 +121,7 @@ class SendMessageWorkerTest {
// Assert
assertEquals(ListenableWorker.Result.retry(), result)
assertEquals(emptyList<DataPacket>(), radioController.sentPackets)
verifySuspend(mode = VerifyMode.exactly(0)) { packetRepository.updateMessageStatus(any(), any()) }
verifySuspend(mode = VerifyMode.exactly(0)) { packetRepository.updateMessageStatus(any<DataPacket>(), any<MessageStatus>()) }
}
@Test
@@ -143,15 +143,15 @@ class SendMessageWorkerTest {
val result = worker.doWork()
assertEquals(ListenableWorker.Result.failure(), result)
verifySuspend(mode = VerifyMode.exactly(0)) { packetRepository.getPacketByPacketId(any()) }
verifySuspend(mode = VerifyMode.exactly(0)) { packetRepository.getPacketByPacketId(any<Int>()) }
}
@Test
fun `doWork returns retry and marks queued when send throws`() = runTest {
val packetId = 12345
val dataPacket = DataPacket(to = "dest", bytes = "Hello".encodeToByteArray().toByteString(), dataType = 0)
val dataPacket = DataPacket(to = 1234, bytes = "Hello".encodeToByteArray().toByteString(), dataType = 0)
everySuspend { packetRepository.getPacketByPacketId(packetId) } returns dataPacket
everySuspend { packetRepository.updateMessageStatus(any(), any()) } returns Unit
everySuspend { packetRepository.updateMessageStatus(any<DataPacket>(), any<MessageStatus>()) } returns Unit
radioController.throwOnSend = true
val worker =

View File

@@ -317,7 +317,9 @@ class MeshServiceNotificationsImpl(
is ConnectionState.DeviceSleep -> getString(Res.string.device_sleeping)
is ConnectionState.Connecting -> getString(Res.string.connecting)
is ConnectionState.Connecting,
is ConnectionState.Configuring,
is ConnectionState.Reconnecting -> getString(Res.string.connecting)
}
// Update caches if telemetry is provided
@@ -420,7 +422,7 @@ class MeshServiceNotificationsImpl(
val history =
packetRepository.value
.getMessagesFrom(contactKey, includeFiltered = false) { nodeId ->
if (nodeId == DataPacket.ID_LOCAL) {
if (nodeId == DataPacket.nodeNumToId(DataPacket.LOCAL)) {
ourNode ?: nodeRepository.value.getNode(nodeId)
} else {
nodeRepository.value.getNode(nodeId.orEmpty())
@@ -461,7 +463,7 @@ class MeshServiceNotificationsImpl(
val me =
Person.Builder()
.setName(meName)
.setKey(ourNode?.user?.id ?: DataPacket.ID_LOCAL)
.setKey(ourNode?.user?.id ?: DataPacket.nodeNumToId(DataPacket.LOCAL))
.apply { ourNode?.let { setIcon(createPersonIcon(meName, it.colors.second, it.colors.first)) } }
.build()
@@ -573,7 +575,7 @@ class MeshServiceNotificationsImpl(
val me =
Person.Builder()
.setName(meName)
.setKey(ourNode?.user?.id ?: DataPacket.ID_LOCAL)
.setKey(ourNode?.user?.id ?: DataPacket.nodeNumToId(DataPacket.LOCAL))
.apply { ourNode?.let { setIcon(createPersonIcon(meName, it.colors.second, it.colors.first)) } }
.build()

View File

@@ -77,7 +77,7 @@ class ReplyReceiver :
// contactKey: unique contact key filter (channel)+(nodeId)
val channel = contactKey.getOrNull(0)?.digitToIntOrNull()
val dest = if (channel != null) contactKey.substring(1) else contactKey
val p = DataPacket(dest, channel ?: 0, str)
val p = DataPacket(DataPacket.parseNodeNum(dest), channel ?: 0, str)
radioController.sendMessage(p)
}
}

View File

@@ -130,7 +130,7 @@ class TAKMeshIntegration(
val dataPacket =
DataPacket(
to = DataPacket.ID_BROADCAST,
to = DataPacket.BROADCAST,
bytes = payload.toByteString(),
dataType = PortNum.ATAK_PLUGIN.value,
)

View File

@@ -93,7 +93,7 @@ class GenericCoTHandler(private val radioController: RadioController, private va
private suspend fun sendDirect(payload: ByteArray) {
val dataPacket =
DataPacket(
to = DataPacket.ID_BROADCAST,
to = DataPacket.BROADCAST,
bytes = payload.toByteString(),
dataType = PortNum.ATAK_FORWARDER.value,
)
@@ -115,7 +115,7 @@ class GenericCoTHandler(private val radioController: RadioController, private va
for ((index, packetData) in packets.withIndex()) {
val dataPacket =
DataPacket(
to = DataPacket.ID_BROADCAST,
to = DataPacket.BROADCAST,
bytes = packetData.toByteString(),
dataType = PortNum.ATAK_FORWARDER.value,
)
@@ -191,7 +191,7 @@ class GenericCoTHandler(private val radioController: RadioController, private va
val dataPacket =
DataPacket(
to = toNodeNum.toString(),
to = toNodeNum,
bytes = ackPacket.toByteString(),
dataType = PortNum.ATAK_FORWARDER.value,
)

View File

@@ -75,7 +75,9 @@ fun ConnectionsNavIcon(
@Composable
private fun getTint(connectionState: ConnectionState): Color = when (connectionState) {
ConnectionState.Connecting -> colorScheme.StatusOrange
is ConnectionState.Connecting,
is ConnectionState.Configuring,
is ConnectionState.Reconnecting -> colorScheme.StatusOrange
ConnectionState.Disconnected -> colorScheme.StatusRed
ConnectionState.DeviceSleep -> colorScheme.StatusYellow
else -> colorScheme.StatusGreen
@@ -88,7 +90,9 @@ fun getIconPair(connectionState: ConnectionState, deviceType: DeviceType? = null
ConnectionState.DeviceSleep -> MeshtasticIcons.Device to MeshtasticIcons.DeviceSleep
ConnectionState.Connecting -> MeshtasticIcons.Device to MeshtasticIcons.Reconnecting
is ConnectionState.Connecting,
is ConnectionState.Configuring,
is ConnectionState.Reconnecting -> MeshtasticIcons.Device to MeshtasticIcons.Reconnecting
else ->
MeshtasticIcons.Device to

View File

@@ -190,7 +190,9 @@ private fun NavigationIconContent(
if (isConnectionsRoute) {
when (connectionState) {
ConnectionState.Connected -> stringResource(Res.string.connected)
ConnectionState.Connecting -> stringResource(Res.string.connecting)
is ConnectionState.Connecting,
is ConnectionState.Configuring,
is ConnectionState.Reconnecting -> stringResource(Res.string.connecting)
ConnectionState.DeviceSleep -> stringResource(Res.string.device_sleeping)
ConnectionState.Disconnected -> stringResource(Res.string.disconnected)
}

View File

@@ -104,7 +104,9 @@ class ConnectionsViewModel(
is ConnectionState.Connected ->
if (unset) ConnectionStatus.MUST_SET_REGION else ConnectionStatus.CONNECTED
ConnectionState.Connecting -> ConnectionStatus.CONNECTING
is ConnectionState.Connecting,
is ConnectionState.Configuring,
is ConnectionState.Reconnecting -> ConnectionStatus.CONNECTING
ConnectionState.Disconnected -> ConnectionStatus.NOT_CONNECTED

View File

@@ -198,7 +198,9 @@ fun ConnectionsScreen(
ConnectionUiState.CONNECTED_WITH_NODE
connectionState is ConnectionState.Connected ||
connectionState == ConnectionState.Connecting ||
connectionState is ConnectionState.Connecting ||
connectionState is ConnectionState.Configuring ||
connectionState is ConnectionState.Reconnecting ||
selectedDevice != NO_DEVICE_SELECTED -> ConnectionUiState.CONNECTING
else -> ConnectionUiState.NO_DEVICE

View File

@@ -89,12 +89,17 @@ fun DeviceListItem(
}
}
val isConnecting =
connectionState is ConnectionState.Connecting ||
connectionState is ConnectionState.Configuring ||
connectionState is ConnectionState.Reconnecting
val icon =
when (device) {
is DeviceListEntry.Ble ->
if (connectionState is ConnectionState.Connected) {
MeshtasticIcons.BluetoothConnected
} else if (connectionState is ConnectionState.Connecting) {
} else if (isConnecting) {
MeshtasticIcons.BluetoothSearching
} else {
MeshtasticIcons.Bluetooth
@@ -155,7 +160,7 @@ fun DeviceListItem(
Rssi(rssi = displayedRssi)
}
if (connectionState is ConnectionState.Connecting) {
if (isConnecting) {
CircularProgressIndicator(modifier = Modifier.size(32.dp))
} else {
RadioButton(selected = connectionState is ConnectionState.Connected, onClick = null)

View File

@@ -142,19 +142,19 @@ open class BaseMapViewModel(
}
open fun getUser(userId: String?) =
nodeRepository.getUser(userId ?: org.meshtastic.core.model.DataPacket.ID_BROADCAST)
nodeRepository.getUser(userId ?: DataPacket.nodeNumToId(DataPacket.BROADCAST))
fun getNodeOrFallback(nodeNum: Int): Node = nodeRepository.nodeDBbyNum.value[nodeNum] ?: Node(num = nodeNum)
fun deleteWaypoint(id: Int) =
safeLaunch(context = ioDispatcher, tag = "deleteWaypoint") { packetRepository.deleteWaypoint(id) }
fun sendWaypoint(wpt: Waypoint, contactKey: String = "0${DataPacket.ID_BROADCAST}") {
fun sendWaypoint(wpt: Waypoint, contactKey: String = "0${DataPacket.nodeNumToId(DataPacket.BROADCAST)}") {
// contactKey: unique contact key filter (channel)+(nodeId)
val channel = contactKey[0].digitToIntOrNull()
val dest = if (channel != null) contactKey.substring(1) else contactKey
val p = DataPacket(dest, channel ?: 0, wpt)
val p = DataPacket(DataPacket.parseNodeNum(dest), channel ?: 0, wpt)
if (wpt.id != 0) sendDataPacket(p)
}

View File

@@ -162,7 +162,7 @@ fun MessageScreen(
val title =
remember(nodeId, channelName, viewModel) {
when (nodeId) {
DataPacket.ID_BROADCAST -> channelName
DataPacket.nodeNumToId(DataPacket.BROADCAST) -> channelName
else -> viewModel.getUser(nodeId).long_name
}
}

View File

@@ -344,7 +344,7 @@ private fun RenderPagedChatMessageRow(
message.emojis.any { reaction ->
(
reaction.user.id == ourNode.user.id ||
reaction.user.id == org.meshtastic.core.model.DataPacket.ID_LOCAL
reaction.user.id == org.meshtastic.core.model.DataPacket.nodeNumToId(org.meshtastic.core.model.DataPacket.LOCAL)
) && reaction.emoji == emoji
}
if (!hasReacted) {

View File

@@ -195,9 +195,9 @@ class MessageViewModel(
}
}
fun getNode(userId: String?) = nodeRepository.getNode(userId ?: DataPacket.ID_BROADCAST)
fun getNode(userId: String?) = nodeRepository.getNode(userId ?: DataPacket.nodeNumToId(DataPacket.BROADCAST))
fun getUser(userId: String?) = nodeRepository.getUser(userId ?: DataPacket.ID_BROADCAST)
fun getUser(userId: String?) = nodeRepository.getUser(userId ?: DataPacket.nodeNumToId(DataPacket.BROADCAST))
/**
* Sends a message to a contact or channel.
@@ -212,7 +212,7 @@ class MessageViewModel(
* broadcasting on channel 0.
* @param replyId The ID of the message this is a reply to, if any.
*/
fun sendMessage(str: String, contactKey: String = "0${DataPacket.ID_BROADCAST}", replyId: Int? = null) {
fun sendMessage(str: String, contactKey: String = "0${DataPacket.nodeNumToId(DataPacket.BROADCAST)}", replyId: Int? = null) {
safeLaunch(tag = "sendMessage") { sendMessageUseCase.invoke(str, contactKey, replyId) }
}

View File

@@ -145,7 +145,8 @@ internal fun ReactionRow(
items(emojiGroups.entries.toList(), key = { it.key }) { entry ->
val emoji = entry.key
val reactions = entry.value
val localReaction = reactions.find { it.user.id == DataPacket.ID_LOCAL || it.user.id == myId }
val localReaction =
reactions.find { it.user.id == DataPacket.nodeNumToId(DataPacket.LOCAL) || it.user.id == myId }
ReactionItem(
emoji = emoji,
emojiCount = reactions.size,
@@ -231,7 +232,8 @@ internal fun ReactionDialog(
items(groupedEmojis.entries.toList(), key = { it.key }) { entry ->
val emoji = entry.key
val reactions = entry.value
val localReaction = reactions.find { it.user.id == DataPacket.ID_LOCAL || it.user.id == myId }
val localReaction =
reactions.find { it.user.id == DataPacket.nodeNumToId(DataPacket.LOCAL) || it.user.id == myId }
val isSending =
localReaction?.status == MessageStatus.QUEUED || localReaction?.status == MessageStatus.ENROUTE
Text(
@@ -263,7 +265,8 @@ internal fun ReactionDialog(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
val isLocal = reaction.user.id == myId || reaction.user.id == DataPacket.ID_LOCAL
val isLocal =
reaction.user.id == myId || reaction.user.id == DataPacket.nodeNumToId(DataPacket.LOCAL)
val displayName =
if (isLocal) {
"${reaction.user.long_name} (${stringResource(Res.string.you)})"

View File

@@ -75,12 +75,12 @@ class ContactsViewModel(
channelSet,
settings,
->
val (myNodeInfo, myId) = identity
val (myNodeInfo, _) = identity
val myNodeNum = myNodeInfo?.myNodeNum ?: return@combine emptyList<Contact>()
// Add empty channel placeholders (always show Broadcast contacts, even when empty)
val placeholder =
(0 until channelSet.settings.size).associate { ch ->
val contactKey = "$ch${DataPacket.ID_BROADCAST}"
val contactKey = "$ch${DataPacket.nodeNumToId(DataPacket.BROADCAST)}"
val data = DataPacket(bytes = null, dataType = 1, time = 0L, channel = ch)
contactKey to data
}
@@ -89,14 +89,13 @@ class ContactsViewModel(
val contactKey = entry.key
val packetData = entry.value
// Determine if this is my message (originated on this device)
val fromLocal =
(packetData.from == DataPacket.ID_LOCAL || (myId != null && packetData.from == myId))
val toBroadcast = packetData.to == DataPacket.ID_BROADCAST
val fromLocal = packetData.from == DataPacket.LOCAL || packetData.from == myNodeNum
val toBroadcast = packetData.to == DataPacket.BROADCAST
// grab usernames from NodeInfo
val userId = if (fromLocal) packetData.to else packetData.from
val user = nodeRepository.getUser(userId ?: DataPacket.ID_BROADCAST)
val node = nodeRepository.getNode(userId ?: DataPacket.ID_BROADCAST)
val userId = DataPacket.nodeNumToId(if (fromLocal) packetData.to else packetData.from)
val user = nodeRepository.getUser(userId)
val node = nodeRepository.getNode(userId)
val shortName = user.short_name
val longName =
@@ -129,31 +128,30 @@ class ContactsViewModel(
val contactListPaged: Flow<PagingData<Contact>> =
combine(identityFlow, channels, packetRepository.getContactSettings()) { identity, channelSet, settings ->
val (myNodeInfo, myId) = identity
ContactsPagedParams(myNodeInfo?.myNodeNum, channelSet, settings, myId)
val (myNodeInfo, _) = identity
ContactsPagedParams(myNodeInfo?.myNodeNum, channelSet, settings)
}
.flatMapLatest { params ->
val channelSet = params.channelSet
val settings = params.settings
val myId = params.myId
val myNodeNum = params.myNodeNum
packetRepository.getContactsPaged().map { pagingData ->
pagingData.map { packetData: DataPacket ->
// Determine if this is my message (originated on this device)
val fromLocal =
(packetData.from == DataPacket.ID_LOCAL || (myId != null && packetData.from == myId))
val toBroadcast = packetData.to == DataPacket.ID_BROADCAST
val fromLocal = packetData.from == DataPacket.LOCAL || packetData.from == myNodeNum
val toBroadcast = packetData.to == DataPacket.BROADCAST
// Reconstruct contactKey exactly as rememberDataPacket() computes it:
// For outgoing or broadcast: use the "to" field (recipient / ^all)
// For incoming DMs: use the "from" field (the other party)
val contactId = if (fromLocal || toBroadcast) packetData.to else packetData.from
val contactKey = "${packetData.channel}$contactId"
val contactKey = "${packetData.channel}${DataPacket.nodeNumToId(contactId)}"
// grab usernames from NodeInfo
val userId = if (fromLocal) packetData.to else packetData.from
val user = nodeRepository.getUser(userId ?: DataPacket.ID_BROADCAST)
val node = nodeRepository.getNode(userId ?: DataPacket.ID_BROADCAST)
val userId = DataPacket.nodeNumToId(if (fromLocal) packetData.to else packetData.from)
val user = nodeRepository.getUser(userId)
val node = nodeRepository.getNode(userId)
val shortName = user.short_name
val longName =
@@ -185,7 +183,7 @@ class ContactsViewModel(
}
.cachedIn(viewModelScope)
fun getNode(userId: String?) = nodeRepository.getNode(userId ?: DataPacket.ID_BROADCAST)
fun getNode(userId: String?) = nodeRepository.getNode(userId ?: DataPacket.nodeNumToId(DataPacket.BROADCAST))
fun deleteContacts(contacts: List<String>) =
safeLaunch(context = ioDispatcher, tag = "deleteContacts") { packetRepository.deleteContacts(contacts) }
@@ -218,6 +216,5 @@ class ContactsViewModel(
val myNodeNum: Int?,
val channelSet: ChannelSet,
val settings: Map<String, ContactSettings>,
val myId: String?,
)
}

View File

@@ -109,7 +109,9 @@ private fun ThisNodeStatusBadge(connectionState: ConnectionState, deviceType: De
stringResource(
when (connectionState) {
ConnectionState.Connected -> Res.string.connected
ConnectionState.Connecting -> Res.string.connecting
is ConnectionState.Connecting,
is ConnectionState.Configuring,
is ConnectionState.Reconnecting -> Res.string.connecting
ConnectionState.Disconnected -> Res.string.disconnected
ConnectionState.DeviceSleep -> Res.string.device_sleeping
},

View File

@@ -304,7 +304,9 @@ class LocalStatsWidget :
val statusText =
when (state.connectionState) {
is ConnectionState.Disconnected -> stringResource(Res.string.disconnected)
is ConnectionState.Connecting -> stringResource(Res.string.connecting)
is ConnectionState.Connecting,
is ConnectionState.Configuring,
is ConnectionState.Reconnecting -> stringResource(Res.string.connecting)
is ConnectionState.DeviceSleep -> stringResource(Res.string.device_sleeping)
is ConnectionState.Connected -> ""
}

View File

@@ -126,7 +126,10 @@ class LocalStatsWidgetStateProvider(nodeRepository: NodeRepository, serviceRepos
return LocalStatsWidgetUiState(
connectionState = connectionState,
isConnecting = connectionState is ConnectionState.Connecting,
isConnecting =
connectionState is ConnectionState.Connecting ||
connectionState is ConnectionState.Configuring ||
connectionState is ConnectionState.Reconnecting,
showContent = connectionState is ConnectionState.Connected,
nodeShortName = localNode?.user?.short_name,
nodeColors = localNode?.colors,