mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-05-12 00:28:20 -04:00
feat: rearchitect around SDK — decompose RadioController, simplify DataPacket, integrate SDK utilities
Android rearchitecture consuming meshtastic-sdk improvements: A1 — ConnectionState Enrichment: - Rich sealed interface with Connecting(attempt), Configuring(phase, progress), Reconnecting(attempt) - SdkStateBridge maps SDK states preserving metadata A2 — MessageHandle Integration: - MessageDeliveryTracker: tracks delivery via SDK MessageHandle - SdkRadioController captures handles on send A3 — RadioController Decomposition: - Split into 5 focused interfaces: MessageSender, DeviceAdmin, RemoteAdmin, DeviceControl, DataRequester - RadioController extends all; SdkRadioController binds all via Koin A4 — DataPacket Simplification: - to/from fields changed from String? to Int (node numbers directly) - Removed string ID parsing layer; added BROADCAST/LOCAL constants - Updated ~40 consumer files across feature modules A5 — SDK Utility Consumption: - DeviceVersion, Capabilities, SfppHasher, LocationUtils delegate to SDK - Removed duplicated protocol logic A6 — Presence Events: - SdkStateBridge handles NodeChange.WentOffline/CameOnline - Updates node online status via repository Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -15,6 +15,9 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import org.gradle.api.tasks.testing.Test
|
||||
import org.gradle.jvm.toolchain.JavaLanguageVersion
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.meshtastic.kmp.library)
|
||||
alias(libs.plugins.kotlin.parcelize)
|
||||
@@ -34,6 +37,7 @@ kotlin {
|
||||
commonMain.dependencies {
|
||||
implementation(libs.kotlinx.atomicfu)
|
||||
implementation(libs.kotlinx.coroutines.core)
|
||||
implementation(libs.sdk.core)
|
||||
api(libs.kotlinx.datetime)
|
||||
api(libs.okio)
|
||||
api(libs.uri.kmp)
|
||||
@@ -44,3 +48,11 @@ kotlin {
|
||||
commonTest.dependencies { implementation(libs.kotlinx.coroutines.test) }
|
||||
}
|
||||
}
|
||||
|
||||
tasks.withType<Test>().configureEach {
|
||||
javaLauncher.set(
|
||||
javaToolchains.launcherFor {
|
||||
languageVersion.set(JavaLanguageVersion.of(21))
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.data.radio
|
||||
|
||||
import co.touchlab.kermit.Logger
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import org.koin.core.annotation.Single
|
||||
import org.meshtastic.core.di.CoroutineDispatchers
|
||||
import org.meshtastic.core.model.MessageStatus
|
||||
import org.meshtastic.core.repository.PacketRepository
|
||||
import org.meshtastic.sdk.MessageHandle
|
||||
import org.meshtastic.sdk.SendState
|
||||
|
||||
/**
|
||||
* Tracks in-flight message delivery via SDK [MessageHandle]s.
|
||||
* Maps SDK [SendState] transitions to app [MessageStatus] and persists updates.
|
||||
*/
|
||||
@Single
|
||||
class MessageDeliveryTracker(
|
||||
private val packetRepository: Lazy<PacketRepository>,
|
||||
dispatchers: CoroutineDispatchers,
|
||||
) {
|
||||
private val scope = CoroutineScope(SupervisorJob() + dispatchers.default)
|
||||
private val activeHandles = mutableMapOf<Int, MessageHandle>()
|
||||
private val activeHandlesMutex = Mutex()
|
||||
|
||||
/**
|
||||
* Begin tracking a [MessageHandle] for the given packet ID.
|
||||
* Observes state transitions and updates message status in the repository.
|
||||
*/
|
||||
fun track(packetId: Int, handle: MessageHandle) {
|
||||
scope.launch {
|
||||
activeHandlesMutex.withLock {
|
||||
activeHandles[packetId] = handle
|
||||
}
|
||||
|
||||
val repository = packetRepository.value
|
||||
handle.state
|
||||
.onEach { state ->
|
||||
val status = mapSendState(state)
|
||||
Logger.d { "[DeliveryTracker] Packet $packetId → $status" }
|
||||
repository.updateMessageStatus(packetId, status)
|
||||
}
|
||||
.first { state ->
|
||||
val terminal = state.isTerminal()
|
||||
if (terminal) {
|
||||
activeHandlesMutex.withLock {
|
||||
if (activeHandles[packetId] === handle) {
|
||||
activeHandles.remove(packetId)
|
||||
}
|
||||
}
|
||||
}
|
||||
terminal
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun mapSendState(state: SendState): MessageStatus = when (state) {
|
||||
SendState.Queued -> MessageStatus.QUEUED
|
||||
SendState.Sent -> MessageStatus.ENROUTE
|
||||
SendState.Acked -> MessageStatus.DELIVERED
|
||||
SendState.Delivered -> MessageStatus.DELIVERED
|
||||
is SendState.Failed -> MessageStatus.ERROR
|
||||
}
|
||||
|
||||
private fun SendState.isTerminal(): Boolean =
|
||||
this is SendState.Acked || this is SendState.Delivered || this is SendState.Failed
|
||||
}
|
||||
@@ -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" }
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -37,8 +37,8 @@ data class PacketEntity(
|
||||
val reactions: List<ReactionEntity> = emptyList(),
|
||||
) {
|
||||
suspend fun toMessage(getNode: suspend (userId: String?) -> Node) = with(packet) {
|
||||
val node = getNode(data.from)
|
||||
val isFromLocal = node.user.id == DataPacket.ID_LOCAL || (myNodeNum != 0 && node.num == myNodeNum)
|
||||
val node = getNode(DataPacket.nodeNumToId(data.from))
|
||||
val isFromLocal = data.from == DataPacket.LOCAL || (myNodeNum != 0 && data.from == myNodeNum)
|
||||
Message(
|
||||
uuid = uuid,
|
||||
receivedTime = received_time,
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -15,6 +15,9 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import org.gradle.api.tasks.testing.Test
|
||||
import org.gradle.jvm.toolchain.JavaLanguageVersion
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.meshtastic.kmp.library)
|
||||
alias(libs.plugins.meshtastic.kotlinx.serialization)
|
||||
@@ -38,6 +41,7 @@ kotlin {
|
||||
api(projects.core.common)
|
||||
api(projects.core.resources)
|
||||
|
||||
implementation(libs.sdk.core)
|
||||
api(libs.kotlinx.coroutines.core)
|
||||
api(libs.kotlinx.serialization.json)
|
||||
api(libs.kotlinx.datetime)
|
||||
@@ -72,3 +76,11 @@ publishing {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tasks.withType<Test>().configureEach {
|
||||
javaLauncher.set(
|
||||
javaToolchains.launcherFor {
|
||||
languageVersion.set(JavaLanguageVersion.of(21))
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -17,7 +17,17 @@
|
||||
package org.meshtastic.core.model
|
||||
|
||||
import co.touchlab.kermit.Logger
|
||||
import kotlinx.serialization.KSerializer
|
||||
import kotlinx.serialization.SerializationException
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.descriptors.PrimitiveKind
|
||||
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
|
||||
import kotlinx.serialization.descriptors.SerialDescriptor
|
||||
import kotlinx.serialization.encoding.Decoder
|
||||
import kotlinx.serialization.encoding.Encoder
|
||||
import kotlinx.serialization.json.JsonDecoder
|
||||
import kotlinx.serialization.json.intOrNull
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import okio.ByteString
|
||||
import okio.ByteString.Companion.toByteString
|
||||
import org.meshtastic.core.common.util.CommonIgnoredOnParcel
|
||||
@@ -25,13 +35,15 @@ import org.meshtastic.core.common.util.CommonParcel
|
||||
import org.meshtastic.core.common.util.CommonParcelable
|
||||
import org.meshtastic.core.common.util.CommonParcelize
|
||||
import org.meshtastic.core.common.util.CommonTypeParceler
|
||||
import org.meshtastic.core.common.util.formatString
|
||||
import org.meshtastic.core.common.util.nowMillis
|
||||
import org.meshtastic.core.model.util.ByteStringParceler
|
||||
import org.meshtastic.core.model.util.ByteStringSerializer
|
||||
import org.meshtastic.proto.MeshPacket
|
||||
import org.meshtastic.proto.PortNum
|
||||
import org.meshtastic.proto.Waypoint
|
||||
import org.meshtastic.sdk.NodeId
|
||||
import org.meshtastic.sdk.fromDefaultId
|
||||
import org.meshtastic.sdk.toDefaultId
|
||||
|
||||
@CommonParcelize
|
||||
enum class MessageStatus : CommonParcelable {
|
||||
@@ -49,13 +61,15 @@ enum class MessageStatus : CommonParcelable {
|
||||
@Serializable
|
||||
@CommonParcelize
|
||||
data class DataPacket(
|
||||
var to: String? = ID_BROADCAST, // a nodeID string, or ID_BROADCAST for broadcast
|
||||
@Serializable(with = NodeNumSerializer::class)
|
||||
var to: Int = BROADCAST,
|
||||
@Serializable(with = ByteStringSerializer::class)
|
||||
@CommonTypeParceler<ByteString?, ByteStringParceler>
|
||||
var bytes: ByteString?,
|
||||
// A port number for this packet
|
||||
var dataType: Int,
|
||||
var from: String? = ID_LOCAL, // a nodeID string, or ID_LOCAL for localhost
|
||||
@Serializable(with = NodeNumSerializer::class)
|
||||
var from: Int = LOCAL,
|
||||
var time: Long = nowMillis, // msecs since 1970
|
||||
var id: Int = 0, // 0 means unassigned
|
||||
var status: MessageStatus? = MessageStatus.UNKNOWN,
|
||||
@@ -78,10 +92,10 @@ data class DataPacket(
|
||||
) : CommonParcelable {
|
||||
|
||||
fun readFromParcel(parcel: CommonParcel) {
|
||||
to = parcel.readString()
|
||||
to = parcel.readInt()
|
||||
bytes = ByteStringParceler.create(parcel)
|
||||
dataType = parcel.readInt()
|
||||
from = parcel.readString()
|
||||
from = parcel.readInt()
|
||||
time = parcel.readLong()
|
||||
id = parcel.readInt()
|
||||
|
||||
@@ -121,7 +135,7 @@ data class DataPacket(
|
||||
|
||||
/** Syntactic sugar to make it easy to create text messages */
|
||||
constructor(
|
||||
to: String?,
|
||||
to: Int,
|
||||
channel: Int,
|
||||
text: String,
|
||||
replyId: Int? = null,
|
||||
@@ -151,7 +165,7 @@ data class DataPacket(
|
||||
}
|
||||
|
||||
constructor(
|
||||
to: String?,
|
||||
to: Int,
|
||||
channel: Int,
|
||||
waypoint: Waypoint,
|
||||
) : this(
|
||||
@@ -177,23 +191,48 @@ data class DataPacket(
|
||||
get() = if (hopStart == 0 || (hopLimit > hopStart)) -1 else hopStart - hopLimit
|
||||
|
||||
companion object {
|
||||
// Special node IDs that can be used for sending messages
|
||||
|
||||
/** the Node ID for broadcast destinations */
|
||||
const val ID_BROADCAST = "^all"
|
||||
|
||||
/** The Node ID for the local node - used for from when sender doesn't know our local node ID */
|
||||
const val ID_LOCAL = "^local"
|
||||
|
||||
// special broadcast address
|
||||
const val NODENUM_BROADCAST = (0xffffffff).toInt()
|
||||
const val BROADCAST: Int = 0xffffffff.toInt()
|
||||
const val LOCAL: Int = 0
|
||||
|
||||
// Public-key cryptography (PKC) channel index
|
||||
const val PKC_CHANNEL_INDEX = 8
|
||||
|
||||
fun nodeNumToDefaultId(n: Int): String = formatString("!%08x", n)
|
||||
/** Format a node number as the default display ID ("!aabbccdd"). */
|
||||
fun nodeNumToDefaultId(n: Int): String = NodeId(n).toDefaultId()
|
||||
|
||||
@Suppress("MagicNumber")
|
||||
fun idToDefaultNodeNum(id: String?): Int? = runCatching { id?.toLong(16)?.toInt() }.getOrNull()
|
||||
fun nodeNumToId(n: Int): String = when (n) {
|
||||
BROADCAST -> "^all"
|
||||
LOCAL -> "^local"
|
||||
else -> nodeNumToDefaultId(n)
|
||||
}
|
||||
|
||||
fun parseNodeNum(id: String): Int {
|
||||
val normalized = id.trim()
|
||||
return when {
|
||||
normalized.equals("^all", ignoreCase = true) -> BROADCAST
|
||||
normalized.equals("^local", ignoreCase = true) -> LOCAL
|
||||
else -> NodeId.fromDefaultId(normalized)?.raw
|
||||
?: NodeId.fromDefaultId("!$normalized")?.raw
|
||||
?: runCatching { normalized.toLong(16).toInt() }.getOrNull()
|
||||
?: throw SerializationException("Unsupported node id: $id")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private object NodeNumSerializer : KSerializer<Int> {
|
||||
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("NodeNum", PrimitiveKind.STRING)
|
||||
|
||||
override fun serialize(encoder: Encoder, value: Int) {
|
||||
encoder.encodeString(DataPacket.nodeNumToId(value))
|
||||
}
|
||||
|
||||
override fun deserialize(decoder: Decoder): Int {
|
||||
if (decoder is JsonDecoder) {
|
||||
val primitive = decoder.decodeJsonElement().jsonPrimitive
|
||||
primitive.intOrNull?.let { return it }
|
||||
return DataPacket.parseNodeNum(primitive.content)
|
||||
}
|
||||
return DataPacket.parseNodeNum(decoder.decodeString())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.model
|
||||
|
||||
/** Focused interface for requesting data from nodes. */
|
||||
interface DataRequester {
|
||||
suspend fun requestPosition(destNum: Int, currentPosition: Position)
|
||||
suspend fun requestUserInfo(destNum: Int)
|
||||
suspend fun requestTraceroute(requestId: Int, destNum: Int)
|
||||
suspend fun requestTelemetry(requestId: Int, destNum: Int, typeValue: Int)
|
||||
suspend fun requestNeighborInfo(requestId: Int, destNum: Int)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.model
|
||||
|
||||
import org.meshtastic.proto.Channel
|
||||
import org.meshtastic.proto.Config
|
||||
|
||||
/** Focused interface for local device configuration and edit sessions. */
|
||||
interface DeviceAdmin {
|
||||
suspend fun setLocalConfig(config: Config)
|
||||
suspend fun setLocalChannel(channel: Channel)
|
||||
suspend fun beginEditSettings(destNum: Int)
|
||||
suspend fun commitEditSettings(destNum: Int)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.model
|
||||
|
||||
/** Focused interface for device lifecycle control. */
|
||||
interface DeviceControl {
|
||||
suspend fun reboot(destNum: Int, packetId: Int)
|
||||
suspend fun rebootToDfu(nodeNum: Int)
|
||||
suspend fun requestRebootOta(requestId: Int, destNum: Int, mode: Int, hash: ByteArray?)
|
||||
suspend fun shutdown(destNum: Int, packetId: Int)
|
||||
suspend fun factoryReset(destNum: Int, packetId: Int)
|
||||
suspend fun nodedbReset(destNum: Int, packetId: Int, preserveFavorites: Boolean)
|
||||
suspend fun removeByNodenum(packetId: Int, nodeNum: Int)
|
||||
}
|
||||
@@ -16,42 +16,16 @@
|
||||
*/
|
||||
package org.meshtastic.core.model
|
||||
|
||||
import co.touchlab.kermit.Logger
|
||||
import org.meshtastic.sdk.DeviceVersion as SdkDeviceVersion
|
||||
|
||||
/** Provide structured access to parse and compare device version strings */
|
||||
data class DeviceVersion(val asString: String) : Comparable<DeviceVersion> {
|
||||
private val delegate = SdkDeviceVersion(asString)
|
||||
|
||||
/** The integer representation of the version (e.g., 2.7.12 -> 20712). Calculated once. */
|
||||
@Suppress("TooGenericExceptionCaught", "SwallowedException")
|
||||
val asInt: Int =
|
||||
try {
|
||||
verStringToInt(asString)
|
||||
} catch (e: Exception) {
|
||||
Logger.w { "Exception while parsing version '$asString', assuming version 0" }
|
||||
0
|
||||
}
|
||||
val asInt: Int
|
||||
get() = delegate.asInt
|
||||
|
||||
/**
|
||||
* Convert a version string of the form 1.23.57 to a comparable integer of the form 12357.
|
||||
*
|
||||
* Or throw an exception if the string can not be parsed
|
||||
*/
|
||||
@Suppress("TooGenericExceptionThrown", "MagicNumber")
|
||||
private fun verStringToInt(s: String): Int {
|
||||
// Allow 1 to two digits per match
|
||||
val versionString =
|
||||
if (s.split(".").size == 2) {
|
||||
"$s.0"
|
||||
} else {
|
||||
s
|
||||
}
|
||||
val match =
|
||||
Regex("(\\d{1,2}).(\\d{1,2}).(\\d{1,2})").find(versionString) ?: throw Exception("Can't parse version $s")
|
||||
val (major, minor, build) = match.destructured
|
||||
return major.toInt() * 10000 + minor.toInt() * 100 + build.toInt()
|
||||
}
|
||||
|
||||
override fun compareTo(other: DeviceVersion): Int = asInt.compareTo(other.asInt)
|
||||
override fun compareTo(other: DeviceVersion): Int = delegate.compareTo(other.delegate)
|
||||
|
||||
companion object {
|
||||
const val MIN_FW_VERSION = "2.5.14"
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.model
|
||||
|
||||
/** Focused interface for sending messages over the mesh. */
|
||||
interface MessageSender {
|
||||
suspend fun sendMessage(packet: DataPacket)
|
||||
fun getPacketId(): Int
|
||||
}
|
||||
@@ -20,14 +20,12 @@ import kotlinx.coroutines.flow.StateFlow
|
||||
import org.meshtastic.proto.ClientNotification
|
||||
|
||||
/**
|
||||
* Central interface for controlling the radio and mesh network.
|
||||
* Composite interface for all radio operations.
|
||||
*
|
||||
* This component provides an abstraction over the underlying communication transport (e.g., BLE, Serial, TCP) and the
|
||||
* low-level mesh protocols. It allows feature modules to interact with the mesh without needing to know about
|
||||
* platform-specific service details or AIDL interfaces.
|
||||
* Consumers should prefer the focused sub-interfaces (for example [MessageSender] and [RemoteAdmin]) for new code.
|
||||
* This super-interface remains for backward compatibility with existing injections.
|
||||
*/
|
||||
@Suppress("TooManyFunctions")
|
||||
interface RadioController {
|
||||
interface RadioController : MessageSender, DeviceAdmin, RemoteAdmin, DeviceControl, DataRequester {
|
||||
/**
|
||||
* Canonical app-level connection state, delegated from [ServiceRepository][connectionState].
|
||||
*
|
||||
@@ -47,13 +45,6 @@ interface RadioController {
|
||||
*/
|
||||
val clientNotification: StateFlow<ClientNotification?>
|
||||
|
||||
/**
|
||||
* Sends a data packet to the mesh.
|
||||
*
|
||||
* @param packet The [DataPacket] containing the payload and routing information.
|
||||
*/
|
||||
suspend fun sendMessage(packet: DataPacket)
|
||||
|
||||
/** Clears the current [clientNotification]. */
|
||||
fun clearClientNotification()
|
||||
|
||||
@@ -75,258 +66,6 @@ interface RadioController {
|
||||
*/
|
||||
suspend fun sendSharedContact(nodeNum: Int): Boolean
|
||||
|
||||
/**
|
||||
* Updates the local radio configuration.
|
||||
*
|
||||
* @param config The new configuration [org.meshtastic.proto.Config].
|
||||
*/
|
||||
suspend fun setLocalConfig(config: org.meshtastic.proto.Config)
|
||||
|
||||
/**
|
||||
* Updates a local radio channel.
|
||||
*
|
||||
* @param channel The channel configuration [org.meshtastic.proto.Channel].
|
||||
*/
|
||||
suspend fun setLocalChannel(channel: org.meshtastic.proto.Channel)
|
||||
|
||||
/**
|
||||
* Updates the owner (user info) on a remote node.
|
||||
*
|
||||
* @param destNum The destination node number.
|
||||
* @param user The new user info [org.meshtastic.proto.User].
|
||||
* @param packetId The request packet ID.
|
||||
*/
|
||||
suspend fun setOwner(destNum: Int, user: org.meshtastic.proto.User, packetId: Int)
|
||||
|
||||
/**
|
||||
* Updates the general configuration on a remote node.
|
||||
*
|
||||
* @param destNum The destination node number.
|
||||
* @param config The new configuration [org.meshtastic.proto.Config].
|
||||
* @param packetId The request packet ID.
|
||||
*/
|
||||
suspend fun setConfig(destNum: Int, config: org.meshtastic.proto.Config, packetId: Int)
|
||||
|
||||
/**
|
||||
* Updates a module configuration on a remote node.
|
||||
*
|
||||
* @param destNum The destination node number.
|
||||
* @param config The new module configuration [org.meshtastic.proto.ModuleConfig].
|
||||
* @param packetId The request packet ID.
|
||||
*/
|
||||
suspend fun setModuleConfig(destNum: Int, config: org.meshtastic.proto.ModuleConfig, packetId: Int)
|
||||
|
||||
/**
|
||||
* Updates a channel configuration on a remote node.
|
||||
*
|
||||
* @param destNum The destination node number.
|
||||
* @param channel The new channel configuration [org.meshtastic.proto.Channel].
|
||||
* @param packetId The request packet ID.
|
||||
*/
|
||||
suspend fun setRemoteChannel(destNum: Int, channel: org.meshtastic.proto.Channel, packetId: Int)
|
||||
|
||||
/**
|
||||
* Sets a fixed position on a remote node.
|
||||
*
|
||||
* @param destNum The destination node number.
|
||||
* @param position The position to set.
|
||||
*/
|
||||
suspend fun setFixedPosition(destNum: Int, position: Position)
|
||||
|
||||
/**
|
||||
* Updates the notification ringtone on a remote node.
|
||||
*
|
||||
* @param destNum The destination node number.
|
||||
* @param ringtone The name/ID of the ringtone.
|
||||
*/
|
||||
suspend fun setRingtone(destNum: Int, ringtone: String)
|
||||
|
||||
/**
|
||||
* Updates the canned messages configuration on a remote node.
|
||||
*
|
||||
* @param destNum The destination node number.
|
||||
* @param messages The canned messages string.
|
||||
*/
|
||||
suspend fun setCannedMessages(destNum: Int, messages: String)
|
||||
|
||||
/**
|
||||
* Requests the current owner (user info) from a remote node.
|
||||
*
|
||||
* @param destNum The remote node number.
|
||||
* @param packetId The request packet ID.
|
||||
*/
|
||||
suspend fun getOwner(destNum: Int, packetId: Int)
|
||||
|
||||
/**
|
||||
* Requests a specific configuration section from a remote node.
|
||||
*
|
||||
* @param destNum The remote node number.
|
||||
* @param configType The numeric type of the configuration section.
|
||||
* @param packetId The request packet ID.
|
||||
*/
|
||||
suspend fun getConfig(destNum: Int, configType: Int, packetId: Int)
|
||||
|
||||
/**
|
||||
* Requests a module configuration section from a remote node.
|
||||
*
|
||||
* @param destNum The remote node number.
|
||||
* @param moduleConfigType The numeric type of the module configuration section.
|
||||
* @param packetId The request packet ID.
|
||||
*/
|
||||
suspend fun getModuleConfig(destNum: Int, moduleConfigType: Int, packetId: Int)
|
||||
|
||||
/**
|
||||
* Requests a specific channel configuration from a remote node.
|
||||
*
|
||||
* @param destNum The remote node number.
|
||||
* @param index The channel index.
|
||||
* @param packetId The request packet ID.
|
||||
*/
|
||||
suspend fun getChannel(destNum: Int, index: Int, packetId: Int)
|
||||
|
||||
/**
|
||||
* Requests the current ringtone from a remote node.
|
||||
*
|
||||
* @param destNum The remote node number.
|
||||
* @param packetId The request packet ID.
|
||||
*/
|
||||
suspend fun getRingtone(destNum: Int, packetId: Int)
|
||||
|
||||
/**
|
||||
* Requests the current canned messages from a remote node.
|
||||
*
|
||||
* @param destNum The remote node number.
|
||||
* @param packetId The request packet ID.
|
||||
*/
|
||||
suspend fun getCannedMessages(destNum: Int, packetId: Int)
|
||||
|
||||
/**
|
||||
* Requests the hardware connection status from a remote node.
|
||||
*
|
||||
* @param destNum The remote node number.
|
||||
* @param packetId The request packet ID.
|
||||
*/
|
||||
suspend fun getDeviceConnectionStatus(destNum: Int, packetId: Int)
|
||||
|
||||
/**
|
||||
* Commands a node to reboot.
|
||||
*
|
||||
* @param destNum The target node number.
|
||||
* @param packetId The request packet ID.
|
||||
*/
|
||||
suspend fun reboot(destNum: Int, packetId: Int)
|
||||
|
||||
/**
|
||||
* Commands a node to reboot into DFU (Device Firmware Update) mode.
|
||||
*
|
||||
* @param nodeNum The target node number.
|
||||
*/
|
||||
suspend fun rebootToDfu(nodeNum: Int)
|
||||
|
||||
/**
|
||||
* Initiates an Over-The-Air (OTA) reboot request.
|
||||
*
|
||||
* @param requestId The request ID.
|
||||
* @param destNum The target node number.
|
||||
* @param mode The OTA mode.
|
||||
* @param hash Optional hash for verification.
|
||||
*/
|
||||
suspend fun requestRebootOta(requestId: Int, destNum: Int, mode: Int, hash: ByteArray?)
|
||||
|
||||
/**
|
||||
* Commands a node to shut down.
|
||||
*
|
||||
* @param destNum The target node number.
|
||||
* @param packetId The request packet ID.
|
||||
*/
|
||||
suspend fun shutdown(destNum: Int, packetId: Int)
|
||||
|
||||
/**
|
||||
* Performs a factory reset on a node.
|
||||
*
|
||||
* @param destNum The target node number.
|
||||
* @param packetId The request packet ID.
|
||||
*/
|
||||
suspend fun factoryReset(destNum: Int, packetId: Int)
|
||||
|
||||
/**
|
||||
* Resets the NodeDB on a node.
|
||||
*
|
||||
* @param destNum The target node number.
|
||||
* @param packetId The request packet ID.
|
||||
* @param preserveFavorites Whether to keep favorite nodes in the database.
|
||||
*/
|
||||
suspend fun nodedbReset(destNum: Int, packetId: Int, preserveFavorites: Boolean)
|
||||
|
||||
/**
|
||||
* Removes a node from the mesh by its node number.
|
||||
*
|
||||
* @param packetId The request packet ID.
|
||||
* @param nodeNum The node number to remove.
|
||||
*/
|
||||
suspend fun removeByNodenum(packetId: Int, nodeNum: Int)
|
||||
|
||||
/**
|
||||
* Requests the current GPS position from a remote node.
|
||||
*
|
||||
* @param destNum The target node number.
|
||||
* @param currentPosition Our current position to provide in the request.
|
||||
*/
|
||||
suspend fun requestPosition(destNum: Int, currentPosition: Position)
|
||||
|
||||
/**
|
||||
* Requests detailed user info from a remote node.
|
||||
*
|
||||
* @param destNum The target node number.
|
||||
*/
|
||||
suspend fun requestUserInfo(destNum: Int)
|
||||
|
||||
/**
|
||||
* Initiates a traceroute request to a remote node.
|
||||
*
|
||||
* @param requestId The request ID.
|
||||
* @param destNum The destination node number.
|
||||
*/
|
||||
suspend fun requestTraceroute(requestId: Int, destNum: Int)
|
||||
|
||||
/**
|
||||
* Requests telemetry data from a remote node.
|
||||
*
|
||||
* @param requestId The request ID.
|
||||
* @param destNum The destination node number.
|
||||
* @param typeValue The numeric type of telemetry requested.
|
||||
*/
|
||||
suspend fun requestTelemetry(requestId: Int, destNum: Int, typeValue: Int)
|
||||
|
||||
/**
|
||||
* Requests neighbor information (detected nodes) from a remote node.
|
||||
*
|
||||
* @param requestId The request ID.
|
||||
* @param destNum The destination node number.
|
||||
*/
|
||||
suspend fun requestNeighborInfo(requestId: Int, destNum: Int)
|
||||
|
||||
/**
|
||||
* Signals the start of a batch configuration session.
|
||||
*
|
||||
* @param destNum The target node number.
|
||||
*/
|
||||
suspend fun beginEditSettings(destNum: Int)
|
||||
|
||||
/**
|
||||
* Commits all pending configuration changes in a batch session.
|
||||
*
|
||||
* @param destNum The target node number.
|
||||
*/
|
||||
suspend fun commitEditSettings(destNum: Int)
|
||||
|
||||
/**
|
||||
* Generates a unique packet ID for a new request.
|
||||
*
|
||||
* @return A unique 32-bit integer.
|
||||
*/
|
||||
fun getPacketId(): Int
|
||||
|
||||
/** Starts providing the phone's location to the mesh. */
|
||||
fun startProvideLocation()
|
||||
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.model
|
||||
|
||||
import org.meshtastic.proto.Channel
|
||||
import org.meshtastic.proto.Config
|
||||
import org.meshtastic.proto.ModuleConfig
|
||||
import org.meshtastic.proto.User
|
||||
|
||||
/** Focused interface for remote node administration. */
|
||||
interface RemoteAdmin {
|
||||
suspend fun setOwner(destNum: Int, user: User, packetId: Int)
|
||||
suspend fun setConfig(destNum: Int, config: Config, packetId: Int)
|
||||
suspend fun setModuleConfig(destNum: Int, config: ModuleConfig, packetId: Int)
|
||||
suspend fun setRemoteChannel(destNum: Int, channel: Channel, packetId: Int)
|
||||
suspend fun setFixedPosition(destNum: Int, position: Position)
|
||||
suspend fun setRingtone(destNum: Int, ringtone: String)
|
||||
suspend fun setCannedMessages(destNum: Int, messages: String)
|
||||
suspend fun getOwner(destNum: Int, packetId: Int)
|
||||
suspend fun getConfig(destNum: Int, configType: Int, packetId: Int)
|
||||
suspend fun getModuleConfig(destNum: Int, moduleConfigType: Int, packetId: Int)
|
||||
suspend fun getChannel(destNum: Int, index: Int, packetId: Int)
|
||||
suspend fun getRingtone(destNum: Int, packetId: Int)
|
||||
suspend fun getCannedMessages(destNum: Int, packetId: Int)
|
||||
suspend fun getDeviceConnectionStatus(destNum: Int, packetId: Int)
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -46,8 +46,10 @@ interface ServiceRepository {
|
||||
*
|
||||
* State transitions are managed by [SdkStateBridge], which maps SDK connection events into app-level transitions:
|
||||
* - [ConnectionState.Disconnected] — no active connection to a radio
|
||||
* - [ConnectionState.Connecting] — transport is up, mesh handshake (config + node-info) in progress
|
||||
* - [ConnectionState.Connecting] — transport establishment is in progress
|
||||
* - [ConnectionState.Configuring] — transport is up and mesh handshake/config sync is in progress
|
||||
* - [ConnectionState.Connected] — handshake complete, radio fully operational
|
||||
* - [ConnectionState.Reconnecting] — connection dropped and automatic retry is in progress
|
||||
* - [ConnectionState.DeviceSleep] — radio entered light-sleep (transient disconnect)
|
||||
*/
|
||||
val connectionState: StateFlow<ConnectionState>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -64,9 +64,9 @@ class SendMessageWorkerTest {
|
||||
fun `doWork returns success when packet is sent successfully`() = runTest {
|
||||
// Arrange
|
||||
val packetId = 12345
|
||||
val dataPacket = DataPacket(to = "dest", bytes = "Hello".encodeToByteArray().toByteString(), dataType = 0)
|
||||
val dataPacket = DataPacket(to = 1234, bytes = "Hello".encodeToByteArray().toByteString(), dataType = 0)
|
||||
everySuspend { packetRepository.getPacketByPacketId(packetId) } returns dataPacket
|
||||
everySuspend { packetRepository.updateMessageStatus(any(), any()) } returns Unit
|
||||
everySuspend { packetRepository.updateMessageStatus(any<DataPacket>(), any<MessageStatus>()) } returns Unit
|
||||
|
||||
val worker =
|
||||
TestListenableWorkerBuilder<SendMessageWorker>(context)
|
||||
@@ -96,7 +96,7 @@ class SendMessageWorkerTest {
|
||||
fun `doWork returns retry when radio is disconnected`() = runTest {
|
||||
// Arrange
|
||||
val packetId = 12345
|
||||
val dataPacket = DataPacket(to = "dest", bytes = "Hello".encodeToByteArray().toByteString(), dataType = 0)
|
||||
val dataPacket = DataPacket(to = 1234, bytes = "Hello".encodeToByteArray().toByteString(), dataType = 0)
|
||||
everySuspend { packetRepository.getPacketByPacketId(packetId) } returns dataPacket
|
||||
radioController.setConnectionState(ConnectionState.Disconnected)
|
||||
|
||||
@@ -121,7 +121,7 @@ class SendMessageWorkerTest {
|
||||
// Assert
|
||||
assertEquals(ListenableWorker.Result.retry(), result)
|
||||
assertEquals(emptyList<DataPacket>(), radioController.sentPackets)
|
||||
verifySuspend(mode = VerifyMode.exactly(0)) { packetRepository.updateMessageStatus(any(), any()) }
|
||||
verifySuspend(mode = VerifyMode.exactly(0)) { packetRepository.updateMessageStatus(any<DataPacket>(), any<MessageStatus>()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -143,15 +143,15 @@ class SendMessageWorkerTest {
|
||||
val result = worker.doWork()
|
||||
|
||||
assertEquals(ListenableWorker.Result.failure(), result)
|
||||
verifySuspend(mode = VerifyMode.exactly(0)) { packetRepository.getPacketByPacketId(any()) }
|
||||
verifySuspend(mode = VerifyMode.exactly(0)) { packetRepository.getPacketByPacketId(any<Int>()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `doWork returns retry and marks queued when send throws`() = runTest {
|
||||
val packetId = 12345
|
||||
val dataPacket = DataPacket(to = "dest", bytes = "Hello".encodeToByteArray().toByteString(), dataType = 0)
|
||||
val dataPacket = DataPacket(to = 1234, bytes = "Hello".encodeToByteArray().toByteString(), dataType = 0)
|
||||
everySuspend { packetRepository.getPacketByPacketId(packetId) } returns dataPacket
|
||||
everySuspend { packetRepository.updateMessageStatus(any(), any()) } returns Unit
|
||||
everySuspend { packetRepository.updateMessageStatus(any<DataPacket>(), any<MessageStatus>()) } returns Unit
|
||||
radioController.throwOnSend = true
|
||||
|
||||
val worker =
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,7 +130,7 @@ class TAKMeshIntegration(
|
||||
|
||||
val dataPacket =
|
||||
DataPacket(
|
||||
to = DataPacket.ID_BROADCAST,
|
||||
to = DataPacket.BROADCAST,
|
||||
bytes = payload.toByteString(),
|
||||
dataType = PortNum.ATAK_PLUGIN.value,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
|
||||
|
||||
@@ -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)})"
|
||||
|
||||
@@ -75,12 +75,12 @@ class ContactsViewModel(
|
||||
channelSet,
|
||||
settings,
|
||||
->
|
||||
val (myNodeInfo, myId) = identity
|
||||
val (myNodeInfo, _) = identity
|
||||
val myNodeNum = myNodeInfo?.myNodeNum ?: return@combine emptyList<Contact>()
|
||||
// Add empty channel placeholders (always show Broadcast contacts, even when empty)
|
||||
val placeholder =
|
||||
(0 until channelSet.settings.size).associate { ch ->
|
||||
val contactKey = "$ch${DataPacket.ID_BROADCAST}"
|
||||
val contactKey = "$ch${DataPacket.nodeNumToId(DataPacket.BROADCAST)}"
|
||||
val data = DataPacket(bytes = null, dataType = 1, time = 0L, channel = ch)
|
||||
contactKey to data
|
||||
}
|
||||
@@ -89,14 +89,13 @@ class ContactsViewModel(
|
||||
val contactKey = entry.key
|
||||
val packetData = entry.value
|
||||
// Determine if this is my message (originated on this device)
|
||||
val fromLocal =
|
||||
(packetData.from == DataPacket.ID_LOCAL || (myId != null && packetData.from == myId))
|
||||
val toBroadcast = packetData.to == DataPacket.ID_BROADCAST
|
||||
val fromLocal = packetData.from == DataPacket.LOCAL || packetData.from == myNodeNum
|
||||
val toBroadcast = packetData.to == DataPacket.BROADCAST
|
||||
|
||||
// grab usernames from NodeInfo
|
||||
val userId = if (fromLocal) packetData.to else packetData.from
|
||||
val user = nodeRepository.getUser(userId ?: DataPacket.ID_BROADCAST)
|
||||
val node = nodeRepository.getNode(userId ?: DataPacket.ID_BROADCAST)
|
||||
val userId = DataPacket.nodeNumToId(if (fromLocal) packetData.to else packetData.from)
|
||||
val user = nodeRepository.getUser(userId)
|
||||
val node = nodeRepository.getNode(userId)
|
||||
|
||||
val shortName = user.short_name
|
||||
val longName =
|
||||
@@ -129,31 +128,30 @@ class ContactsViewModel(
|
||||
|
||||
val contactListPaged: Flow<PagingData<Contact>> =
|
||||
combine(identityFlow, channels, packetRepository.getContactSettings()) { identity, channelSet, settings ->
|
||||
val (myNodeInfo, myId) = identity
|
||||
ContactsPagedParams(myNodeInfo?.myNodeNum, channelSet, settings, myId)
|
||||
val (myNodeInfo, _) = identity
|
||||
ContactsPagedParams(myNodeInfo?.myNodeNum, channelSet, settings)
|
||||
}
|
||||
.flatMapLatest { params ->
|
||||
val channelSet = params.channelSet
|
||||
val settings = params.settings
|
||||
val myId = params.myId
|
||||
val myNodeNum = params.myNodeNum
|
||||
|
||||
packetRepository.getContactsPaged().map { pagingData ->
|
||||
pagingData.map { packetData: DataPacket ->
|
||||
// Determine if this is my message (originated on this device)
|
||||
val fromLocal =
|
||||
(packetData.from == DataPacket.ID_LOCAL || (myId != null && packetData.from == myId))
|
||||
val toBroadcast = packetData.to == DataPacket.ID_BROADCAST
|
||||
val fromLocal = packetData.from == DataPacket.LOCAL || packetData.from == myNodeNum
|
||||
val toBroadcast = packetData.to == DataPacket.BROADCAST
|
||||
|
||||
// Reconstruct contactKey exactly as rememberDataPacket() computes it:
|
||||
// For outgoing or broadcast: use the "to" field (recipient / ^all)
|
||||
// For incoming DMs: use the "from" field (the other party)
|
||||
val contactId = if (fromLocal || toBroadcast) packetData.to else packetData.from
|
||||
val contactKey = "${packetData.channel}$contactId"
|
||||
val contactKey = "${packetData.channel}${DataPacket.nodeNumToId(contactId)}"
|
||||
|
||||
// grab usernames from NodeInfo
|
||||
val userId = if (fromLocal) packetData.to else packetData.from
|
||||
val user = nodeRepository.getUser(userId ?: DataPacket.ID_BROADCAST)
|
||||
val node = nodeRepository.getNode(userId ?: DataPacket.ID_BROADCAST)
|
||||
val userId = DataPacket.nodeNumToId(if (fromLocal) packetData.to else packetData.from)
|
||||
val user = nodeRepository.getUser(userId)
|
||||
val node = nodeRepository.getNode(userId)
|
||||
|
||||
val shortName = user.short_name
|
||||
val longName =
|
||||
@@ -185,7 +183,7 @@ class ContactsViewModel(
|
||||
}
|
||||
.cachedIn(viewModelScope)
|
||||
|
||||
fun getNode(userId: String?) = nodeRepository.getNode(userId ?: DataPacket.ID_BROADCAST)
|
||||
fun getNode(userId: String?) = nodeRepository.getNode(userId ?: DataPacket.nodeNumToId(DataPacket.BROADCAST))
|
||||
|
||||
fun deleteContacts(contacts: List<String>) =
|
||||
safeLaunch(context = ioDispatcher, tag = "deleteContacts") { packetRepository.deleteContacts(contacts) }
|
||||
@@ -218,6 +216,5 @@ class ContactsViewModel(
|
||||
val myNodeNum: Int?,
|
||||
val channelSet: ChannelSet,
|
||||
val settings: Map<String, ContactSettings>,
|
||||
val myId: String?,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
@@ -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 -> ""
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user