diff --git a/app/src/fdroid/kotlin/org/meshtastic/app/map/MapView.kt b/app/src/fdroid/kotlin/org/meshtastic/app/map/MapView.kt
index da85fc950..65040e2dc 100644
--- a/app/src/fdroid/kotlin/org/meshtastic/app/map/MapView.kt
+++ b/app/src/fdroid/kotlin/org/meshtastic/app/map/MapView.kt
@@ -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")
diff --git a/app/src/google/kotlin/org/meshtastic/app/map/MapViewModel.kt b/app/src/google/kotlin/org/meshtastic/app/map/MapViewModel.kt
index 8a4a798a8..5acd0b1a0 100644
--- a/app/src/google/kotlin/org/meshtastic/app/map/MapViewModel.kt
+++ b/app/src/google/kotlin/org/meshtastic/app/map/MapViewModel.kt
@@ -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 {
diff --git a/core/common/build.gradle.kts b/core/common/build.gradle.kts
index 1927104b4..6101977f4 100644
--- a/core/common/build.gradle.kts
+++ b/core/common/build.gradle.kts
@@ -15,6 +15,9 @@
* along with this program. If not, see .
*/
+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().configureEach {
+ javaLauncher.set(
+ javaToolchains.launcherFor {
+ languageVersion.set(JavaLanguageVersion.of(21))
+ },
+ )
+}
diff --git a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/LocationUtils.kt b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/LocationUtils.kt
index bdb13eac8..74810daac 100644
--- a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/LocationUtils.kt
+++ b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/LocationUtils.kt
@@ -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)
diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MessagePersistenceHandler.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MessagePersistenceHandler.kt
index 22793bf0e..3d4dd73e2 100644
--- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MessagePersistenceHandler.kt
+++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MessagePersistenceHandler.kt
@@ -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,
)
diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/StoreForwardPacketHandlerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/StoreForwardPacketHandlerImpl.kt
index 6de3fee62..daf1b7f81 100644
--- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/StoreForwardPacketHandlerImpl.kt
+++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/StoreForwardPacketHandlerImpl.kt
@@ -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)
diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/MessageDeliveryTracker.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/MessageDeliveryTracker.kt
new file mode 100644
index 000000000..e3237143a
--- /dev/null
+++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/MessageDeliveryTracker.kt
@@ -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 .
+ */
+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,
+ dispatchers: CoroutineDispatchers,
+) {
+ private val scope = CoroutineScope(SupervisorJob() + dispatchers.default)
+ private val activeHandles = mutableMapOf()
+ 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
+}
diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/SdkRadioController.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/SdkRadioController.kt
index c4fb269ff..2905a2a98 100644
--- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/SdkRadioController.kt
+++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/SdkRadioController.kt
@@ -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" }
diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/SdkStateBridge.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/SdkStateBridge.kt
index 19d7f98b3..943c8c142 100644
--- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/SdkStateBridge.kt
+++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/SdkStateBridge.kt
@@ -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)
}
}
}
diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/PacketRepositoryImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/PacketRepositoryImpl.kt
index 1e5a487df..d97699660 100644
--- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/PacketRepositoryImpl.kt
+++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/PacketRepositoryImpl.kt
@@ -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
diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/SdkNodeRepositoryImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/SdkNodeRepositoryImpl.kt
index 6f0a5c680..1eb28ecde 100644
--- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/SdkNodeRepositoryImpl.kt
+++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/SdkNodeRepositoryImpl.kt
@@ -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)
}
diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/SdkNodeRepositoryImplTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/SdkNodeRepositoryImplTest.kt
index 82313494f..1cccfc73f 100644
--- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/SdkNodeRepositoryImplTest.kt
+++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/SdkNodeRepositoryImplTest.kt
@@ -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
diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/StoreForwardPacketHandlerImplTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/StoreForwardPacketHandlerImplTest.kt
index 11916f60c..f84254504 100644
--- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/StoreForwardPacketHandlerImplTest.kt
+++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/StoreForwardPacketHandlerImplTest.kt
@@ -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,
)
diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/TelemetryPacketHandlerImplTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/TelemetryPacketHandlerImplTest.kt
index 49d583f94..6c013b697 100644
--- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/TelemetryPacketHandlerImplTest.kt
+++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/TelemetryPacketHandlerImplTest.kt
@@ -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,
)
diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/repository/CommonPacketRepositoryTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/repository/CommonPacketRepositoryTest.kt
index 147ed09bd..cffa154c9 100644
--- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/repository/CommonPacketRepositoryTest.kt
+++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/repository/CommonPacketRepositoryTest.kt
@@ -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)
diff --git a/core/database/src/androidHostTest/kotlin/org/meshtastic/core/database/dao/MigrationTest.kt b/core/database/src/androidHostTest/kotlin/org/meshtastic/core/database/dao/MigrationTest.kt
index dd6966a56..c06540a0b 100644
--- a/core/database/src/androidHostTest/kotlin/org/meshtastic/core/database/dao/MigrationTest.kt
+++ b/core/database/src/androidHostTest/kotlin/org/meshtastic/core/database/dao/MigrationTest.kt
@@ -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)
}
diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/Packet.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/Packet.kt
index 0a9ea4aa2..1bee41392 100644
--- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/Packet.kt
+++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/Packet.kt
@@ -37,8 +37,8 @@ data class PacketEntity(
val reactions: List = 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,
diff --git a/core/database/src/commonTest/kotlin/org/meshtastic/core/database/dao/CommonPacketDaoTest.kt b/core/database/src/commonTest/kotlin/org/meshtastic/core/database/dao/CommonPacketDaoTest.kt
index 5977e08a1..ad9bc6ed9 100644
--- a/core/database/src/commonTest/kotlin/org/meshtastic/core/database/dao/CommonPacketDaoTest.kt
+++ b/core/database/src/commonTest/kotlin/org/meshtastic/core/database/dao/CommonPacketDaoTest.kt
@@ -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,
),
diff --git a/core/model/build.gradle.kts b/core/model/build.gradle.kts
index d115947f4..f3ac1e4c6 100644
--- a/core/model/build.gradle.kts
+++ b/core/model/build.gradle.kts
@@ -15,6 +15,9 @@
* along with this program. If not, see .
*/
+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().configureEach {
+ javaLauncher.set(
+ javaToolchains.launcherFor {
+ languageVersion.set(JavaLanguageVersion.of(21))
+ },
+ )
+}
diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Capabilities.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Capabilities.kt
index 8dbccf69a..564877c03 100644
--- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Capabilities.kt
+++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Capabilities.kt
@@ -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)
}
diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/ChannelOption.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/ChannelOption.kt
index da6ae71cd..0338e76b7 100644
--- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/ChannelOption.kt
+++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/ChannelOption.kt
@@ -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() {
diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/ConnectionState.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/ConnectionState.kt
index c8bbdadb5..9967b6916 100644
--- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/ConnectionState.kt
+++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/ConnectionState.kt
@@ -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
}
diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/DataPacket.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/DataPacket.kt
index 4214dd62c..8ef21e45d 100644
--- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/DataPacket.kt
+++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/DataPacket.kt
@@ -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
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 {
+ 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())
}
}
diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/DataRequester.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/DataRequester.kt
new file mode 100644
index 000000000..981e5d8a8
--- /dev/null
+++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/DataRequester.kt
@@ -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 .
+ */
+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)
+}
diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/DeviceAdmin.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/DeviceAdmin.kt
new file mode 100644
index 000000000..6554a7da6
--- /dev/null
+++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/DeviceAdmin.kt
@@ -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 .
+ */
+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)
+}
diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/DeviceControl.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/DeviceControl.kt
new file mode 100644
index 000000000..1e42ec820
--- /dev/null
+++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/DeviceControl.kt
@@ -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 .
+ */
+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)
+}
diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/DeviceVersion.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/DeviceVersion.kt
index e77327d12..08fcc1b40 100644
--- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/DeviceVersion.kt
+++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/DeviceVersion.kt
@@ -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 {
+ 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"
diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/MessageSender.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/MessageSender.kt
new file mode 100644
index 000000000..897c2501c
--- /dev/null
+++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/MessageSender.kt
@@ -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 .
+ */
+package org.meshtastic.core.model
+
+/** Focused interface for sending messages over the mesh. */
+interface MessageSender {
+ suspend fun sendMessage(packet: DataPacket)
+ fun getPacketId(): Int
+}
diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/RadioController.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/RadioController.kt
index 84994e628..a79c745ba 100644
--- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/RadioController.kt
+++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/RadioController.kt
@@ -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
- /**
- * 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()
diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/RemoteAdmin.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/RemoteAdmin.kt
new file mode 100644
index 000000000..0773e4da4
--- /dev/null
+++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/RemoteAdmin.kt
@@ -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 .
+ */
+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)
+}
diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/MeshDataMapper.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/MeshDataMapper.kt
index 4df932c50..7d35a8e31 100644
--- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/MeshDataMapper.kt
+++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/MeshDataMapper.kt
@@ -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,
diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/SfppHasher.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/SfppHasher.kt
index f24919f02..3a6caf542 100644
--- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/SfppHasher.kt
+++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/SfppHasher.kt
@@ -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)
}
diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/PacketRepository.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/PacketRepository.kt
index 491c3e193..88de64629 100644
--- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/PacketRepository.kt
+++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/PacketRepository.kt
@@ -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)
diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/ServiceRepository.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/ServiceRepository.kt
index 5aefa697c..b41b71cda 100644
--- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/ServiceRepository.kt
+++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/ServiceRepository.kt
@@ -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
diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/usecase/SendMessageUseCase.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/usecase/SendMessageUseCase.kt
index 7fc17c2a9..0b13c67f0 100644
--- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/usecase/SendMessageUseCase.kt
+++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/usecase/SendMessageUseCase.kt
@@ -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
}
diff --git a/core/repository/src/commonTest/kotlin/org/meshtastic/core/repository/usecase/SendMessageUseCaseTest.kt b/core/repository/src/commonTest/kotlin/org/meshtastic/core/repository/usecase/SendMessageUseCaseTest.kt
index c65812c01..fc5a858e3 100644
--- a/core/repository/src/commonTest/kotlin/org/meshtastic/core/repository/usecase/SendMessageUseCaseTest.kt
+++ b/core/repository/src/commonTest/kotlin/org/meshtastic/core/repository/usecase/SendMessageUseCaseTest.kt
@@ -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.
diff --git a/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/SendMessageWorkerTest.kt b/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/SendMessageWorkerTest.kt
index 8a43a2a3d..fc1c713b9 100644
--- a/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/SendMessageWorkerTest.kt
+++ b/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/SendMessageWorkerTest.kt
@@ -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(), any()) } returns Unit
val worker =
TestListenableWorkerBuilder(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(), radioController.sentPackets)
- verifySuspend(mode = VerifyMode.exactly(0)) { packetRepository.updateMessageStatus(any(), any()) }
+ verifySuspend(mode = VerifyMode.exactly(0)) { packetRepository.updateMessageStatus(any(), any()) }
}
@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()) }
}
@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(), any()) } returns Unit
radioController.throwOnSend = true
val worker =
diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshServiceNotificationsImpl.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshServiceNotificationsImpl.kt
index 3bddd1291..7e074c90d 100644
--- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshServiceNotificationsImpl.kt
+++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshServiceNotificationsImpl.kt
@@ -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()
diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ReplyReceiver.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ReplyReceiver.kt
index c7f57eba2..44ef74ff4 100644
--- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ReplyReceiver.kt
+++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ReplyReceiver.kt
@@ -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)
}
}
diff --git a/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TAKMeshIntegration.kt b/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TAKMeshIntegration.kt
index 4b1097cc4..dffa2c46c 100644
--- a/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TAKMeshIntegration.kt
+++ b/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TAKMeshIntegration.kt
@@ -130,7 +130,7 @@ class TAKMeshIntegration(
val dataPacket =
DataPacket(
- to = DataPacket.ID_BROADCAST,
+ to = DataPacket.BROADCAST,
bytes = payload.toByteString(),
dataType = PortNum.ATAK_PLUGIN.value,
)
diff --git a/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/fountain/GenericCoTHandler.kt b/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/fountain/GenericCoTHandler.kt
index 374b46305..ec135a501 100644
--- a/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/fountain/GenericCoTHandler.kt
+++ b/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/fountain/GenericCoTHandler.kt
@@ -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,
)
diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/ConnectionsNavIcon.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/ConnectionsNavIcon.kt
index 5aed67880..3fbb11a09 100644
--- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/ConnectionsNavIcon.kt
+++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/ConnectionsNavIcon.kt
@@ -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
diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/MeshtasticNavigationSuite.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/MeshtasticNavigationSuite.kt
index f23f082b5..caf72b4c3 100644
--- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/MeshtasticNavigationSuite.kt
+++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/MeshtasticNavigationSuite.kt
@@ -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)
}
diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/ConnectionsViewModel.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/ConnectionsViewModel.kt
index f4d15d3d9..d72a30d74 100644
--- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/ConnectionsViewModel.kt
+++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/ConnectionsViewModel.kt
@@ -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
diff --git a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/ConnectionsScreen.kt b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/ConnectionsScreen.kt
index b52d5013d..0a180fd4b 100644
--- a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/ConnectionsScreen.kt
+++ b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/ConnectionsScreen.kt
@@ -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
diff --git a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/DeviceListItem.kt b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/DeviceListItem.kt
index cebc40724..60aa4d8b5 100644
--- a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/DeviceListItem.kt
+++ b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/DeviceListItem.kt
@@ -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)
diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/BaseMapViewModel.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/BaseMapViewModel.kt
index fdfd3f05a..455236e19 100644
--- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/BaseMapViewModel.kt
+++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/BaseMapViewModel.kt
@@ -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)
}
diff --git a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/Message.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/Message.kt
index e88a73077..d3c64bfde 100644
--- a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/Message.kt
+++ b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/Message.kt
@@ -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
}
}
diff --git a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/MessageListPaged.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/MessageListPaged.kt
index 3f92f3cbf..e9e4f45d9 100644
--- a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/MessageListPaged.kt
+++ b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/MessageListPaged.kt
@@ -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) {
diff --git a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/MessageViewModel.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/MessageViewModel.kt
index ca29b3842..85b746c05 100644
--- a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/MessageViewModel.kt
+++ b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/MessageViewModel.kt
@@ -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) }
}
diff --git a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/Reaction.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/Reaction.kt
index c08298e29..b3c7b8c56 100644
--- a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/Reaction.kt
+++ b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/Reaction.kt
@@ -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)})"
diff --git a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/ui/contact/ContactsViewModel.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/ui/contact/ContactsViewModel.kt
index d846ba260..9de72aac1 100644
--- a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/ui/contact/ContactsViewModel.kt
+++ b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/ui/contact/ContactsViewModel.kt
@@ -75,12 +75,12 @@ class ContactsViewModel(
channelSet,
settings,
->
- val (myNodeInfo, myId) = identity
+ val (myNodeInfo, _) = identity
val myNodeNum = myNodeInfo?.myNodeNum ?: return@combine emptyList()
// 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> =
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) =
safeLaunch(context = ioDispatcher, tag = "deleteContacts") { packetRepository.deleteContacts(contacts) }
@@ -218,6 +216,5 @@ class ContactsViewModel(
val myNodeNum: Int?,
val channelSet: ChannelSet,
val settings: Map,
- val myId: String?,
)
}
diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeStatusIcons.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeStatusIcons.kt
index 6151c7d36..de6e547e5 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeStatusIcons.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeStatusIcons.kt
@@ -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
},
diff --git a/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/LocalStatsWidget.kt b/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/LocalStatsWidget.kt
index d37194bd1..0436053d3 100644
--- a/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/LocalStatsWidget.kt
+++ b/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/LocalStatsWidget.kt
@@ -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 -> ""
}
diff --git a/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/LocalStatsWidgetState.kt b/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/LocalStatsWidgetState.kt
index f86acb8c1..a19f822eb 100644
--- a/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/LocalStatsWidgetState.kt
+++ b/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/LocalStatsWidgetState.kt
@@ -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,