From e9cb4398499c8e5b2ce3bb0b0b552cc286dbe106 Mon Sep 17 00:00:00 2001 From: James Rich Date: Tue, 5 May 2026 13:08:07 -0500 Subject: [PATCH] =?UTF-8?q?feat:=20rearchitect=20around=20SDK=20=E2=80=94?= =?UTF-8?q?=20decompose=20RadioController,=20simplify=20DataPacket,=20inte?= =?UTF-8?q?grate=20SDK=20utilities?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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> --- .../kotlin/org/meshtastic/app/map/MapView.kt | 4 +- .../org/meshtastic/app/map/MapViewModel.kt | 2 +- core/common/build.gradle.kts | 12 + .../core/common/util/LocationUtils.kt | 49 +--- .../data/manager/MessagePersistenceHandler.kt | 22 +- .../manager/StoreForwardPacketHandlerImpl.kt | 4 +- .../core/data/radio/MessageDeliveryTracker.kt | 88 ++++++ .../core/data/radio/SdkRadioController.kt | 29 +- .../core/data/radio/SdkStateBridge.kt | 36 ++- .../data/repository/PacketRepositoryImpl.kt | 22 +- .../data/repository/SdkNodeRepositoryImpl.kt | 12 +- .../data/manager/SdkNodeRepositoryImplTest.kt | 4 +- .../StoreForwardPacketHandlerImplTest.kt | 4 +- .../manager/TelemetryPacketHandlerImplTest.kt | 4 +- .../repository/CommonPacketRepositoryTest.kt | 2 +- .../core/database/dao/MigrationTest.kt | 2 +- .../meshtastic/core/database/entity/Packet.kt | 4 +- .../core/database/dao/CommonPacketDaoTest.kt | 18 +- core/model/build.gradle.kts | 12 + .../org/meshtastic/core/model/Capabilities.kt | 46 ++- .../meshtastic/core/model/ChannelOption.kt | 10 +- .../meshtastic/core/model/ConnectionState.kt | 19 +- .../org/meshtastic/core/model/DataPacket.kt | 79 +++-- .../meshtastic/core/model/DataRequester.kt | 26 ++ .../org/meshtastic/core/model/DeviceAdmin.kt | 28 ++ .../meshtastic/core/model/DeviceControl.kt | 28 ++ .../meshtastic/core/model/DeviceVersion.kt | 36 +-- .../meshtastic/core/model/MessageSender.kt | 23 ++ .../meshtastic/core/model/RadioController.kt | 269 +----------------- .../org/meshtastic/core/model/RemoteAdmin.kt | 40 +++ .../core/model/util/MeshDataMapper.kt | 4 +- .../meshtastic/core/model/util/SfppHasher.kt | 23 +- .../core/repository/PacketRepository.kt | 5 + .../core/repository/ServiceRepository.kt | 4 +- .../repository/usecase/SendMessageUseCase.kt | 19 +- .../usecase/SendMessageUseCaseTest.kt | 4 +- .../core/service/SendMessageWorkerTest.kt | 14 +- .../service/MeshServiceNotificationsImpl.kt | 10 +- .../meshtastic/core/service/ReplyReceiver.kt | 2 +- .../core/takserver/TAKMeshIntegration.kt | 2 +- .../takserver/fountain/GenericCoTHandler.kt | 6 +- .../core/ui/component/ConnectionsNavIcon.kt | 8 +- .../ui/component/MeshtasticNavigationSuite.kt | 4 +- .../core/ui/viewmodel/ConnectionsViewModel.kt | 4 +- .../connections/ui/ConnectionsScreen.kt | 4 +- .../ui/components/DeviceListItem.kt | 9 +- .../feature/map/BaseMapViewModel.kt | 6 +- .../meshtastic/feature/messaging/Message.kt | 2 +- .../feature/messaging/MessageListPaged.kt | 2 +- .../feature/messaging/MessageViewModel.kt | 6 +- .../feature/messaging/component/Reaction.kt | 9 +- .../messaging/ui/contact/ContactsViewModel.kt | 37 ++- .../feature/node/component/NodeStatusIcons.kt | 4 +- .../feature/widget/LocalStatsWidget.kt | 4 +- .../feature/widget/LocalStatsWidgetState.kt | 5 +- 55 files changed, 573 insertions(+), 558 deletions(-) create mode 100644 core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/MessageDeliveryTracker.kt create mode 100644 core/model/src/commonMain/kotlin/org/meshtastic/core/model/DataRequester.kt create mode 100644 core/model/src/commonMain/kotlin/org/meshtastic/core/model/DeviceAdmin.kt create mode 100644 core/model/src/commonMain/kotlin/org/meshtastic/core/model/DeviceControl.kt create mode 100644 core/model/src/commonMain/kotlin/org/meshtastic/core/model/MessageSender.kt create mode 100644 core/model/src/commonMain/kotlin/org/meshtastic/core/model/RemoteAdmin.kt 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,